diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000..bd13d704b5d79 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,22 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node +{ + "name": "Node.js & TypeScript", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye" + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "pnpm install", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.eslintrc.base.json b/.eslintrc.base.json deleted file mode 100644 index 53d62908af6c3..0000000000000 --- a/.eslintrc.base.json +++ /dev/null @@ -1,336 +0,0 @@ -{ - "env": { - "es6": true - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking", - "plugin:import/recommended", - "plugin:import/typescript", - "prettier" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module", - "ecmaFeatures": { - "impliedStrict": true - }, - "project": true - }, - "plugins": ["anti-trojan-source", "import", "@typescript-eslint"], - "reportUnusedDisableDirectives": true, - "root": true, - "rules": { - "anti-trojan-source/no-bidi": "error", - "arrow-parens": ["off"], - "brace-style": ["off", "stroustrup"], - "consistent-return": "off", - "curly": ["error", "multi-line", "consistent"], - "eol-last": "error", - "linebreak-style": ["error", "unix"], - "new-parens": "error", - "no-console": "off", - "no-constant-condition": ["warn", { "checkLoops": false }], - "no-constant-binary-expression": "error", - "no-caller": "error", - "no-debugger": "off", - "no-dupe-class-members": "off", - "no-else-return": "warn", - "no-empty": ["warn", { "allowEmptyCatch": true }], - "no-eval": "error", - "no-ex-assign": "warn", - "no-extend-native": "error", - "no-extra-bind": "error", - "no-floating-decimal": "error", - "no-implicit-coercion": "error", - "no-implied-eval": "error", - // Turn off until fix for: https://github.com/typescript-eslint/typescript-eslint/issues/239 - "no-inner-declarations": "off", - "no-lone-blocks": "error", - "no-lonely-if": "error", - "no-loop-func": "error", - "no-multi-spaces": "error", - "no-restricted-globals": ["error", "process"], - "no-restricted-imports": [ - "error", - { - "paths": [ - "lodash", - "lodash-es", - // Disallow node imports below - "assert", - "buffer", - "child_process", - "cluster", - "crypto", - "dgram", - "dns", - "domain", - "events", - "freelist", - "fs", - "http", - "https", - "module", - "net", - "os", - "path", - "process", - "punycode", - "querystring", - "readline", - "repl", - "smalloc", - "stream", - "string_decoder", - "sys", - "timers", - "tls", - "tracing", - "tty", - "url", - "util", - "vm", - "zlib" - ], - "patterns": [ - { - "group": ["**/env/**/*"], - "message": "Use @env/ instead" - }, - { - "group": ["src/**/*"], - "message": "Use relative paths instead" - } - ] - } - ], - "no-return-assign": "error", - "no-return-await": "warn", - "no-self-compare": "error", - "no-sequences": "error", - "no-template-curly-in-string": "warn", - "no-throw-literal": "error", - "no-unmodified-loop-condition": "warn", - "no-unneeded-ternary": "error", - "no-use-before-define": "off", - "no-useless-call": "error", - "no-useless-catch": "error", - "no-useless-computed-key": "error", - "no-useless-concat": "error", - "no-useless-rename": "error", - "no-useless-return": "error", - "no-var": "error", - "no-with": "error", - "object-shorthand": ["error", "never"], - "one-var": ["error", "never"], - "prefer-arrow-callback": "error", - "prefer-const": [ - "error", - { - "destructuring": "all", - "ignoreReadBeforeAssign": false - } - ], - "prefer-numeric-literals": "error", - "prefer-object-spread": "error", - "prefer-rest-params": "error", - "prefer-spread": "error", - "prefer-template": "error", - "quotes": ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": true }], - // Turn off until fix for: https://github.com/eslint/eslint/issues/11899 - "require-atomic-updates": "off", - "semi": ["error", "always"], - "semi-style": ["error", "last"], - "sort-imports": [ - "error", - { - "ignoreCase": true, - "ignoreDeclarationSort": true, - "ignoreMemberSort": false, - "memberSyntaxSortOrder": ["none", "all", "multiple", "single"] - } - ], - "yoda": "error", - "import/export": "off", - "import/extensions": ["error", "never"], - "import/named": "off", - "import/namespace": "off", - "import/newline-after-import": "warn", - "import/no-absolute-path": "error", - "import/no-cycle": "off", - "import/no-default-export": "error", - "import/no-duplicates": "error", - "import/no-dynamic-require": "error", - "import/no-self-import": "error", - "import/no-unresolved": ["warn", { "ignore": ["vscode", "@env"] }], - "import/no-useless-path-segments": "error", - "import/order": [ - "warn", - { - "alphabetize": { - "order": "asc", - "orderImportKind": "asc", - "caseInsensitive": true - }, - "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], - "newlines-between": "never" - } - ], - "@typescript-eslint/ban-types": [ - "error", - { - "extendDefaults": false, - "types": { - "String": { - "message": "Use string instead", - "fixWith": "string" - }, - "Boolean": { - "message": "Use boolean instead", - "fixWith": "boolean" - }, - "Number": { - "message": "Use number instead", - "fixWith": "number" - }, - "Symbol": { - "message": "Use symbol instead", - "fixWith": "symbol" - }, - // "Function": { - // "message": "The `Function` type accepts any function-like value.\nIt provides no type safety when calling the function, which can be a common source of bugs.\nIt also accepts things like class declarations, which will throw at runtime as they will not be called with `new`.\nIf you are expecting the function to accept certain arguments, you should explicitly define the function shape." - // }, - "Object": { - "message": "The `Object` type actually means \"any non-nullish value\", so it is marginally better than `unknown`.\n- If you want a type meaning \"any object\", you probably want `Record` instead.\n- If you want a type meaning \"any value\", you probably want `unknown` instead." - }, - "{}": { - "message": "`{}` actually means \"any non-nullish value\".\n- If you want a type meaning \"any object\", you probably want `object` or `Record` instead.\n- If you want a type meaning \"any value\", you probably want `unknown` instead.", - "fixWith": "object" - } - // "object": { - // "message": "The `object` type is currently hard to use ([see this issue](https://github.com/microsoft/TypeScript/issues/21732)).\nConsider using `Record` instead, as it allows you to more easily inspect and use the keys." - // } - } - } - ], - "@typescript-eslint/consistent-type-assertions": [ - "error", - { - "assertionStyle": "as", - "objectLiteralTypeAssertions": "allow-as-parameter" - } - ], - "@typescript-eslint/consistent-type-imports": ["error", { "disallowTypeAnnotations": false }], - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-member-accessibility": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", // TODO@eamodio revisit - "@typescript-eslint/naming-convention": [ - "error", - { - "selector": "variable", - "format": ["camelCase", "PascalCase"], - "leadingUnderscore": "allow", - "filter": { - "regex": "^_$", - "match": false - } - }, - { - "selector": "variableLike", - "format": ["camelCase"], - "leadingUnderscore": "allow", - "filter": { - "regex": "^_$", - "match": false - } - }, - { - "selector": "memberLike", - "modifiers": ["private"], - "format": ["camelCase"], - "leadingUnderscore": "allow" - }, - { - "selector": "memberLike", - "modifiers": ["private", "readonly"], - "format": ["camelCase", "PascalCase"], - "leadingUnderscore": "allow" - }, - { - "selector": "memberLike", - "modifiers": ["static", "readonly"], - "format": ["camelCase", "PascalCase"] - }, - { - "selector": "interface", - "format": ["PascalCase"], - "custom": { - "regex": "^I[A-Z]", - "match": false - } - } - ], - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/no-empty-interface": "error", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-extraneous-class": "error", - "@typescript-eslint/no-floating-promises": "error", - "@typescript-eslint/no-inferrable-types": ["warn", { "ignoreParameters": true, "ignoreProperties": true }], - "@typescript-eslint/no-meaningless-void-operator": "error", - "@typescript-eslint/no-misused-promises": ["error", { "checksVoidReturn": false }], - "@typescript-eslint/no-namespace": "off", // TODO: Should aim to enable this - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-non-null-asserted-nullish-coalescing": "error", - "@typescript-eslint/no-throw-literal": "error", - // "@typescript-eslint/no-unnecessary-condition": ["error", { "allowConstantLoopConditions": true }], - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-return": "error", - "@typescript-eslint/no-unused-expressions": ["warn", { "allowShortCircuit": true }], - "@typescript-eslint/no-unused-vars": [ - "warn", - { - "args": "after-used", - "argsIgnorePattern": "^_", - "ignoreRestSiblings": true, - "varsIgnorePattern": "^_$" - } - ], - "@typescript-eslint/non-nullable-type-assertion-style": "error", - "@typescript-eslint/prefer-for-of": "warn", - "@typescript-eslint/prefer-includes": "warn", - "@typescript-eslint/prefer-literal-enum-member": ["warn", { "allowBitwiseExpressions": true }], - "@typescript-eslint/prefer-nullish-coalescing": "off", // warn - "@typescript-eslint/prefer-optional-chain": "warn", - "@typescript-eslint/prefer-reduce-type-parameter": "warn", - "@typescript-eslint/restrict-template-expressions": [ - "error", - { "allowAny": true, "allowBoolean": true, "allowNumber": true, "allowNullish": true } - ], - "@typescript-eslint/unbound-method": "off" // Too many bugs right now: https://github.com/typescript-eslint/typescript-eslint/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+unbound-method - }, - "settings": { - "import/parsers": { - "@typescript-eslint/parser": [".ts", ".tsx"] - }, - "import/resolver": { - "typescript": { - "alwaysTryTypes": true // always try to resolve types under `@types` directory even it doesn't contain any source code, like `@types/unist` - } - } - }, - "ignorePatterns": ["dist/*", "out/*", "**/@types/*", "emojis.json", "tsconfig*.tsbuildinfo", "webpack.config*.js"], - "overrides": [ - { - "files": ["src/env/node/**/*"], - "rules": { - "no-restricted-imports": "off" - } - } - ] -} diff --git a/.eslintrc.browser.json b/.eslintrc.browser.json deleted file mode 100644 index 6d3cbd92d0fb2..0000000000000 --- a/.eslintrc.browser.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": [".eslintrc.base.json"], - "env": { - "worker": true - }, - "ignorePatterns": ["src/test/**/*", "src/webviews/apps/**/*", "src/env/node/**/*"], - "parserOptions": { - "project": "tsconfig.browser.json" - } -} diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index a8c1525f04190..0000000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": [".eslintrc.base.json"], - "env": { - "node": true - }, - "ignorePatterns": ["src/test/**/*", "src/webviews/apps/**/*", "src/env/browser/*"], - "parserOptions": { - "project": "tsconfig.json" - } -} diff --git a/.fantasticonrc.js b/.fantasticonrc.js index 5a3c9de2247ac..f1cc3725ce234 100644 --- a/.fantasticonrc.js +++ b/.fantasticonrc.js @@ -1,6 +1,6 @@ //@ts-check -/** @type { import('fantasticon').RunnerOptions} } */ +/** @type {import('@twbs/fantasticon').RunnerOptions} */ const config = { name: 'glicons', prefix: 'glicon', diff --git a/.gitignore-revs b/.git-blame-ignore-revs similarity index 81% rename from .gitignore-revs rename to .git-blame-ignore-revs index 15052f5cc979b..28f97a2c07331 100644 --- a/.gitignore-revs +++ b/.git-blame-ignore-revs @@ -7,3 +7,5 @@ d790e9db047769de079f6838c3578f3a47bf5930 60f8cb9fb8d1a56772d18a1a81cdd1748d589c2e 9c2df377d3e1842ed09eea5bb99be00edee9ca9c 444bf829156b3170c8b4b5156dcf10b06db83779 +4dba4612670c0a942e3daa3e6a34a57aebe257ae +fbccf2428fd671378202de43ff99deff66168a13 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 84216448c4a10..99ea03e40b9a9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug report description: Create a report to help GitLens improve -labels: ['potential-bug', 'triage'] +labels: ['bug', 'triage'] body: - type: textarea attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d714ed6ffa72d..939105b60c97d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -6,6 +6,6 @@ contact_links: - name: Documentation url: https://help.gitkraken.com/gitlens about: Read the GitLens support documentation - - name: GitLens+ Support - url: https://gitkraken.com/gitlens-support - about: Get email support for GitLens+ issues and questions + - name: GitKraken Support + url: https://help.gitkraken.com/gitlens/gl-contact-support/?product_s_=GitLens + about: Get email support for issues and questions on paid features or relating to your GitKraken account or plan diff --git a/.github/workflows/cd-insiders.yml b/.github/workflows/cd-insiders.yml deleted file mode 100644 index aafc5a723ddb2..0000000000000 --- a/.github/workflows/cd-insiders.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Publish Insiders - -on: - schedule: - - cron: '0 9 * * *' # every day at 4am EST - workflow_dispatch: - -jobs: - check: - name: Check for updates - runs-on: ubuntu-latest - permissions: - contents: write - outputs: - status: ${{ steps.earlyexit.outputs.status }} - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - id: earlyexit - run: | - git config user.name github-actions - git config user.email github-actions@github.com - if git rev-parse origin/insiders >/dev/null 2>&1; then - insidersRef=$(git show-ref -s origin/insiders) - headRef=$(git show-ref --head -s head) - echo "origin/insiders" - echo $insidersRef - echo "HEAD" - echo $headRef - if [ "$insidersRef" = "$headRef" ]; then - echo "No changes since last insiders build. Exiting." - echo "::set-output name=status::unchanged" - exit 0 - else - echo "Updating insiders" - git push origin --delete insiders - git checkout -b insiders - git push origin insiders - fi - else - echo "No insiders branch. Creating." - git checkout -b insiders - git push origin insiders - fi - echo "::set-output name=status::changed" - - publish: - name: Publish insiders - needs: check - runs-on: ubuntu-latest - if: needs.check.outputs.status == 'changed' - steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Setup node - uses: actions/setup-node@v3 - with: - node-version: '14' - - name: Install - run: yarn - - name: Apply insiders patch - run: yarn run patch-insiders - - name: Setup Environment - run: node -e "console.log('PACKAGE_VERSION=' + require('./package.json').version + '\nPACKAGE_NAME=' + require('./package.json').name + '-' + require('./package.json').version)" >> $GITHUB_ENV - - name: Package extension - run: yarn run package - - name: Publish extension - run: yarn vsce publish --yarn --packagePath ./${{ env.PACKAGE_NAME }}.vsix -p ${{ secrets.GITLENS_VSCODE_MARKETPLACE_PAT }} - - name: Publish artifact - uses: actions/upload-artifact@v3 - with: - name: ${{ env.PACKAGE_NAME }}.vsix - path: ./${{ env.PACKAGE_NAME }}.vsix diff --git a/.github/workflows/cd-pre.yml b/.github/workflows/cd-pre.yml index 264555572c23f..ba567b56e2af4 100644 --- a/.github/workflows/cd-pre.yml +++ b/.github/workflows/cd-pre.yml @@ -15,7 +15,7 @@ jobs: status: ${{ steps.earlyexit.outputs.status }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - id: earlyexit @@ -31,7 +31,7 @@ jobs: echo $headRef if [ "$preRef" = "$headRef" ]; then echo "No changes since last pre-release build. Exiting." - echo "::set-output name=status::unchanged" + echo "status=unchanged" >> $GITHUB_OUTPUT exit 0 else echo "Updating pre" @@ -44,7 +44,7 @@ jobs: git checkout -b pre git push origin pre fi - echo "::set-output name=status::changed" + echo "status=changed" >> $GITHUB_OUTPUT publish: name: Publish pre-release @@ -53,23 +53,27 @@ jobs: if: needs.check.outputs.status == 'changed' steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: '14' + node-version: '20' - name: Install - run: yarn + run: pnpm install - name: Apply pre-release patch - run: yarn run patch-pre + run: pnpm run patch-pre - name: Setup Environment run: node -e "console.log('PACKAGE_VERSION=' + require('./package.json').version + '\nPACKAGE_NAME=' + require('./package.json').name + '-' + require('./package.json').version)" >> $GITHUB_ENV - name: Package extension - run: yarn run package --pre-release + run: pnpm run package --pre-release - name: Publish extension - run: yarn vsce publish --yarn --pre-release --packagePath ./${{ env.PACKAGE_NAME }}.vsix -p ${{ secrets.GITLENS_VSCODE_MARKETPLACE_PAT }} + run: pnpm vsce publish --no-dependencies --pre-release --packagePath ./${{ env.PACKAGE_NAME }}.vsix -p ${{ secrets.GITLENS_VSCODE_MARKETPLACE_PAT }} - name: Publish artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ env.PACKAGE_NAME }}.vsix path: ./${{ env.PACKAGE_NAME }}.vsix diff --git a/.github/workflows/cd-stable.yml b/.github/workflows/cd-stable.yml index 3fe67655f5e14..17e5b33e68a89 100644 --- a/.github/workflows/cd-stable.yml +++ b/.github/workflows/cd-stable.yml @@ -13,21 +13,25 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 8 - name: Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: '14' + node-version: '20' - name: Setup Environment run: node -e "console.log('PACKAGE_VERSION=' + require('./package.json').version + '\nPACKAGE_NAME=' + require('./package.json').name + '-' + require('./package.json').version)" >> $GITHUB_ENV - name: Verify versions run: node -e "if ('refs/tags/v' + '${{ env.PACKAGE_VERSION }}' !== '${{ github.ref }}') { console.log('::error' + 'Version Mismatch. refs/tags/v' + '${{ env.PACKAGE_VERSION }}', '${{ github.ref }}'); throw Error('Version Mismatch')} " - name: Install - run: yarn + run: pnpm install - name: Package extension - run: yarn run package + run: pnpm run package - name: Publish Extension - run: yarn vsce publish --yarn --packagePath ./${{ env.PACKAGE_NAME }}.vsix -p ${{ secrets.GITLENS_VSCODE_MARKETPLACE_PAT }} + run: pnpm vsce publish --no-dependencies --packagePath ./${{ env.PACKAGE_NAME }}.vsix -p ${{ secrets.GITLENS_VSCODE_MARKETPLACE_PAT }} - name: Generate Changelog id: changelog uses: mindsers/changelog-reader-action@v2 @@ -36,7 +40,7 @@ jobs: path: ./CHANGELOG.md - name: Create GitHub release id: create_release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 09b1271beb2d3..3c1b9b4deb3e2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,62 +9,84 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: 'CodeQL' +name: 'CodeQL Advanced' on: push: - branches: [main] + branches: ['main'] pull_request: - # The branches below must be a subset of the branches above - # branches: [ main ] + branches: ['main'] schedule: - - cron: '43 22 * * 0' + - cron: '27 6 * * 3' jobs: analyze: - name: Analyze - runs-on: ubuntu-latest + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories actions: read contents: read - security-events: write strategy: fail-fast: false matrix: - language: ['javascript'] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://git.io/codeql-language-support - + include: + - language: javascript-typescript + build-mode: none + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - # - name: Autobuild - # uses: github/codeql-action/autobuild@v2 + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 + with: + category: '/language:${{matrix.language}}' diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000000000..8810fff7dcf27 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,20 @@ +name: Unit tests + +on: + pull_request: + branches: ['*'] + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + test: + name: Run unit tests + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + steps: + - name: Checkout + uses: actions/checkout@v3 + - run: cd tests/docker && ./run-e2e-test-local.sh diff --git a/.gitignore b/.gitignore index 926b610352976..a0b2de5261746 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store .eslintcache* .vscode-clean/** .vscode-test/** @@ -8,4 +9,3 @@ node_modules images/settings gitlens-*.vsix tsconfig*.tsbuildinfo -.DS_Store diff --git a/.mailmap b/.mailmap index 55784a3f9d983..8423fce7d3cef 100644 --- a/.mailmap +++ b/.mailmap @@ -3,3 +3,5 @@ Eric Amodio Eric Amodio Eric Follana Eric Eric Follana ericf-axosoft <90025366+ericf-axosoft@users.noreply.github.com> Ramin Tadayon Ramin Tadayon <67011668+axosoft-ramint@users.noreply.github.com> +Ramin Tadayon Ramin Test +Keith Daulton Keith Daulton diff --git a/.prettierignore b/.prettierignore index 9e985e79501f3..773364b5ec3e1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,10 @@ -emojis.json +dist +out +node_modules git.d.ts glicons.scss images/icons/template/icons-contribution.hbs images/icons/template/mapping.json images/icons/template/styles.hbs +src/emojis.generated.ts +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc index 5a24bc35e38c2..df7b60158484f 100644 --- a/.prettierrc +++ b/.prettierrc @@ -14,10 +14,6 @@ { "files": "*.md", "options": { "tabWidth": 2 } - }, - { - "files": "*.svg", - "options": { "parser": "html" } } ] } diff --git a/.vscode-test.mjs b/.vscode-test.mjs new file mode 100644 index 0000000000000..0a51801937d2c --- /dev/null +++ b/.vscode-test.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from '@vscode/test-cli'; + +export default defineConfig([ + { + mocha: { + ui: 'bdd', + timeout: 20000, + }, + label: 'unitTests', + files: 'out/**/*.test.js', + }, +]); diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 957f8030e8763..98e7c6fe26d84 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,11 @@ { // See http://go.microsoft.com/fwlink/?LinkId=827846 // for the documentation about the extensions.json format - "recommendations": ["dbaeumer.vscode-eslint", "amodio.tsl-problem-matcher", "esbenp.prettier-vscode"] + "recommendations": [ + "dbaeumer.vscode-eslint", + "amodio.tsl-problem-matcher", + "esbenp.prettier-vscode", + "joyceerhl.github-graphql-nb", + "ms-vscode.vscode-github-issue-notebooks" + ] } diff --git a/.vscode/queries.github-graphql-nb b/.vscode/queries.github-graphql-nb index dbd2eb4643770..5df1e21ccf488 100644 --- a/.vscode/queries.github-graphql-nb +++ b/.vscode/queries.github-graphql-nb @@ -1 +1 @@ -{"cells":[{"code":"### Get Default Branch & Tip","kind":"markdown"},{"code":"query getDefaultBranchAndTip(\n\t$owner: String!\n\t$repo: String!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tdefaultBranchRef {\n\t\t\tname\n\t\t\ttarget { oid }\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get Branches","kind":"markdown"},{"code":"query getBranches(\n\t$owner: String!\n\t$repo: String!\n\t$branchQuery: String\n\t$cursor: String\n\t$limit: Int = 100\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\trefs(query: $branchQuery, refPrefix: \"refs/heads/\", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\n\t\t\tpageInfo {\n\t\t\t\tendCursor\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t\tnodes {\n\t\t\t\tname\n\t\t\t\ttarget {\n\t\t\t\t\toid\n\t\t\t\t\tcommitUrl\n\t\t\t\t\t...on Commit {\n\t\t\t\t\t\tauthoredDate\n\t\t\t\t\t\tcommittedDate\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get Blame","kind":"markdown"},{"code":"query getBlame(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String!\n) {\n\tviewer { name }\n\trepository(owner: $owner, name: $repo) {\n\t\tobject(expression: $ref) {\n\t\t\t...on Commit {\n\t\t\t\tblame(path: $path) {\n\t\t\t\t\tranges {\n\t\t\t\t\t\tstartingLine\n\t\t\t\t\t\tendingLine\n\t\t\t\t\t\tage\n\t\t\t\t\t\tcommit {\n\t\t\t\t\t\t\toid\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\t\t\t\tmessage\n\t\t\t\t\t\t\tadditions\n\t\t\t\t\t\t\tchangedFiles\n\t\t\t\t\t\t\tdeletions\n\t\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\t\tavatarUrl\n\t\t\t\t\t\t\t\tdate\n\t\t\t\t\t\t\t\temail\n\t\t\t\t\t\t\t\tname\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcommitter {\n\t\t\t\t\t\t\t\tdate\n\t\t\t\t\t\t\t\temail\n\t\t\t\t\t\t\t\tname\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"gitkraken\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"0b8f151bd0458340b7779a64884c97754c3cedb8\",\n\t\"path\": \"package.json\"\n}","kind":"code"},{"code":"### Get Commit for File","kind":"markdown"},{"code":"query getCommitForFile(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tref(qualifiedName: $ref) {\n\t\t\ttarget {\n\t\t\t\t... on Commit {\n\t\t\t\t\thistory(first: 1, path: $path) {\n\t\t\t\t\t\tnodes {\n\t\t\t\t\t\t\toid\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\t\t\t\tmessage\n\t\t\t\t\t\t\tadditions\n\t\t\t\t\t\t\tchangedFiles\n\t\t\t\t\t\t\tdeletions\n\t\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\t\tdate\n\t\t\t\t\t\t\t\temail\n\t\t\t\t\t\t\t\tname\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcommitter { date }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"refs/heads/main\",\n\t\"path\": \"src/extension.ts\"\n}","kind":"code"},{"code":"### Get Current User","kind":"markdown"},{"code":"query getCurrentUser(\n\t$owner: String!\n\t$repo: String!\n) {\n\tviewer { name }\n\trepository(name: $repo owner: $owner) {\n\t\tviewerPermission\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get Commit","kind":"markdown"},{"code":"query getCommit(\n\t$owner: String!\n\t$repo: String!\n\t$ref: GitObjectID!\n) {\n\trepository(name: $repo owner: $owner) {\n\t\tobject(oid: $ref) {\n\t\t\t...on Commit {\n\t\t\t\toid\n\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\tmessage\n\t\t\t\tadditions\n\t\t\t\tchangedFiles\n\t\t\t\tdeletions\n\t\t\t\tauthor {\n\t\t\t\t\tdate\n\t\t\t\t\temail\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\tcommitter { date }\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"54f28933055124d6ba3808a787f6947c929f9db0\"\n}","kind":"code"},{"code":"### Get Commits","kind":"markdown"},{"code":"query getCommits(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String\n\t$author: CommitAuthor\n\t$after: String\n\t$before: String\n\t$limit: Int = 100\n\t$since: GitTimestamp\n\t$until: GitTimestamp\n) {\n\tviewer { name }\n\trepository(name: $repo, owner: $owner) {\n\t\tobject(expression: $ref) {\n\t\t\t... on Commit {\n\t\t\t\thistory(first: $limit, author: $author, path: $path, after: $after, before: $before, since: $since, until: $until) {\n\t\t\t\t\tpageInfo {\n\t\t\t\t\t\tstartCursor\n\t\t\t\t\t\tendCursor\n\t\t\t\t\t\thasNextPage\n\t\t\t\t\t\thasPreviousPage\n\t\t\t\t\t}\n\t\t\t\t\tnodes {\n\t\t\t\t\t\t... on Commit {\n\t\t\t\t\t\t\toid\n\t\t\t\t\t\t\tmessage\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\n\t\t\t\t\t\t\tadditions\n\t\t\t\t\t\t\tchangedFiles\n\t\t\t\t\t\t\tdeletions\n\t\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\t\tavatarUrl\n\t\t\t\t\t\t\t\tdate\n\t\t\t\t\t\t\t\temail\n\t\t\t\t\t\t\t\tname\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcommitter {\n\t\t\t\t\t\t\t\t date\n\t\t\t\t\t\t\t\t email\n\t\t\t\t\t\t\t\t name\n\t\t\t\t\t\t\t }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"HEAD\",\n\t\"-path\": \"src/extension.ts\",\n\t\"since\": \"2022-02-07T00:00:00Z\"\n}","kind":"code"},{"code":"### Get Tags","kind":"markdown"},{"code":"query getTags(\n\t$owner: String!\n\t$repo: String!\n\t$tagQuery: String\n\t$cursor: String\n\t$limit: Int = 100\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\trefs(query: $tagQuery, refPrefix: \"refs/tags/\", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\n\t\t\tpageInfo {\n\t\t\t\tendCursor\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t\tnodes {\n\t\t\t\tname\n\t\t\t\ttarget {\n\t\t\t\t\toid\n\t\t\t\t\tcommitUrl\n\t\t\t\t\t...on Commit {\n\t\t\t\t\t\tauthoredDate\n\t\t\t\t\t\tcommittedDate\n\t\t\t\t\t\tmessage\n\t\t\t\t\t}\n\t\t\t\t\t...on Tag {\n\t\t\t\t\t\tmessage\n\t\t\t\t\t\ttagger { date }\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Get collaborators ","kind":"markdown"},{"code":"query getCollaborators (\n\t$owner: String!\n\t$repo: String!\n\t$cursor: String\n\t$limit: Int = 100\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tcollaborators(affiliation: ALL, first: $limit, after: $cursor) {\n\t\t\tpageInfo {\n\t\t\t\tendCursor\n\t\t\t\thasNextPage\n\t\t\t}\n\t\t\tnodes {\n\t\t\t\tname\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\"\n}","kind":"code"},{"code":"### Resolve reference","kind":"markdown"},{"code":"query resolveReference(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tobject(expression: $ref) {\n\t\t\t... on Commit {\n\t\t\t\thistory(first: 1, path: $path) {\n\t\t\t\t\tnodes { oid }\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"d790e9db047769de079f6838c3578f3a47bf5930^\",\n\t\"path\": \"CODE_OF_CONDUCT.md\"\n}","kind":"code"},{"code":"### Get branches that contain commit","kind":"markdown"},{"code":"query getCommitBranches(\n\t$owner: String!\n\t$repo: String!\n\t$since: GitTimestamp!\n\t$until: GitTimestamp!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\trefs(first: 20, refPrefix: \"refs/heads/\", orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\n\t\t\tnodes {\n\t\t\t\tname\n\t\t\t\ttarget {\n\t\t\t\t\t... on Commit {\n\t\t\t\t\t\thistory(first: 3, since: $since until: $until) {\n\t\t\t\t\t\t\tnodes { oid }\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"since\": \"2022-01-06T01:07:46-04:00\",\n\t\"until\": \"2022-01-06T01:07:46-05:00\"\n}","kind":"code"},{"code":"query getCommitBranch(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$since: GitTimestamp!\n\t$until: GitTimestamp!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tref(qualifiedName: $ref) {\n\t\t\ttarget {\n\t\t\t\t... on Commit {\n\t\t\t\t\thistory(first: 3, since: $since until: $until) {\n\t\t\t\t\t\tnodes { oid }\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"refs/heads/main\",\n\t\"since\": \"2022-01-06T01:07:46-04:00\",\n\t\"until\": \"2022-01-06T01:07:46-05:00\"\n}","kind":"code"},{"code":"### Get commit count for branch (ref)","kind":"markdown"},{"code":"query getCommitCount(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n) {\n\trepository(owner: $owner, name: $repo) {\n\t\tref(qualifiedName: $ref) {\n\t\t\ttarget {\n\t\t\t\t... on Commit {\n\t\t\t\t\thistory(first: 1) {\n\t\t\t\t\t\ttotalCount\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"refs/heads/main\"\n}","kind":"code"},{"code":"### Get commit refs (sha)","kind":"markdown"},{"code":"query getCommitRefs(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String\n\t$since: GitTimestamp\n\t$until: GitTimestamp\n\t$limit: Int = 1\n) {\n\tviewer { name }\n\trepository(name: $repo, owner: $owner) {\n\t\tref(qualifiedName: $ref) {\n\t\t\thistory(first: $limit, path: $path, since: $since, until: $until) {\n\t\t\t\tnodes { oid, message }\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"refs/heads/main\",\n\t\"path\": \"extension.ts\",\n\t\"limit\": 2\n}","kind":"code"},{"code":"### Get next file commit","kind":"markdown"},{"code":"query getCommitDate(\n\t$owner: String!\n\t$repo: String!\n\t$ref: GitObjectID!\n) {\n\trepository(name: $repo owner: $owner) {\n\t\tobject(oid: $ref) {\n\t\t\t...on Commit {\n\t\t\t\tcommitter { date }\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"a03ff942c40665c451bd4c7768f46e3e5f00e97c\"\n}","kind":"code"},{"code":"query getNextCommitCursor(\n\t$owner: String!\n\t$repo: String!\n\t$ref: String!\n\t$path: String!\n\t$since: GitTimestamp!\n) {\n\trepository(name: $repo owner: $owner) {\n\t\tobject(expression: $ref) {\n\t\t\t... on Commit {\n\t\t\t\thistory(first:1, path: $path, since: $since) {\n\t\t\t\t\ttotalCount\n\t\t\t\t\tpageInfo { startCursor }\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"b062e960b6ee5ca7ac081dd84d9217bd4b2051e0\",\n \"path\": \"src/extension.ts\",\n \"since\": \"2021-11-03T02:46:29-04:00\"\n}","kind":"code"},{"code":"query getNextCommit(\n\t$owner: String!\n\t$repo: String!\n $ref: String!\n $path: String!\n\t$before: String!\n) {\trepository(name: $repo owner: $owner) {\n object(expression: $ref) {\n ... on Commit {\n history(last:4, path: $path, before: $before) {\n totalCount\n pageInfo {\n startCursor\n }\n nodes {\n oid\n message\n committedDate\n }\n }\n }\n }\n }\n}\n\nvariables {\n\t\"owner\": \"eamodio\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"ref\": \"496c35eaeff2c33d3f1256a25d83198ace6aa6b0\",\n \"path\": \"src/extension.ts\",\n \"before\": \"496c35eaeff2c33d3f1256a25d83198ace6aa6b0 4\"\n}","kind":"code"},{"code":"### Get Pull Request for Branch","kind":"markdown"},{"code":"query getPullRequestForBranch(\n\t$owner: String!\n\t$repo: String!\n\t$branch: String!\n\t$limit: Int!\n\t$include: [PullRequestState!]\n\t$avatarSize: Int\n) {\n\trepository(name: $repo, owner: $owner) {\n\t\trefs(query: $branch, refPrefix: \"refs/heads/\", first: 1) {\n\t\t\tnodes {\n\t\t\t\tassociatedPullRequests(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}, states: $include) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\tlogin\n\t\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\t\turl\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpermalink\n\t\t\t\t\t\tnumber\n\t\t\t\t\t\ttitle\n\t\t\t\t\t\tstate\n\t\t\t\t\t\tupdatedAt\n\t\t\t\t\t\tclosedAt\n\t\t\t\t\t\tmergedAt\n\t\t\t\t\t\trepository {\n\t\t\t\t\t\t\tisFork\n\t\t\t\t\t\t\towner {\n\t\t\t\t\t\t\t\tlogin\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"owner\": \"gitkraken\",\n\t\"repo\": \"vscode-gitlens\",\n\t\"branch\": \"main\",\n\t\"limit\": 1\n}","kind":"code"},{"code":"### Get My Assigned Pull Requests","kind":"markdown"},{"code":"query getMyAssignedPullRequests($assigned: String!) {\n search(first: 100, query: $assigned, type: ISSUE) {\n nodes {\n ... on PullRequest {\n assignees(first: 100) {\n nodes {\n login\n avatarUrl\n url\n }\n }\n author {\n login\n avatarUrl\n url\n }\n baseRefName\n baseRefOid\n baseRepository {\n name\n owner {\n login\n }\n }\n checksUrl\n isDraft\n isCrossRepository\n isReadByViewer\n headRefName\n headRefOid\n headRepository {\n name\n owner {\n login\n }\n }\n permalink\n number\n title\n state\n additions\n deletions\n updatedAt\n closedAt\n mergeable\n mergedAt\n mergedBy {\n login\n }\n repository {\n isFork\n owner {\n login\n }\n }\n reviewDecision\n reviewRequests(first: 100) {\n nodes {\n asCodeOwner\n requestedReviewer {\n ... on User {\n login\n avatarUrl\n url\n }\n }\n }\n }\n totalCommentsCount\n }\n }\n }\n}\n\nvariables {\n \"assigned\": \"assignee:@me is:pr is:open archived:false repo:gitkraken/vscode-gitlens\"\n}","kind":"code"},{"code":"### Get My Assigned Issues","kind":"markdown"},{"code":"query MyQuery($assigned: String!) {\n search(first: 2, query: $assigned, type: ISSUE) {\n nodes {\n ... on Issue {\n assignees(first: 100) {\n nodes {\n login\n url\n avatarUrl\n }\n }\n author {\n login\n avatarUrl\n url\n }\n comments {\n totalCount\n }\n number\n title\n url\n createdAt\n closedAt\n closed\n updatedAt\n labels(first: 20) {\n nodes {\n color\n name\n }\n }\n reactions(content: THUMBS_UP) {\n totalCount\n }\n repository {\n name\n owner {\n login\n }\n }\n }\n }\n }\n}\n\nvariables {\n \"assigned\": \"assignee:@me type:issue is:open archived:false repo:gitkraken/vscode-gitlens\"\n}","kind":"code"}]} \ No newline at end of file +{"cells":[{"code":"### Get Default Branch & Tip","kind":"markdown"},{"code":"query getDefaultBranchAndTip(\r\n\t$owner: String!\r\n\t$repo: String!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tdefaultBranchRef {\r\n\t\t\tname\r\n\t\t\ttarget { oid }\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get Branches","kind":"markdown"},{"code":"query getBranches(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$branchQuery: String\r\n\t$cursor: String\r\n\t$limit: Int = 100\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\trefs(query: $branchQuery, refPrefix: \"refs/heads/\", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\r\n\t\t\tpageInfo {\r\n\t\t\t\tendCursor\r\n\t\t\t\thasNextPage\r\n\t\t\t}\r\n\t\t\tnodes {\r\n\t\t\t\tname\r\n\t\t\t\ttarget {\r\n\t\t\t\t\toid\r\n\t\t\t\t\tcommitUrl\r\n\t\t\t\t\t...on Commit {\r\n\t\t\t\t\t\tauthoredDate\r\n\t\t\t\t\t\tcommittedDate\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get Blame","kind":"markdown"},{"code":"query getBlame(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String!\r\n) {\r\n\tviewer { name }\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tobject(expression: $ref) {\r\n\t\t\t...on Commit {\r\n\t\t\t\tblame(path: $path) {\r\n\t\t\t\t\tranges {\r\n\t\t\t\t\t\tstartingLine\r\n\t\t\t\t\t\tendingLine\r\n\t\t\t\t\t\tage\r\n\t\t\t\t\t\tcommit {\r\n\t\t\t\t\t\t\toid\r\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\r\n\t\t\t\t\t\t\tmessage\r\n\t\t\t\t\t\t\tadditions\r\n\t\t\t\t\t\t\tchangedFiles\r\n\t\t\t\t\t\t\tdeletions\r\n\t\t\t\t\t\t\tauthor {\r\n\t\t\t\t\t\t\t\tavatarUrl\r\n\t\t\t\t\t\t\t\tdate\r\n\t\t\t\t\t\t\t\temail\r\n\t\t\t\t\t\t\t\tname\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\tcommitter {\r\n\t\t\t\t\t\t\t\tdate\r\n\t\t\t\t\t\t\t\temail\r\n\t\t\t\t\t\t\t\tname\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"gitkraken\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"0b8f151bd0458340b7779a64884c97754c3cedb8\",\r\n\t\"path\": \"package.json\"\r\n}","kind":"code"},{"code":"### Get Commit for File","kind":"markdown"},{"code":"query getCommitForFile(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tref(qualifiedName: $ref) {\r\n\t\t\ttarget {\r\n\t\t\t\t... on Commit {\r\n\t\t\t\t\thistory(first: 1, path: $path) {\r\n\t\t\t\t\t\tnodes {\r\n\t\t\t\t\t\t\toid\r\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\r\n\t\t\t\t\t\t\tmessage\r\n\t\t\t\t\t\t\tadditions\r\n\t\t\t\t\t\t\tchangedFiles\r\n\t\t\t\t\t\t\tdeletions\r\n\t\t\t\t\t\t\tauthor {\r\n\t\t\t\t\t\t\t\tdate\r\n\t\t\t\t\t\t\t\temail\r\n\t\t\t\t\t\t\t\tname\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\tcommitter { date }\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"refs/heads/main\",\r\n\t\"path\": \"src/extension.ts\"\r\n}","kind":"code"},{"code":"### Get Current User","kind":"markdown"},{"code":"query getCurrentUser(\r\n\t$owner: String!\r\n\t$repo: String!\r\n) {\r\n\tviewer { name }\r\n\trepository(name: $repo owner: $owner) {\r\n\t\tviewerPermission\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get Commit","kind":"markdown"},{"code":"query getCommit(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: GitObjectID!\r\n) {\r\n\trepository(name: $repo owner: $owner) {\r\n\t\tobject(oid: $ref) {\r\n\t\t\t...on Commit {\r\n\t\t\t\toid\r\n\t\t\t\tparents(first: 3) { nodes { oid } }\r\n\t\t\t\tmessage\r\n\t\t\t\tadditions\r\n\t\t\t\tchangedFiles\r\n\t\t\t\tdeletions\r\n\t\t\t\tauthor {\r\n\t\t\t\t\tdate\r\n\t\t\t\t\temail\r\n\t\t\t\t\tname\r\n\t\t\t\t}\r\n\t\t\t\tcommitter { date }\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"54f28933055124d6ba3808a787f6947c929f9db0\"\r\n}","kind":"code"},{"code":"### Get Commits","kind":"markdown"},{"code":"query getCommits(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String\r\n\t$author: CommitAuthor\r\n\t$after: String\r\n\t$before: String\r\n\t$limit: Int = 100\r\n\t$since: GitTimestamp\r\n\t$until: GitTimestamp\r\n) {\r\n\tviewer { name }\r\n\trepository(name: $repo, owner: $owner) {\r\n\t\tobject(expression: $ref) {\r\n\t\t\t... on Commit {\r\n\t\t\t\thistory(first: $limit, author: $author, path: $path, after: $after, before: $before, since: $since, until: $until) {\r\n\t\t\t\t\tpageInfo {\r\n\t\t\t\t\t\tstartCursor\r\n\t\t\t\t\t\tendCursor\r\n\t\t\t\t\t\thasNextPage\r\n\t\t\t\t\t\thasPreviousPage\r\n\t\t\t\t\t}\r\n\t\t\t\t\tnodes {\r\n\t\t\t\t\t\t... on Commit {\r\n\t\t\t\t\t\t\toid\r\n\t\t\t\t\t\t\tmessage\r\n\t\t\t\t\t\t\tparents(first: 3) { nodes { oid } }\r\n\t\t\t\t\t\t\tadditions\r\n\t\t\t\t\t\t\tchangedFiles\r\n\t\t\t\t\t\t\tdeletions\r\n\t\t\t\t\t\t\tauthor {\r\n\t\t\t\t\t\t\t\tavatarUrl\r\n\t\t\t\t\t\t\t\tdate\r\n\t\t\t\t\t\t\t\temail\r\n\t\t\t\t\t\t\t\tname\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\tcommitter {\r\n\t\t\t\t\t\t\t\t date\r\n\t\t\t\t\t\t\t\t email\r\n\t\t\t\t\t\t\t\t name\r\n\t\t\t\t\t\t\t }\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"HEAD\",\r\n\t\"-path\": \"src/extension.ts\",\r\n\t\"since\": \"2022-02-07T00:00:00Z\"\r\n}","kind":"code"},{"code":"### Get Tags","kind":"markdown"},{"code":"query getTags(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$tagQuery: String\r\n\t$cursor: String\r\n\t$limit: Int = 100\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\trefs(query: $tagQuery, refPrefix: \"refs/tags/\", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\r\n\t\t\tpageInfo {\r\n\t\t\t\tendCursor\r\n\t\t\t\thasNextPage\r\n\t\t\t}\r\n\t\t\tnodes {\r\n\t\t\t\tname\r\n\t\t\t\ttarget {\r\n\t\t\t\t\toid\r\n\t\t\t\t\tcommitUrl\r\n\t\t\t\t\t...on Commit {\r\n\t\t\t\t\t\tauthoredDate\r\n\t\t\t\t\t\tcommittedDate\r\n\t\t\t\t\t\tmessage\r\n\t\t\t\t\t}\r\n\t\t\t\t\t...on Tag {\r\n\t\t\t\t\t\tmessage\r\n\t\t\t\t\t\ttagger { date }\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get collaborators ","kind":"markdown"},{"code":"query getCollaborators (\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$cursor: String\r\n\t$limit: Int = 100\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tcollaborators(affiliation: ALL, first: $limit, after: $cursor) {\r\n\t\t\tpageInfo {\r\n\t\t\t\tendCursor\r\n\t\t\t\thasNextPage\r\n\t\t\t}\r\n\t\t\tnodes {\r\n\t\t\t\tname\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Resolve reference","kind":"markdown"},{"code":"query resolveReference(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tobject(expression: $ref) {\r\n\t\t\t... on Commit {\r\n\t\t\t\thistory(first: 1, path: $path) {\r\n\t\t\t\t\tnodes { oid }\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"d790e9db047769de079f6838c3578f3a47bf5930^\",\r\n\t\"path\": \"CODE_OF_CONDUCT.md\"\r\n}","kind":"code"},{"code":"### Get branches that contain commit","kind":"markdown"},{"code":"query getCommitBranches(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$since: GitTimestamp!\r\n\t$until: GitTimestamp!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\trefs(first: 20, refPrefix: \"refs/heads/\", orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) {\r\n\t\t\tnodes {\r\n\t\t\t\tname\r\n\t\t\t\ttarget {\r\n\t\t\t\t\t... on Commit {\r\n\t\t\t\t\t\thistory(first: 3, since: $since until: $until) {\r\n\t\t\t\t\t\t\tnodes { oid }\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"since\": \"2022-01-06T01:07:46-04:00\",\r\n\t\"until\": \"2022-01-06T01:07:46-05:00\"\r\n}","kind":"code"},{"code":"query getCommitBranch(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$since: GitTimestamp!\r\n\t$until: GitTimestamp!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tref(qualifiedName: $ref) {\r\n\t\t\ttarget {\r\n\t\t\t\t... on Commit {\r\n\t\t\t\t\thistory(first: 3, since: $since until: $until) {\r\n\t\t\t\t\t\tnodes { oid }\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"refs/heads/main\",\r\n\t\"since\": \"2022-01-06T01:07:46-04:00\",\r\n\t\"until\": \"2022-01-06T01:07:46-05:00\"\r\n}","kind":"code"},{"code":"### Get commit count for branch (ref)","kind":"markdown"},{"code":"query getCommitCount(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n) {\r\n\trepository(owner: $owner, name: $repo) {\r\n\t\tref(qualifiedName: $ref) {\r\n\t\t\ttarget {\r\n\t\t\t\t... on Commit {\r\n\t\t\t\t\thistory(first: 1) {\r\n\t\t\t\t\t\ttotalCount\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"refs/heads/main\"\r\n}","kind":"code"},{"code":"### Get commit refs (sha)","kind":"markdown"},{"code":"query getCommitRefs(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String\r\n\t$since: GitTimestamp\r\n\t$until: GitTimestamp\r\n\t$limit: Int = 1\r\n) {\r\n\tviewer { name }\r\n\trepository(name: $repo, owner: $owner) {\r\n\t\tref(qualifiedName: $ref) {\r\n\t\t\thistory(first: $limit, path: $path, since: $since, until: $until) {\r\n\t\t\t\tnodes { oid, message }\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"refs/heads/main\",\r\n\t\"path\": \"extension.ts\",\r\n\t\"limit\": 2\r\n}","kind":"code"},{"code":"### Get next file commit","kind":"markdown"},{"code":"query getCommitDate(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: GitObjectID!\r\n) {\r\n\trepository(name: $repo owner: $owner) {\r\n\t\tobject(oid: $ref) {\r\n\t\t\t...on Commit {\r\n\t\t\t\tcommitter { date }\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"a03ff942c40665c451bd4c7768f46e3e5f00e97c\"\r\n}","kind":"code"},{"code":"query getNextCommitCursor(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$ref: String!\r\n\t$path: String!\r\n\t$since: GitTimestamp!\r\n) {\r\n\trepository(name: $repo owner: $owner) {\r\n\t\tobject(expression: $ref) {\r\n\t\t\t... on Commit {\r\n\t\t\t\thistory(first:1, path: $path, since: $since) {\r\n\t\t\t\t\ttotalCount\r\n\t\t\t\t\tpageInfo { startCursor }\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"b062e960b6ee5ca7ac081dd84d9217bd4b2051e0\",\r\n \"path\": \"src/extension.ts\",\r\n \"since\": \"2021-11-03T02:46:29-04:00\"\r\n}","kind":"code"},{"code":"query getNextCommit(\r\n\t$owner: String!\r\n\t$repo: String!\r\n $ref: String!\r\n $path: String!\r\n\t$before: String!\r\n) {\trepository(name: $repo owner: $owner) {\r\n object(expression: $ref) {\r\n ... on Commit {\r\n history(last:4, path: $path, before: $before) {\r\n totalCount\r\n pageInfo {\r\n startCursor\r\n }\r\n nodes {\r\n oid\r\n message\r\n committedDate\r\n }\r\n }\r\n }\r\n }\r\n }\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"eamodio\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"ref\": \"496c35eaeff2c33d3f1256a25d83198ace6aa6b0\",\r\n \"path\": \"src/extension.ts\",\r\n \"before\": \"496c35eaeff2c33d3f1256a25d83198ace6aa6b0 4\"\r\n}","kind":"code"},{"code":"### Get Pull Request for Branch","kind":"markdown"},{"code":"query getPullRequestForBranch(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$branch: String!\r\n\t$limit: Int!\r\n\t$include: [PullRequestState!]\r\n\t$avatarSize: Int\r\n) {\r\n\trepository(name: $repo, owner: $owner) {\r\n\t\trefs(query: $branch, refPrefix: \"refs/heads/\", first: 1) {\r\n\t\t\tnodes {\r\n\t\t\t\tassociatedPullRequests(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}, states: $include) {\r\n\t\t\t\t\tnodes {\r\n\t\t\t\t\t\tauthor {\r\n\t\t\t\t\t\t\tlogin\r\n\t\t\t\t\t\t\tavatarUrl(size: $avatarSize)\r\n\t\t\t\t\t\t\turl\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t\tpermalink\r\n\t\t\t\t\t\tnumber\r\n\t\t\t\t\t\ttitle\r\n\t\t\t\t\t\tstate\r\n\t\t\t\t\t\tcreatedAt\r\n\t\t\t\t\t\tupdatedAt\r\n\t\t\t\t\t\tclosedAt\r\n\t\t\t\t\t\tmergedAt\r\n\t\t\t\t\t\trepository {\r\n\t\t\t\t\t\t\tisFork\r\n\t\t\t\t\t\t\towner {\r\n\t\t\t\t\t\t\t\tlogin\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"gitkraken\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"branch\": \"main\",\r\n\t\"limit\": 1\r\n}","kind":"code"},{"code":"### Get My Assigned Pull Requests","kind":"markdown"},{"code":"query getMyAssignedPullRequests($assigned: String!) {\r\n search(first: 100, query: $assigned, type: ISSUE) {\r\n nodes {\r\n ... on PullRequest {\r\n assignees(first: 100) {\r\n nodes {\r\n login\r\n avatarUrl\r\n url\r\n }\r\n }\r\n author {\r\n login\r\n avatarUrl\r\n url\r\n }\r\n baseRefName\r\n baseRefOid\r\n baseRepository {\r\n name\r\n owner {\r\n login\r\n }\r\n url\r\n }\r\n checksUrl\r\n isDraft\r\n isCrossRepository\r\n isReadByViewer\r\n headRefName\r\n headRefOid\r\n headRepository {\r\n name\r\n owner {\r\n login\r\n }\r\n url\r\n }\r\n permalink\r\n number\r\n title\r\n state\r\n additions\r\n deletions\r\n createdAt\r\n updatedAt\r\n closedAt\r\n mergeable\r\n mergedAt\r\n mergedBy {\r\n login\r\n }\r\n reactions(content: THUMBS_UP) {\r\n totalCount\r\n }\r\n repository {\r\n isFork\r\n owner {\r\n login\r\n }\r\n viewerPermission\r\n }\r\n reviewDecision\r\n reviewRequests(first: 100) {\r\n nodes {\r\n asCodeOwner\r\n requestedReviewer {\r\n ... on User {\r\n login\r\n avatarUrl\r\n url\r\n }\r\n }\r\n }\r\n }\r\n totalCommentsCount\r\n viewerCanUpdate\r\n }\r\n }\r\n }\r\n}\r\n\r\nvariables {\r\n \"assigned\": \"assignee:@me is:pr is:open archived:false repo:gitkraken/vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get My Assigned Issues","kind":"markdown"},{"code":"query MyQuery($assigned: String!) {\r\n search(first: 2, query: $assigned, type: ISSUE) {\r\n nodes {\r\n ... on Issue {\r\n assignees(first: 100) {\r\n nodes {\r\n login\r\n url\r\n avatarUrl\r\n }\r\n }\r\n author {\r\n login\r\n avatarUrl\r\n url\r\n }\r\n comments {\r\n totalCount\r\n }\r\n number\r\n title\r\n url\r\n createdAt\r\n closedAt\r\n closed\r\n updatedAt\r\n labels(first: 20) {\r\n nodes {\r\n color\r\n name\r\n }\r\n }\r\n reactions(content: THUMBS_UP) {\r\n totalCount\r\n }\r\n repository {\r\n name\r\n owner {\r\n login\r\n }\r\n }\r\n }\r\n }\r\n }\r\n}\r\n\r\nvariables {\r\n \"assigned\": \"assignee:@me type:issue is:open archived:false repo:gitkraken/vscode-gitlens\"\r\n}","kind":"code"},{"code":"### Get PR CI Checks","kind":"markdown"},{"code":"query getPullRequest(\r\n\t$owner: String!\r\n\t$repo: String!\r\n\t$number: Int!\r\n) {\r\n\trepository(name: $repo, owner: $owner) {\r\n\t\tpullRequest(number: $number) {\r\n\t\t\ttitle\r\n \t\tcommits(last: 1) {\r\n\t\t\t\tnodes {\r\n\t\t\t\t\tcommit {\r\n\t\t\t\t\t\toid\r\n\t\t\t\t\t\tstatusCheckRollup {\r\n\t\t\t\t\t\t\tstate\r\n\t\t\t\t\t\t\tcontexts(first: 10) {\r\n \t\t\t\tedges {\r\n \t\t\t\t\tnode {\r\n \t\t\t\t\t... on CheckRun {\r\n\t\t\t\t\t\t\t\t\t\t\tname\r\n\t\t\t\t\t\t\t\t\t\t\tstatus\r\n\t\t\t\t\t\t\t\t\t\t\tconclusion\r\n\t\t\t\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t\t\t\t}\r\n \t\t\t\t}\r\n\t\t\t\t\t\t\t}\r\n\t\t\t\t\t\t}\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n\r\nvariables {\r\n\t\"owner\": \"gitkraken\",\r\n\t\"repo\": \"vscode-gitlens\",\r\n\t\"number\": 3036\r\n}","kind":"code"},{"code":"### Search for Pull Request\n","kind":"markdown"},{"code":"query searchMyPullRequests(\n\t$search: String!\n\t$avatarSize: Int\n) {\n\trateLimit {\n \tcost\n \t}\n\tviewer {\n\t\tlogin\n\t}\n\tsearch(first: 100, query: $search, type: ISSUE) {\n\t\tissueCount\n\t\tnodes {\n\t\t\t...on PullRequest {\n\t\t\t\tclosed\n\t\t\t\tclosedAt\n\t\t\t\tcreatedAt\n\t\t\t\tid\n\t\t\t\tnumber\n\t\t\t\tstate\n\t\t\t\ttitle\n\t\t\t\tupdatedAt\n\t\t\t\turl\n\t\t\t\tauthor {\n\t\t\t\t\tlogin\n\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t\tbaseRefName\n\t\t\t\tbaseRefOid\n\t\t\t\theadRefName\n\t\t\t\theadRefOid\n\t\t\t\theadRepository {\n\t\t\t\t\tname\n\t\t\t\t\towner {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t}\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t\tisCrossRepository\n\t\t\t\tmergedAt\n\t\t\t\tpermalink\n\t\t\t\trepository {\n\t\t\t\t\tisFork\n\t\t\t\t\tname\n\t\t\t\t\towner {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t}\n\t\t\t\t\tviewerPermission\n\t\t\t\t}\n\t\t\t\tadditions\n\t\t\t\tassignees(first: 10) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\turl\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tchecksUrl\n\t\t\t\tdeletions\n\t\t\t\tisDraft\n\t\t\t\tmergeable\n\t\t\t\tmergedBy {\n\t\t\t\t\tlogin\n\t\t\t\t}\n\t\t\t\tmergeable\n\t\t\t\tmergedBy {\n\t\t\t\t\tlogin\n\t\t\t\t}\n\t\t\t\treviewDecision\n\t\t\t\tlatestReviews (first: 10) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\tlogin\n\t\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\t\turl\n\t\t\t\t\t\t}\n\t\t\t\t\t\tstate\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treviewRequests(first: 10) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tasCodeOwner\n\t\t\t\t\t\tid\n\t\t\t\t\t\trequestedReviewer {\n\t\t\t\t\t\t\t... on User {\n\t\t\t\t\t\t\t\tlogin\n\t\t\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\t\t\turl\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tstatusCheckRollup {\n\t\t\t\t\tstate\n\t\t\t\t}\n\t\t\t\ttotalCommentsCount\n\t\t\t\tviewerCanUpdate\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"search\": \"is:pr is:open archived:false involves:@me\",\n\t\"avatarSize\": 16\n}\n","kind":"code"},{"code":"### Search for Pull Request (Lite)\n","kind":"markdown"},{"code":"query searchMyPullRequests(\n\t$search: String!\n\t$avatarSize: Int\n) {\n\trateLimit {\n \tcost\n \t}\n\tviewer {\n\t\tlogin\n\t}\n\tsearch(first: 100, query: $search, type: ISSUE) {\n\t\tissueCount\n\t\tnodes {\n\t\t\t...on PullRequest {\n\t\t\t\tclosed\n\t\t\t\tclosedAt\n\t\t\t\tcreatedAt\n\t\t\t\tid\n\t\t\t\tnumber\n\t\t\t\tstate\n\t\t\t\ttitle\n\t\t\t\tupdatedAt\n\t\t\t\turl\n\t\t\t\tauthor {\n\t\t\t\t\tlogin\n\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t\tbaseRefName\n\t\t\t\tbaseRefOid\n\t\t\t\theadRefName\n\t\t\t\theadRefOid\n\t\t\t\theadRepository {\n\t\t\t\t\tname\n\t\t\t\t\towner {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t}\n\t\t\t\t\turl\n\t\t\t\t}\n\t\t\t\tisCrossRepository\n\t\t\t\tmergedAt\n\t\t\t\tpermalink\n\t\t\t\trepository {\n\t\t\t\t\tisFork\n\t\t\t\t\tname\n\t\t\t\t\towner {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t}\n\t\t\t\t\tviewerPermission\n\t\t\t\t}\n\t\t\t\tadditions\n\t\t\t\tassignees(first: 10) {\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tlogin\n\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\turl\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tchecksUrl\n\t\t\t\tdeletions\n\t\t\t\tisDraft\n\t\t\t\tmergeable\n\t\t\t\tmergedBy {\n\t\t\t\t\tlogin\n\t\t\t\t}\n\t\t\t\treviewDecision\n\t\t\t\t# myReviews:reviews(states: [APPROVED, CHANGES_REQUESTED, COMMENTED] author: \"@me\") {\n\t\t\t\t# \ttotalCount\n\t\t\t\t# }\n\t\t\t\tpendingReviews:reviews(first: 10,states: [PENDING]) {\n\t\t\t\t\ttotalCount\n\t\t\t\t\tnodes {\n\t\t\t\t\t\tauthor {\n\t\t\t\t\t\t\tlogin\n\t\t\t\t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t\t\t\turl\n\t\t\t\t\t\t}\n\t\t\t\t\t\tstate\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tchangesRequestedReviews:reviews(states: [CHANGES_REQUESTED]) {\n\t\t\t\t\ttotalCount\n\t\t\t\t}\n\t\t\t\tcommentedReviews:reviews(states: [COMMENTED]) {\n\t\t\t\t\ttotalCount\n\t\t\t\t}\n\t\t\t\treviewRequests {\n\t\t\t\t\ttotalCount\n\t\t\t\t}\n\t\t\t\t# latestReviews(first: 10) {\n\t\t\t\t# \tnodes {\n\t\t\t\t# \t\tauthor {\n\t\t\t\t# \t\t\tlogin\n\t\t\t\t# \t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t# \t\t\turl\n\t\t\t\t# \t\t}\n\t\t\t\t# \t\tstate\n\t\t\t\t# \t}\n\t\t\t\t# }\n\t\t\t\t# reviewRequests(first: 10) {\n\t\t\t\t# \tnodes {\n\t\t\t\t# \t\tasCodeOwner\n\t\t\t\t# \t\tid\n\t\t\t\t# \t\trequestedReviewer {\n\t\t\t\t# \t\t\t... on User {\n\t\t\t\t# \t\t\t\tlogin\n\t\t\t\t# \t\t\t\tavatarUrl(size: $avatarSize)\n\t\t\t\t# \t\t\t\turl\n\t\t\t\t# \t\t\t}\n\t\t\t\t# \t\t}\n\t\t\t\t# \t}\n\t\t\t\t# }\n\t\t\t\tstatusCheckRollup {\n\t\t\t\t\tstate\n\t\t\t\t}\n\t\t\t\ttotalCommentsCount\n\t\t\t\tviewerCanUpdate\n\t\t\t\tviewerLatestReview {\n\t\t\t\t\tid\n\t\t\t\t\tstate\n\t\t\t\t}\n\t\t\t\tviewerLatestReviewRequest {\n\t\t\t\t\tid\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nvariables {\n\t\"search\": \"is:pr is:open archived:false involves:@me\",\n\t\"avatarSize\": 16\n}\n","kind":"code"}]} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index b292f7de18974..42c84dbaa2807 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,7 @@ { "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, - "eslint.packageManager": "yarn", "files.associations": { ".eslintrc*.json": "jsonc" }, @@ -19,7 +18,7 @@ }, { "label": "Current", - "query": "state:open repo:${owner}/${repository} milestone:\"13.3\" sort:updated-desc" + "query": "state:open repo:${owner}/${repository} milestone:\"13.5\" sort:updated-desc" }, { "label": "Soonâ„ĸ", @@ -33,19 +32,21 @@ "label": "Pending Release", "query": "state:closed repo:${owner}/${repository} label:pending-release sort:updated-desc" }, + { + "label": "Debt", + "query": "state:open repo:${owner}/${repository} label:debt sort:updated-desc" + }, { "label": "All", "query": "state:open repo:${owner}/${repository} sort:updated-desc" } ], - "gitlens.advanced.blame.customArguments": ["--ignore-revs-file", ".gitignore-revs"], "[html][javascript][json][jsonc][markdown][scss][svg][typescript][typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "npm.packageManager": "yarn", "search.exclude": { "**/dist": true }, "typescript.preferences.importModuleSpecifier": "project-relative", - "typescript.tsdk": "node_modules\\typescript\\lib" + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index de96ce8782d1d..81aa8e5775579 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -55,12 +55,6 @@ }, { "label": "Run (vscode.dev)", - "group": "test", - "dependsOn": ["npm: web:serve", "npm: web:tunnel"], - "dependsOrder": "parallel", - "problemMatcher": [] - }, - { "type": "npm", "script": "web:serve", "group": "test", @@ -71,17 +65,6 @@ }, "problemMatcher": [] }, - { - "type": "npm", - "script": "web:tunnel", - "group": "test", - "isBackground": true, - "presentation": { - "group": "web", - "reveal": "always" - }, - "problemMatcher": [] - }, { "type": "npm", "script": "watch:tests", diff --git a/.vscodeignore b/.vscodeignore index 7a4d64c37ac5d..a58b4162d829e 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,10 +1,11 @@ +.devcontainer/** .github/** .vscode/** .vscode-clean/** .vscode-test/** .vscode-test-web/** .yarn/** -dist/webviews/*.css +docs/** emoji/** images/docs/sponsors/** images/docs/gitlens-preview.gif @@ -13,34 +14,35 @@ images/originals/** node_modules/** out/** patches/** +resources/** scripts/** src/** test/** -**/*.fig +tests/** **/*.map **/*.pdn **/*.js.LICENSE.txt -.browserslistrc .eslintcache +.DS_Store +.browserslistrc .eslintignore -.eslintrc*.json .fantasticonrc.js +.git-blame-ignore-revs .gitattributes .gitignore -.gitignore-revs .mailmap .prettierignore .prettierrc +.vscode-test.mjs .yarnrc BACKERS.md CODE_OF_CONDUCT.md CONTRIBUTING.md -esbuild.js -README.insiders.md +eslint.config.mjs +pnpm-lock.yaml README.pre.md svgo.config.js tsconfig*.json tsconfig*.tsbuildinfo -webpack.config*.js +webpack.config*.mjs yarn.lock -.DS_Store diff --git a/.yarnrc b/.yarnrc deleted file mode 100644 index 2266febf3f87f..0000000000000 --- a/.yarnrc +++ /dev/null @@ -1,2 +0,0 @@ -ignore-engines true -version-git-message "Bumps to v%s" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 074ff37707228..be158f686f2ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,1060 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Added + +- Adds [Cursor](https://cursor.so) support — closes [#3222](https://github.com/gitkraken/vscode-gitlens/issues/3222) + +### Changed + +- Adds vscode-test to run unit-tests — closes [#3570](https://github.com/gitkraken/vscode-gitlens/issues/3570) + +### Fixed + +- Fixes [#3592](https://github.com/gitkraken/vscode-gitlens/issues/3592) - Connecting to an integration via Remotes view (but likely others) doesn't work +- Fixes an issue where virtual repositories for GitHub PRs from forks wouldn't load properly + +## [15.5.1] - 2024-09-16 + +### Fixed + +- Fixes [#3582](https://github.com/gitkraken/vscode-gitlens/issues/3582) - "Delete Branch" option is sometimes unexpectedly missing + +## [15.5.0] - 2024-09-12 + +### Added + +- Adds a `gitlens.views.showCurrentBranchOnTop` setting to specify whether the current branch is shown at the top of the views — closes [#3520](https://github.com/gitkraken/vscode-gitlens/issues/3520) +- Adds a sidebar to the _Commit Graph_ + - Shows counts of branches, remotes, stashes, tags, and worktrees + - Clicking an item reveals its corresponding view + - Try out this new feature by setting `gitlens.graph.sidebar.enabled` to `true` + +### Changed + +- Preview access of Launchpad is ending on September 27th +- Simplifies the _Create Worktree_ command flow by prompting to create a new branch only when necessary — closes [#3542](https://github.com/gitkraken/vscode-gitlens/issues/3542) +- Removes the use of VS Code Authentication API for GitKraken accounts + +### Fixed + +- Fixes [#3514](https://github.com/gitkraken/vscode-gitlens/issues/3514) - Attempting to delete the main worktree's branch causes a invalid prompt to delete the main worktree +- Fixes [#3518](https://github.com/gitkraken/vscode-gitlens/issues/3518) - Branches in worktrees are no longer collapsed into folder groupings + +### Removed + +- Removes (disables) legacy "focus" editor + +## [15.4.0] - 2024-09-04 + +### Added + +- Adds better support for branches in worktrees + - Changes the branch icon to a "repo" icon when the branch is in a worktree in views, quick pick menus, and the _Commit Graph_ + - Adds an _Open in Worktree_ inline and context menu command and an _Open in Worktree in New Window_ context menu command to branches and pull requests in views and on the _Commit Graph_ + - Removes the _Switch to Branch..._ inline and context menu command from branches in views and on the _Commit Graph_ when the branch is in a worktree +- Adds ability to only search stashes when using `type:stash` (or `is:stash`) in commit search via the _Commit Graph_, _Search & Compare_ view, or the _Search Commits_ command +- Adds `...` inline command for stashes on the _GitLens Inspect_ view +- Adds an "up-to-date" indicator dot to the branch icon of branches in views +- Adds an "alt" _Pull_ command for the inline _Fetch_ command on branches in views +- Adds an "alt" _Fetch_ command for the inline _Pull_ command on branches in views +- Adds _Open Comparison on Remote_ command to branch comparisons in views +- Adds new options to the _Git Delete Worktree_ command to also delete the associated branch along with the worktree + +### Changed + +- Improves the branch comparisons in views to automatically select the base or target branch +- Improves tooltips on branches, remotes, and worktrees in views +- _Upgrade to Pro_ flows now support redirects back to GitLens + +### Fixed + +- Fixes [#3479](https://github.com/gitkraken/vscode-gitlens/issues/3479) - Tooltip flickering +- Fixes [#3472](https://github.com/gitkraken/vscode-gitlens/issues/3472) - "Compare working tree with.." often flashes open then closes the menu +- Fixes [#3448](https://github.com/gitkraken/vscode-gitlens/issues/3448) - "Select for Compare" on a Commit/Stash/etc causes the Search and Compare view to be forcibly shown +- Fixes the _Git Delete Branch_ command when deleting a branch that is open on a worktree by adding a step to delete the branch's worktree first +- Fixes an issue where pull requests in views could show the wrong comparison with the working tree when using worktrees +- Fixes _Copy Remote Comparison URL_ command to not open the URL, just copy it +- Fixes cloud integrations remaining disconnected after disconnecting and reconnecting to a GitKraken account +- Fixes "switch" deep links sometimes failing to complete in cases where the switch occurs in the current window + +### Removed + +- Removes status (ahead, behind, etc) decoration icons from branches in views + +## [15.3.1] - 2024-08-21 + +### Added + +- Adds DevEx Days promotion + +### Changed + +- Improves upgrade/purchase flow + +## [15.3.0] - 2024-08-15 + +### Added + +- Adds improvements and enhancements to _Launchpad_ to make it easier to manage and review pull requests + - Adds GitLab (cloud-only for now) support to show and manage merge requests in _Launchpad_ + - Adds a new _Connect Additional Integrations_ button to the _Launchpad_ titlebar to allow connecting additional integrations (GitHub and GitLab currently) + - Adds an new experimental _Launchpad_ view to provide a persistent view of the _Launchpad_ in the sidebar + - To try it out, run the _Show Launchpad View_ command or set the `gitlens.views.launchpad.enabled` setting to `true` — let us know what you think! + - While its functionality is currently limited, pull requests can be expanded to show changes, commits, and code suggestions, as well as actions to open changes in the multi-diff editor, open a comparision, and more +- Adds new features and improvements to the _Commit Graph_ + - Branch visibility options, formerly in the _Graph Filtering_ dropdown, are now moved to the new _Branches Visibility_ dropdown in the _Commit Graph_ header bar + - Adds a new _Smart Branches_ visibility option to shows only relevant branches — the current branch, its upstream, and its base or target branch, to help you better focus + - Improves interactions with hovers on rows — they should do a better job of staying out of your way + - Adds pull request information to branches with missing upstreams +- Adds support for GitHub and GitLab cloud integrations — automatically synced with your GitKraken account + - Adds an improved, streamlined experience for connecting cloud integrations to GitLens + - Manage your connected integration via the the _Manage Integrations_ command or the _Integrations_ button on the _GitKraken Account_ view +- Adds comparison support to virtual (GitHub) repositories + +### Changed + +- Improves the _Compare to/from HEAD_ command (previously _Compare with HEAD_) to compare commits, stashes, and tags with the HEAD commit where directionality is determined by topology and time +- Improves the messaging of the merge and rebase commands +- Renames _Compare with Working Tree_ command to _Compare Working Tree to Here_ +- Renames _Compare Common Base with Working Tree_ command to _Compare Working Tree to Common Base_ +- Renames _Open Worktree in New Window_ Launchpad command to _Open in Worktree_ +- Renames _Open Directory Compare_ command to _Open Directory Comparison_ +- Renames _Open Directory Compare with Working Tree_ command to _Directory Compare Working Tree to Here_ +- Improves some messaging on _Switch_ and _Checkout_ commands + +### Fixed + +- Fixes [#3445](https://github.com/gitkraken/vscode-gitlens/issues/3445) - Cannot merge branch into detached HEAD +- Fixes [#3443](https://github.com/gitkraken/vscode-gitlens/issues/3443) - Don't show gitlens context menu items in Copilot Chat codeblock editors +- Fixes [#3457](https://github.com/gitkraken/vscode-gitlens/issues/3457) - Enriched autolink duplication in graph hover (and possibly other places) +- Fixes [#3473](https://github.com/gitkraken/vscode-gitlens/issues/3473) - Plus features can't be restored after they are hidden +- Fixes column resizing being stuck when the mouse leaves the _Commit Graph_ +- Fixes issues with incorrect commit count when using the merge and rebase commands +- Fixes issues where a merge or rebase operation says there or no changes when there are changes +- Fixes an error with queries that can cause Jira Cloud and other cloud integrations to stop working +- Fixes issues with some directory comparison commands + +## [15.2.3] - 2024-07-26 + +### Fixed + +- Fixes (for real) [#3423](https://github.com/gitkraken/vscode-gitlens/issues/3423) - Blame annotations & revision navigation are missing in 15.2.1 when using remote (WSL, SSH, etc) repositories + +## [15.2.2] - 2024-07-26 + +### Fixed + +- Fixes [#3423](https://github.com/gitkraken/vscode-gitlens/issues/3423) - Blame annotations & revision navigation are missing in 15.2.1 when using remote (WSL, SSH, etc) repositories +- Fixes [#3422](https://github.com/gitkraken/vscode-gitlens/issues/3422) - Extra logging +- Fixes [#3406](https://github.com/gitkraken/vscode-gitlens/issues/3406) - Worktrees typo in package.json — thanks to [PR #3407](https://github.com/gitkraken/vscode-gitlens/pull/3407) by Matthew Yu ([@matthewyu01](https://github.com/matthewyu01)) +- Fixes cloud patch creation error on azure repos +- Fixes [#3385](https://github.com/gitkraken/vscode-gitlens/issues/3385) - Provides commit from stash on create patch from stash action +- Fixes [#3414](https://github.com/gitkraken/vscode-gitlens/issues/3414) - Patch creation may be done multiple times + +## [15.2.1] - 2024-07-24 + +### Added + +- Adds support for OpenAI's GPT-4o Mini model for GitLens' experimental AI features +- Adds a _Jump to HEAD_ button on the _Commit Graph_ header bar (next to the current branch) to quickly jump to the HEAD commit + - Adds a _Jump to Reference_ as an `alt` modifier to the _Jump to HEAD_ button to jump to the selected branch or tag +- Adds support deep link documentation — closes [#3399](https://github.com/gitkraken/vscode-gitlens/issues/3399) +- Adds a pin status icon to Launchpad items when pinned + +### Changed + +- Changes the "What's new" icon on _Home_ to not conflict with _Launchpad_ +- Improves working with worktrees by avoiding showing the root repo in worktrees during certain operations (e.g. rebase) and vice-versa +- Changes how we track open documents to improve performance, reduce UI jumping, and support VS Code's new ability to [show editor commands for all visible editors](https://code.visualstudio.com/updates/v1_90#_always-show-editor-actions) — closes[#3284](https://github.com/gitkraken/vscode-gitlens/issues/3284) +- Changes GitLab & GitLab self-managed access tokens to now require `api` scope instead of `read_api` to be able to merge Pull Requests +- Renames _Open Worktree in New Window_ option to _Open in Worktree_ in _Launchpad_ + +### Fixed + +- Fixes [#3386](https://github.com/gitkraken/vscode-gitlens/issues/3386) - Clicking anywhere on "Get started" should expand the section — thanks to [PR #3402](https://github.com/gitkraken/vscode-gitlens/pull/3402) by Nikolay ([@nzaytsev](https://github.com/nzaytsev)) +- Fixes [#3410](https://github.com/gitkraken/vscode-gitlens/issues/3410) - Adds stash commit message to commit graph row +- Fixes [#3397](https://github.com/gitkraken/vscode-gitlens/issues/3397) - Suppress auth error notifications for github triggered by Launchpad +- Fixes [#3367](https://github.com/gitkraken/vscode-gitlens/issues/3367) - Continually asked to reauthenticate +- Fixes [#3389](https://github.com/gitkraken/vscode-gitlens/issues/3389) - Unable to pull branch 'xxx' from origin +- Fixes [#3394](https://github.com/gitkraken/vscode-gitlens/issues/3394) - Pull request markers, when set in commit graph minimap or scroll, show as unsupported in settings JSON + +## [15.2.0] - 2024-07-10 + +### Added + +- Adds a _Generate Title & Description_ button to the title input in _Create Cloud Patch_ and in _Changes to Suggest_ of the _Inspect Overview_ tab +- Adds support for Anthropic's Claude 3.5 Sonnet model for GitLens' experimental AI features +- Adds a new `counts` option to the `gitlens.launchpad.indicator.label` setting to show the status counts of items which need your attention in the _Launchpad_ status bar indicator +- Adds _Search for Commits within Selection_ command to the editor context menu when there is a selection +- Adds a `gitlens.launchpad.ignoredOrganizations` setting to specify an array of organizations (or users) to ignore in the _Launchpad_ +- Improves the tooltips of stashes in GitLens views + - Adds a `gitlens.views.formats.stashes.tooltip` setting to specify the tooltip format of the stashes in GitLens views +- Improves the display of branch and tag tips in the _File History_ and _Line History_ and in commit tooltips in GitLens views + - Adds provider-specific icons to tips of remote branches +- Adds Commit Graph improvements: + - Adds pull request markers to the graph scroll and minimap + - Adds rich hovers on commit rows which include detailed commit information and links to pull requests, issues, and inspect +- Adds Launchpad improvements: + - Truncates long titles for Pull Requests so that the repository label is always visible + - Adds _Open on GitHub_ button to other relevant rows in the action step + - Adds a new _Open Worktree in New Window_ action and button to Launchpad items to more easily view the item in a worktree + +### Changed + +- Renames `Reset Stored AI Key` command to `Reset Stored AI Keys...` and adds confirmation prompt with options to reset only the current or all AI keys +- Renames _Open Inspect_ to _Inspect Commit Details_ +- Renames _Open Line Inspect_ to _Inspect Line Commit Details_ +- Renames _Open Details_ to _Inspect Commit Details_ +- Replaces _Open in Editor_ link in the Launchpad with a link to _gitkraken.dev_ +- The _Manage Account_ button in the GitKraken Account view and the _GitLens: Manage Your Account_ command now use the account management page at _gitkraken.dev_ +- Fixes some cases where worktree state can be out-of-date after creation/deletion of a worktree + +### Fixed + +- Fixes [#3344](https://github.com/gitkraken/vscode-gitlens/issues/3344) - Make changing the AI key easier +- Fixes [#3377](https://github.com/gitkraken/vscode-gitlens/issues/3377) - Cannot read properties of undefined (reading 'start') +- Fixes [#3377](https://github.com/gitkraken/vscode-gitlens/issues/3378) - Deleting a worktree (without force) with working changes causes double prompts +- Fixes Open SCM command for the Commmit Graph showing in the command palette - Thanks to [PR #3376](https://github.com/gitkraken/vscode-gitlens/pull/3376) by Nikolay ([@nzaytsev](https://github.com/nzaytsev)) +- Fixes fixes issue with Jira integration not refreshing +- Fixes the _Learn More_ link not working in the account verification dialog +- Upgrading to Pro, managing a GitKraken account, and managing or connecting cloud integrations now no longer require the user to log in again in their respective pages on _gitkraken.dev_ +- Fixes deep links failing to cancel in the remote add stage + +## [15.1.0] - 2024-06-05 + +### Added + +- Adds support for GitHub Copilot and other VS Code extension-provided AI models for GitLens' experimental AI features + - Adds a `gitlens.ai.experimental.model` setting to specify the AI model to use + - Adds a `gitlens.ai.experimental.vscode.model` setting to specify the VS Code extension-provided AI model to use when `gitlens.ai.experimental.model` is set to `vscode` +- Adds new Launchpad improvements: + - Collapsed state of Launchpad groups are now saved between uses + - The _Draft_ and _Pinned_ categories in the Launchpad now always sort their items by date + - The Launchpad and Launchpad status bar indicator now indicate when there is an error loading data + - The Launchpad indicator now shows the Launchpad icon next to the loading spinner when the Launchpad is loading + +### Changed + +- Changes the settings used to configure the AI models for GitLens' experimental AI features + - Adds a `gitlens.ai.experimental.model` setting to specify the AI model to use + - Removes the `gitlens.ai.experimental.provider`, `gitlens.ai.experimental.openai.model`, `gitlens.ai.experimental.anthropic.model`, and `gitlens.ai.experimental.gemini.model` settings in favor of the above + +### Fixed + +- Fixes [#3295](https://github.com/gitkraken/vscode-gitlens/issues/3295) - Incorrect pluralization in Authors lens — thanks to [PR #3296](https://github.com/gitkraken/vscode-gitlens/pull/3296) by bm-w ([@bm-w](https://github.com/bm-w)) +- Fixes [#3277](https://github.com/gitkraken/vscode-gitlens/issues/3277) - Unable to pull branch when the local branch whose name differs from its tracking branch + +## [15.0.4] - 2024-05-20 + +### Added + +- Adds a _Copy as Markdown_ context menu command to autolinks in the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view +- Adds a _Connect Remote Integration_ command to the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view +- Adds `gitlens.currentLine.fontFamily`, `gitlens.currentLine.fontSize`, `gitlens.currentLine.fontStyle`, `gitlens.currentLine.fontWeight` settings to specify the font (family, size, style, and weight respectively) of the _Inline Blame_ annotation — closes [#3306](https://github.com/gitkraken/vscode-gitlens/issues/3306) +- Adds `gitlens.blame.fontStyle` settings to specify the font style of the _File Blame_ annotations + +### Changed + +- Improves the _Copy_ context menu command on autolinks in the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view +- Changes the _Open Issue on Remote_ context menu command on autolinks to _Open URL_ in the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view +- Changes the _Copy Issue URL_ context menu command on autolinks to _Copy URL_ in the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view +- Renames the _Connect to Remote_ command to _Connect Remote Integration_ +- Renames the _Disconnect from Remote_ command to _Disconnect Remote Integration_ + +### Fixed + +- Fixes [#3299](https://github.com/gitkraken/vscode-gitlens/issues/3299) - Branches view no longer displays text colors for branch status after updating to v15.0.0 or above +- Fixes [#3277](https://github.com/gitkraken/vscode-gitlens/issues/3277) (in pre-release only) - Unable to pull branch when the local branch whose name differs from its tracking branch +- Fixes "hang" in Worktrees view when a worktree is missing +- Fixes an issue where the Commit Graph header bar sometimes pushes "Fetch" to the right +- Fixes an issue where the autolink type (issue vs pull request) was not shown properly in the _Autolinked Issues and Pull Requests_ section in the _Search & Compare_ view + +## [15.0.3] - 2024-05-14 + +### Fixed + +- Fixes [#3288](https://github.com/gitkraken/vscode-gitlens/issues/3288) - Branch, Tags, Stashes, Local Branch, and Remote Branch "Markers" Are Missing/Removed From Minimap + +## [15.0.2] - 2024-05-14 + +### Fixed + +- Fixes [#3270](https://github.com/gitkraken/vscode-gitlens/issues/3270) - GitLens erroneously thinks certain branches are worktrees under some conditions + +## [15.0.1] - 2024-05-14 + +## [15.0.0] - 2024-05-14 + +### Added + +- Adds [Launchpad](https://gitkraken.com/solutions/launchpad?utm_source=gitlens-extension&utm_medium=in-app-links) `preview`, a new Pro feature bringing your GitHub pull requests into a unified, categorized list to keep you focused and your team unblocked + - Open using the new _GitLens: Open Launchpad_ command + - Categorizes pull requests by status + - _Current Branch_: Pull requests associated with your current branch + - _Ready to Merge_: Pull requests without conflicts, ci failures, change suggestions or other issues preventing merge + - _Blocked_: Pull requests with conflicts, CI failures, or that have no reviewers assigned + - _Needs Your Review_: Pull requests waiting for your review + - _Requires Follow-Up_: Pull requests that have been reviewed and need follow-up + - _Draft_: Draft pull requests + - _Pinned_: Pull requests you have pinned + - _Snoozed_: Pull requests you have snoozed + - _Other_: Other pull requests + - Action on a pull request directly from the Launchpad: + - Merge a pull request + - Open a pull request on GitHub + - Switch to or create a branch or worktree for a pull request to review changes + - Display a pull request's details in the _Overview_ + - Open a pull request's changes in the multi-diff editor + - View a pull request's branch in the _Commit Graph_ + - View or create code suggestions for a pull request + - Pin or snooze a pull request in the Launchpad + - Adds a status bar indicator of the _Launchpad_ + - Opens the Launchpad when clicked + - Shows the top pull request and its status in the status bar + - Also highlights your top pull request in the launchpad when opened from the indicator + - Provides a summary of your most critical pull requests on hover + - Each summary line includes a link to open the Launchpad to that category + - Adds new settings for the Launchpad and indicator + - `gitlens.launchpad.ignoredRepositories`: Array of repositories with `owner/name` format to ignore in the Launchpad + - `gitlens.launchpad.staleThreshold`: Value in days after which a pull request is considered stale and moved to the _Other_ category + - `gitlens.launchpad.indicator.enabled`: Specifies whether to show the Launchpad indicator in the status bar + - `gitlens.launchpad.indicator.icon`: Specifies the style of the Launchpad indicator icon + - `gitlens.launchpad.indicator.label`: Specifies the style of the Launchpad indicator label + - `gitlens.launchpad.indicator.groups`: Specifies which critical categories of pull requests to summarize in the indicator tooltip + - `gitlens.launchpad.indicator.useColors`: Specifies whether to use colors in the indicator + - `gitlens.launchpad.indicator.openInEditor`: Specifies whether to open the Launchpad in the editor when clicked + - `gitlens.launchpad.indicator.polling.enabled`: Specifies whether to regularly check for changes to pull requests + - `gitlens.launchpad.indicator.polling.interval`: Specifies the interval in minutes to check for changes to pull requests +- Adds new features that make code reviews easier + - Adds [Code Suggest](https://gitkraken.com/solutions/code-suggest?utm_source=gitlens-extension&utm_medium=in-app-links) `preview`, a cloud feature, that frees your code reviews from unnecessary restrictions + - Create a Code Suggestion from the _Inspect: Overview_ tab when on a PR's branch + - Upon creation of a Code Suggestion, a comment will appear on the pull request + - Code Suggestions can be viewed and apply directly from [gitkraken.dev](https://gitkraken.dev), or open in GitKraken Desktop or GitLens. + - See a PR's Code Suggestions from anywhere we currently display PR information in our views (Commits, Branches, Remotes) + - You can additionally start Code Suggestions from the Launchpad + - Adds a _Pull Request_ view to view PR commits and review file changes + - Adds a _Pull Request_ badge to the Graph and the Inspect Overview +- Adds rich Jira Cloud integration + - Enables rich automatic Jira autolinks in commit messages everywhere autolinks are supported in GitLens + - Adds a _Cloud Integrations_ button to the GitKraken Account view and a new `GitLens: Manage Cloud Integrations` command to manage connected cloud integrations + - Adds a _Manage Jira_ button to _Inspect_ and a link in Autolink settings to connect to Jira +- Adds support for Google Gemini for GitLens' experimental AI features + - Adds a `gitlens.ai.experimental.gemini.model` setting to specify the Gemini model +- Adds support for the latest OpenAI and Anthropic models for GitLens' experimental AI features +- Adds a new `gitlens.views.collapseWorktreesWhenPossible` setting to specify whether to try to collapse the opened worktrees into a single (common) repository in the views when possible + +### Changed + +- Reworks _Commit Details_, now called the _Inspect_ view + - Revamps the _Working Changes_ tab into the _Overview_ tab + - Provides richer branch status information and branch switching + - Adds Push, Pull, and Fetch actions + - Richer Pull Request Information + - Open details in the Pull Request view + - Links to open and compare changes + - List of the PR's Code Suggestions + - Create a Code Suggestion by clicking the _Suggest Changes for PR_ button +- Improves contributor and team member picking for the adding co-authors, _Code Suggest_, and _Cloud Patches_ +- Improves performance when creating colors derived from the VS Code theme +- Changes the command to open the Launchpad in the editor (formerly _Focus View_) from _GitLens: Show Focus_ to _GitLens: Open Launchpad in Editor_ +- Renames the setting `gitlens.focus.allowMultiple` to `gitlens.launchpad.allowMultiple` +- Updates most deep link prompts to quick picks or quick inputs, moves most prompts to before a repository is opened. +- Updates Pro upgrade links to use the newer gitkraken.dev site + +### Fixed + +- Fixes [#3221](https://github.com/gitkraken/vscode-gitlens/issues/3221) - Cannot use word "detached" in branch names +- Fixes [#3197](https://github.com/gitkraken/vscode-gitlens/issues/3197) - Only emojify standalone emojis — thanks to [PR #3208](https://github.com/gitkraken/vscode-gitlens/pull/3208) by may ([@m4rch3n1ng](https://github.com/m4rch3n1ng)) +- Fixes [#3180](https://github.com/gitkraken/vscode-gitlens/issues/3180) - Focus View feedback button is not working +- Fixes [#3179](https://github.com/gitkraken/vscode-gitlens/issues/3179) - The checkmarks in cherry pick are not displayed +- Fixes [#3249](https://github.com/gitkraken/vscode-gitlens/issues/3249) - Error "Cannot read properties of null (reading 'map') +- Fixes [#3198](https://github.com/gitkraken/vscode-gitlens/issues/3198) - Repository location in cloud workspace doesn't work when the repo descriptor does not contain a url +- Fixes [#3143](https://github.com/gitkraken/vscode-gitlens/issues/3143) - File Annotation icon isn't themed according to the icons... + +## [14.9.0] - 2024-03-06 + +### Added + +- Adds support for Anthropic's Claude 3 Opus & Sonnet models for GitLens' experimental AI features +- Adds a _Compare with Common Base_ command to branches in the _Commit Graph_ and views to review the changes if the selected branch were to be merged by comparing the common ancestor (merge base) with the current branch to the selected branch +- Adds an _Open All Changes with Common Base_ command to branches in the _Commit Graph_ and views to review the changes if the selected branch were to be merged in the multi-diff editor +- Adds a _Stash All Changes_ command to Source Control repository toolbar (off by default) +- Adds the repository name as a prefix to worktree name when adding to the current workspace +- Adds a better message when stashing only untracked files without including untracked files +- Adds a new group of _Cloud Patches_ titled as “Suggested Changes” that includes suggestions coming from pull requests. + +### Changed + +- Re-adds _Add to Workspace_ option when creating a worktree — closes [#3160](https://github.com/gitkraken/vscode-gitlens/issues/3160) +- Changes _Commit Graph_ date style to default to the default date style — refs [#3153](https://github.com/gitkraken/vscode-gitlens/issues/3153) +- Renames the _Compare Ancestry with Working Tree_ command on branches to _Compare Common Base with Working Tree_ for better clarity +- Improves _File Blame_ annotations performance and layout accuracy with certain character sets +- Improves string formatting performance + +### Fixed + +- Fixes [#3146](https://github.com/gitkraken/vscode-gitlens/issues/3146) - Search & Compare fails to remember items after restart +- Fixes [#3152](https://github.com/gitkraken/vscode-gitlens/issues/3152) - Fixes double encoding of redirect URLs during account sign-in which affects certain environments +- Fixes [#3153](https://github.com/gitkraken/vscode-gitlens/issues/3153) - `gitlens.defaultDateStyle` not working in Commit Details view +- Fixes the _Open Pull Request Changes_ & _Compare Pull Request_ commands to scope the changes only to the pull request +- Fixes broken _Compare Common Base with Working Tree_ (previously _Compare Ancestry with Working Tree_) +- Fixes issue when switching to a worktree via branch switch when there are multiple repos in the workspace + +## [14.8.2] - 2024-02-16 + +### Fixed + +- Fixes incorrect organization self-hosting message when creating a Cloud Patch + +## [14.8.1] - 2024-02-15 + +### Added + +- Adds a _Create New Branch..._ option to the _Git Switch to..._ command to easily create a new branch to switch to — closes [#3138](https://github.com/gitkraken/vscode-gitlens/issues/3138) +- Adds the ability to start a new trial from the _Account View_ and feature gates for users without a Pro account whose Pro trial has been expired for over 90 days. + +### Fixed + +- Fixes AI features not being displayed when signed-out of an account + +## [14.8.0] - 2024-02-08 + +### Added + +- Adds support for Cloud Patches hosted on your own dedicated storage for the highest level of security (requires an Enterprise plan) +- Improves worktree usage, discoverability, and accessibility + - Simplifies the create worktree and open worktree flows — reduces number of steps and options presented + - Adds _Create Branch in New Worktree_ confirmation option when creating branches, e.g. via the _GitLens: Git Create Branch..._ command + - Adds _Create Worktree for Branch_, _Create Worktree for Local Branch_, and _Create Worktree for New Local Branch_ confirmation options when switching branches, e.g. via the _GitLens: Git Switch to..._ command + - Adds a _Copy Working Changes to Worktree..._ command to the _Commit Graph_ and command palette to copy the current working changes to an existing worktree + - Avoids prompt to add a (required) remote and instead auto-adds the remote during worktree creation from a pull request +- Adds ability to open multiple changes in VS Code's new multi-diff editor, previously experimental and now enabled by default + - Adds an inline _Open All Changes_ command to commits, stashes, and comparisons in the views + - Changes _Open All Changes_ & _Open All Changes with Working Tree_ commands to use the new multi-diff editor when enabled + - Adds _Open All Changes, Individually_ & _Open All Changes with Working Tree, Individually_ commands to provide access to the previous behavior + - Renames the `gitlens.experimental.openChangesInMultiDiffEditor` setting to `gitlens.views.openChangesInMultiDiffEditor`, which is enabled by default, to specify whether to open changes in the multi-diff editor (single tab) or in individual diff editors (multiple tabs) + - Requires VS Code `1.86` or later, or VS Code `1.85` with `multiDiffEditor.experimental.enabled` enabled +- Adds new comparison features to pull requests in GitLens views + - Adds an _Open Pull Request Changes_ context menu command on pull requests in the _Commit Graph_ and other GitLens views to view pull request changes in a multi-diff editor (single tab) + - Requires VS Code `1.86` or later, or VS Code `1.85` with `multiDiffEditor.experimental.enabled` enabled + - Adds a _Compare Pull Request_ context menu command on pull requests in the _Commit Graph_ and other GitLens views to open a comparison between the head and base of the pull request for easy reviewing +- Adds an _Open in Commit Graph_ context menu command on pull requests in GitLens view to open the tip commit in the _Commit Graph_ +- Adds ability to copy changes, commits, stashes, and comparison as a patch to the clipboard + - Adds a _Copy as Patch_ context menu command on files, commits, stashes, and comparisons in GitLens views + - Adds a _Copy as Patch_ context menu command on files in the _Changes_ and _Staged Changes_ groups as well as the groups themselves in the _Source Control_ view + - Adds a _Apply Copied Patch_ command in the command palette to apply a patch from the clipboard +- Adds an _Open All Changes_ inline button to branch status (upstream) and branch status files in GitLens views +- Adds an _Open Changes_ submenu to branch status (upstream) and branch status files in GitLens views +- Adds ability to preserve inline and file annotations while editing, previously experimental and now enabled by default + - Renames the `gitlens.experimental.allowAnnotationsWhenDirty` setting to `gitlens.fileAnnotations.preserveWhileEditing`, which is enabled by default, to specify whether file annotations will be preserved while editing — closes [#1988](https://github.com/gitkraken/vscode-gitlens/issues/1988), [#3016](https://github.com/gitkraken/vscode-gitlens/issues/3016) + - Use the existing `gitlens.advanced.blame.delayAfterEdit` setting to control how long to wait (defaults to 5s) before the annotation will update while the file is still dirty, which only applies if the file is under the `gitlens.advanced.sizeThresholdAfterEdit` setting threshold (defaults to 5000 lines) +- Adds an _Open File Annotation Settings_ command to the _File Annotations_ submenu in the editor toolbar to open the GitLens Settings editor to the file annotations sections +- Adds `gitlens.blame.fontFamily`, `gitlens.blame.fontSize`, `gitlens.blame.fontWeight` settings to specify the font (family, size, and weight respectively) of the _File Blame_ annotations — closes [#3134](https://github.com/gitkraken/vscode-gitlens/issues/3134) +- Adds _Copy Link to Code_, _Copy Link to File_, and _Copy Link to File at Revision..._ commands to the _Share_ submenu in the editor line number (gutter) context menu +- Adds an alternate flow (pick another file) when using the _Open File at Revision..._ and _Open Changes with Revision..._ commands to open a file that has been renamed and the rename is currently unstaged — closes [#3109](https://github.com/gitkraken/vscode-gitlens/issues/3109) +- Adds access to most _Git Command Palette_ commands directly to the command palette +- Adds _Rename Stash..._ options to stash quick pick menus +- Adds support for the latest GPT-4 Turbo models + +### Changed + +- Changes adds avatars to commits in quick pick menus +- Changes the pull request to be first item in the _Commits_ view, when applicable +- Changes the branch comparison to be below the branch status in the _Commits_ view to keep top focus on the status over the comparison +- Renames "Open Worktree for Pull Request via GitLens..." to "Checkout Pull Request in Worktree (GitLens)..." +- Renames the `gitlens.experimental.openChangesInMultiDiffEditor` setting to `gitlens.views.openChangesInMultiDiffEditor` as it is no longer experimental and enabled by default + +### Fixed + +- Fixes [#3438](https://github.com/gitkraken/vscode-gitlens/issues/3438) - UsageTracker first track creates an object with count 0 +- Fixes [#3115](https://github.com/gitkraken/vscode-gitlens/issues/3115) - Always-on file annotations +- Fixes ahead/behind diffs on files (root) in the _Commits_ view to correctly show the diff of the range rather than the base to the working tree +- Fixes missing repository icons in the _Repositories_ view +- Fixes [#3116](https://github.com/gitkraken/vscode-gitlens/issues/3116) - Fix typos in README.md and package.json — thanks to [PR #3117](https://github.com/gitkraken/vscode-gitlens/pull/3117) by yutotnh ([@yutotnh](https://github.com/yutotnh)) + +## [14.7.0] - 2024-01-17 + +### Added + +- Adds the ability to share Cloud Patches with specific members of your GitKraken organization + - You can now share Cloud Patches exclusively with specific members of your organization by selecting _Collaborators Only_ when viewing or creating a Cloud Patch + - Click the _Invite_ button at the bottom of the _Patch Details_ view to add members of your organization to collaborate and click _Update Patch_ to save your changes + - Cloud Patch collaborators will see these Patches under the _Shared with Me_ section of the _Cloud Patches_ view +- Adds support for deep links to files and code + - Deep link format: `https://gitkraken.dev/link/r/{repoId}/f/{filePath}?[url={remoteUrl}|path={repoPath}]&lines={lines}&ref={ref}` + - Adds _Copy Link to File_, _Copy Link to File at Revision..._, and _Copy Link to Code_ commands to the _Copy As_ submenu in the editor context menu and to the _Share_ submenu of files in GitLens views +- Adds the ability to choose multiple stashes to drop in the _Git Command Palette_'s _stash drop_ command — closes [#3102](https://github.com/gitkraken/vscode-gitlens/issues/3102) +- Adds a new _prune_ subcommand to the _Git Command Palette_'s _branch_ command to easily delete local branches with missing upstreams +- Adds a new _Push Stash Snapshot_ confirmation option to the _Git Command Palette_'s _stash push_ command to save a stash without changing the working tree +- Adds _Copy_ to search results in the _Search & Compare_ view to copy the search query to more easily share or paste queries into the _Commit Graph_ +- Adds a status bar indicator when blame annotations (inline, statusbar, file annotations, etc) are paused because the file has unsaved changes (dirty), with a tooltip explaining why and how to configure/change the behavior +- Adds an experimental `gitlens.experimental.allowAnnotationsWhenDirty` setting to specify whether file annotations are allowed on files with unsaved changes (dirty) — closes [#1988](https://github.com/gitkraken/vscode-gitlens/issues/1988), [#3016](https://github.com/gitkraken/vscode-gitlens/issues/3016) + - Use the existing `gitlens.advanced.blame.delayAfterEdit` setting to control how long to wait (defaults to 5s) before the annotation will update while the file is still dirty, which only applies if the file is under the `gitlens.advanced.sizeThresholdAfterEdit` setting threshold (defaults to 5000 lines) +- Adds a `gitlens.fileAnnotations.dismissOnEscape` setting to specify whether pressing the `ESC` key dismisses the active file annotations — closes [#3016](https://github.com/gitkraken/vscode-gitlens/issues/3016) + +### Changed + +- Changes the commit search by file to allow some fuzziness by default — closes [#3086](https://github.com/gitkraken/vscode-gitlens/issues/3086) + - For example, if you enter `file:readme.txt`, we will treat it as `file:**/readme.txt`, or if you enter `file:readme` it will be treated as `file:*readme*` +- Improves the _Switch_ command to no longer fail when trying to switch to a branch that is linked to another worktree and instead offers to open the worktree +- Changes branch/tag "tips" that are shown on commits in many GitLens views to be truncated to 11 characters by default to avoid stealing to much real estate + +### Fixed + +- Fixes [#3087](https://github.com/gitkraken/vscode-gitlens/issues/3087) - Terminal executed commands fail if the GitLens terminal is closed +- Fixes [#2784](https://github.com/gitkraken/vscode-gitlens/issues/2784) - Git stash push error +- Fixes [#2926](https://github.com/gitkraken/vscode-gitlens/issues/2926) in more cases - "Open File at Revision" has incorrect editor label if revision contains path separator — thanks to [PR #3060](https://github.com/gitkraken/vscode-gitlens/issues/3060) by Ian Chamberlain ([@ian-h-chamberlain](https://github.com/ian-h-chamberlain)) +- Fixes [#3066](https://github.com/gitkraken/vscode-gitlens/issues/3066) - Editing a large file and switching away to another file without saving causes current line blame to disappear; thanks to [PR #3067](https://github.com/gitkraken/vscode-gitlens/pulls/3067) by Brandon Cheng ([@gluxon](https://github.com/gluxon)) +- Fixes [#3063](https://github.com/gitkraken/vscode-gitlens/issues/3063) - Missing icons in GitLens Settings UI +- Fixes issue with _Switch_ command not honoring the confirmation setting +- Fixes worktree delete from offering to delete main worktree (which isn't possible) +- Fixes worktree delete on windows when the worktree's folder is missing + +### Removed + +- Removes the `gitlens.experimental.nativeGit` setting as it is now the default experience — closes [#3055](https://github.com/gitkraken/vscode-gitlens/issues/3055) + +## [14.6.1] - 2023-12-14 + +### Fixed + +- Fixes [#3057](https://github.com/gitkraken/vscode-gitlens/issues/3057) - Uncommitted changes cause an error when gitlens.defaultDateSource is "committed" + +## [14.6.0] - 2023-12-13 + +### Added + +- Adds the ability to specify who can access a Cloud Patch when creating it + - _Anyone with the link_ — allows anyone with the link and a GitKraken account to access the Cloud Patch + - _Members of my Org with the link_ — allows only members of your selected GitKraken organization with the link to access the Cloud Patch + - (Coming soon to GitLens) Ability to explicitly share to specific members from your organization and add them as collaborators on a Cloud Patch + - Cloud Patches that have been explicitly shared with you, i.e. you are a collaborator, now will appear in the _Cloud Patches_ view under _Shared with Me_ +- Adds timed snoozing for items in the _Focus View_ — choose from a selection of times when snoozing and the item will automatically move out of the snoozed tab when that time expires +- Adds the ability to open folder changes — closes [#3020](https://github.com/gitkraken/vscode-gitlens/issues/3020) + - Adds _Open Folder Changes with Revision..._ & _Open Folder Changes with Branch or Tag..._ commands to the command palette and to the _Explorer_ and _Source Control_ views + - Requires VS Code `1.85` or later and `multiDiffEditor.experimental.enabled` to be enabled +- Adds last modified time of the file when showing blame annotations for uncommitted changes +- Adds search results to the minimap tooltips on the _Commit Graph_ +- Adds support for Anthropic's Claude 2.1 model for GitLens' experimental AI features +- Adds a status indicator when the upstream branch is missing in _Commits_ view +- Adds support for opening renamed/deleted files using the _Open File at Revision..._ & _Open File at Revision from..._ commands by showing a quick pick menu if the requested file doesn't exist in the selected revision — closes [#708](https://github.com/gitkraken/vscode-gitlens/issues/708) thanks to [PR #2825](https://github.com/gitkraken/vscode-gitlens/pull/2825) by Victor Hallberg ([@mogelbrod](https://github.com/mogelbrod)) +- Adds an _Open Changes_ submenu to comparisons in the _Search & Compare_ view +- Adds experimental `gitlens.experimental.openChangesInMultiDiffEditor` setting to specify whether to open multiple changes in VS Code's experimental multi-diff editor (single tab) or in individual diff editors (multiple tabs) + - Adds an inline _Open All Changes_ command to commits, stashes, and comparisons in the views + - Changes _Open All Changes_ & _Open All Changes with Working Tree_ commands to use the new multi-diff editor when enabled + - Adds _Open All Changes, Individually_ & _Open All Changes with Working Tree, Individually_ commands to provide access to the previous behavior + - Requires VS Code `1.85` or later and `multiDiffEditor.experimental.enabled` to be enabled +- Adds a confirmation prompt when attempting to undo a commit with uncommitted changes +- Adds a _[Show|Hide] Merge Commits_ toggle to the _Contributors_ view +- Adds _Open in Integrated Terminal_ command to repositories in the views — closes [#3053](https://github.com/gitkraken/vscode-gitlens/issues/3053) +- Adds _Open in Terminal_ & _Open in Integrated Terminal_ commands to the upstream status in the _Commits_ view +- Adds the ability to choose an active GitKraken organization in the _Account View_ for users with multiple GitKraken organizations. + +### Changed + +- Improves AI model choice selection for GitLens' experimental AI features +- Improves performance when logging is enabled +- Changes the contextual view title from GL to GitLens + +### Fixed + +- Fixes [#2663](https://github.com/gitkraken/vscode-gitlens/issues/2663) - Debounce bug: file blame isn't cleared when editing document while text in output window changes +- Fixes [#3050](https://github.com/gitkraken/vscode-gitlens/issues/3050) - Opening revision of a renamed file is broken +- Fixes [#3019](https://github.com/gitkraken/vscode-gitlens/issues/3019) - Commits Views not working +- Fixes [#3026](https://github.com/gitkraken/vscode-gitlens/issues/3026) - Gitlens stopped working in sub-repositories +- Fixes [#2746](https://github.com/gitkraken/vscode-gitlens/issues/2746) - Remove 'undo commit' command from gitlens inspect +- Fixes [#2482](https://github.com/gitkraken/vscode-gitlens/issues/2482) - Unresponsive "commits" view and "branches" view update due to git log +- Fixes duplicate entries in the _Search & Compare_ view when adding a new comparison from outside the view and before the view has loaded +- Fixes _Load more_ in the _File History_ view when the file has been renamed +- Fixes broken _Open Changed & Close Unchanged Files_ (`gitlens.views.openOnlyChangedFiles`) command in the views +- Fixes issues with _Contributors_ view updating when changing toggles +- Fixes issues with _Open [Previous] Changes with Working File_ command in comparisons +- Fixes banner styling on the _Commit Graph_ + +## [14.5.2] - 2023-11-30 + +### Added + +- Adds cyber week promotion + +## [14.5.1] - 2023-11-21 + +### Added + +- Adds support for OpenAI's GPT-4 Turbo and latest Anthropic models for GitLens' experimental AI features — closes [#3005](https://github.com/gitkraken/vscode-gitlens/issues/3005) + +### Changed + +- Improves the performance of the _Commit Graph_ when loading a large number of commits +- Refines AI prompts to provide better commit message generation and explanation results +- Updates Files Changed panel of _Commit Details_, which now supports indent settings and adds better accessibility + +### Fixed + +- Fixes [#3023](https://github.com/gitkraken/vscode-gitlens/issues/3023) - "Unable to show blame. Invalid or missing blame.ignoreRevsFile" with valid ignore revs file +- Fixes [#3018](https://github.com/gitkraken/vscode-gitlens/issues/3018) - Line blame overlay is broken when commit message contains a `)` +- Fixes [#2625](https://github.com/gitkraken/vscode-gitlens/issues/2625) - full issue ref has escape characters that break hover links +- Fixes stuck busy state of the _Commit Details_ Explain AI panel after canceling a request +- Fixes cloud patch deep links requiring a paid plan (while in preview) + +## [14.5.0] - 2023-11-13 + +### Added + +- Adds a preview of [Cloud Patches](https://www.gitkraken.com/solutions/cloud-patches), an all-new â˜ī¸ feature — engage in early collaboration before the pull request: + - Share your work with others by creating a Cloud Patch from Working Changes, Commits, Stashes or Comparisons + - View Cloud Patches from URLs shared to you and apply them to your working tree or to a new or existing branch + - Manage your Cloud Patches from the new _Cloud Patches_ view in the GitLens side bar + - Adds a _Share as Cloud Patch..._ command to the command palette and to the _Share_ submenu in applicable GitLens views + - Adds a `gitlens.cloudPatches.enabled` setting to specify whether to enable Cloud Patches (defaults to `true`) +- Adds support to open multiple instances of the _Commit Graph_, _Focus_, and _Visual File History_ in the editor area + - Adds a _Split Commit Graph_ command to the _Commit Graph_ tab context menu + - Adds a `gitlens.graph.allowMultiple` setting to specify whether to allow opening multiple instances of the _Commit Graph_ in the editor area + - Adds a _Split Focus_ command to the _Focus_ tab context menu + - Adds a `gitlens.focus.allowMultiple` setting to specify whether to allow opening multiple instances of the _Focus_ in the editor area + - Adds a _Split Visual File History_ command to the _Visual File History_ tab context menu + - Adds a `gitlens.visualHistory.allowMultiple` setting to specify whether to allow opening multiple instances of the _Visual File History_ in the editor area +- Adds a _Generate Commit Message (Experimental)_ button to the SCM input when supported (currently `1.84.0-insider` only) + - Adds a `gitlens.ai.experimental.generateCommitMessage.enabled` setting to specify whether to enable GitLens' experimental, AI-powered, on-demand commit message generation — closes [#2652](https://github.com/gitkraken/vscode-gitlens/issues/2652) +- Improves the experience of the _Search Commits_ quick pick menu + - Adds a stateful authors picker to make it much easier to search for commits by specific authors + - Adds a file and folder picker to make it much easier to search for commits containing specific files or in specific folders +- Adds ability to sort repositories in the views and quick pick menus — closes [#2836](https://github.com/gitkraken/vscode-gitlens/issues/2836) thanks to [PR #2991](https://github.com/gitkraken/vscode-gitlens/pull/2991) + - Adds a `gitlens.sortRepositoriesBy` setting to specify how repositories are sorted in quick pick menus and views by Aidos Kanapyanov ([@aidoskanapyanov](https://github.com/aidoskanapyanov)) +- Adds a _[Show|Hide] Merge Commits_ toggle to the _Commits_ view — closes [#1399](https://github.com/gitkraken/vscode-gitlens/issues/1399) thanks to [PR #1540](https://github.com/gitkraken/vscode-gitlens/pull/1540) by Shashank Shastri ([@Shashank-Shastri](https://github.com/Shashank-Shastri)) +- Adds a _Filter Commits by Author..._ commands to the _Commits_ view and comparisons context menus to filter commits in the _Commits_ view by specific authors +- Adds ability to publish to a remote branch to a specific commit using the _Push to Commit_ command +- Adds an _Open Comparison on Remote_ command to comparisons in views +- Adds a _Share > Copy Link to Repository_ command on branches in the views +- Adds _Share > Copy Link to Branch_ and _Share > Copy Link to Repository_ commands on the current branch status in the _Commits_ view +- Adds a _Clear Reviewed Files_ command to comparisons to clear all reviewed files — closes [#2987](https://github.com/gitkraken/vscode-gitlens/issues/2987) +- Adds a _Collapse_ command to many view nodes +- Adds a `gitlens.liveshare.enabled` setting to specify whether to enable integration with Visual Studio Live Share + +### Changed + +- Improves accuracy, performance, and memory usage related to parsing diffs, used in _Changes_ hovers, _Changes_ file annotations, etc +- Improves confirmation messaging in the _Git Command Palette_ +- Refines merge/rebase messaging when there is nothing to do — refs [#1660](https://github.com/gitkraken/vscode-gitlens/issues/1660) +- Improves view messaging while loading/discovering repositories +- Honors VS Code's `git.useForcePushWithLease` and `git.useForcePushIfIncludes` settings when force pushing +- Changes _File Heatmap_ annotations to not color the entire line by default. Want it back, add `line` to the `gitlens.heatmap.locations` setting + +### Fixed + +- Fixes [#2997](https://github.com/gitkraken/vscode-gitlens/issues/2997) - "push to commit" pushes everything instead of up to the selected commit +- Fixes [#2615](https://github.com/gitkraken/vscode-gitlens/issues/2615) - Source Control views disappear after opening a file beyond a symbolic link +- Fixes [#2443](https://github.com/gitkraken/vscode-gitlens/issues/2443) - UNC-PATH: File History changes not displaying any changes when open +- Fixes [#2625](https://github.com/gitkraken/vscode-gitlens/issues/2625) - full issue ref has escape characters that break hover links +- Fixes [#2987](https://github.com/gitkraken/vscode-gitlens/issues/2987) - Unable to remove all marks on reviewed files with a single operation +- Fixes [#2923](https://github.com/gitkraken/vscode-gitlens/issues/2923) - TypeError: Only absolute URLs are supported +- Fixes [#2926](https://github.com/gitkraken/vscode-gitlens/issues/2926) - "Open File at Revision" has incorrect editor label if revision contains path separator +- Fixes [#2971](https://github.com/gitkraken/vscode-gitlens/issues/2971) - \[Regression\] The branch column header text disappears when you have a hidden ref +- Fixes [#2814](https://github.com/gitkraken/vscode-gitlens/issues/2814) - GitLens Inspect: "Files Changed" not following when switching between commits in File History +- Fixes [#2952](https://github.com/gitkraken/vscode-gitlens/issues/2952) - Inline blame not working because of missing ignoreRevsFile +- Fixes issue where _Changes_ hovers and _Changes_ file annotations sometimes weren't accurate +- Fixes intermittent issue where inline blame and other revision-based editor features are unavailable when repository discovery takes a bit +- Fixes intermittent issues where details sometimes get cleared/overwritten when opening the _Commit Details_ view +- Fixes issue when clicking on commits in the Visual File History to open the _Commit Details_ view +- Fixes issue opening stashes in the _Commit Details_ view from the _Stashes_ view +- Fixes issue where GitHub/GitLab enriched autolinks could incorrectly point to the wrong repository +- Fixes issue showing folder history in the _File History_ view when there are uncommitted changes (staged or unstaged) +- Fixes issue when pushing to a remote branch with different name than the local +- Fixes tooltip styling/theming on the _Commit Graph_ +- Fixes issues staged files in repositories not "opened" (discovered) by the built-in Git extension + +## [14.4.0] - 2023-10-13 + +### Added + +- Adds a _Working Changes_ tab to the _Commit Details_ and _Graph Details_ views to show your working tree changes + - Adds _Stage Changes_ and _Unstage Changes_ commands to files on the _Working Changes_ tab +- Adds a _[Show|Hide] Merge Commits_ toggle to the _File History_ view — closes [#2104](https://github.com/gitkraken/vscode-gitlens/issues/2104) & [#2944](https://github.com/gitkraken/vscode-gitlens/issues/2944) + - Adds a `gitlens.advanced.fileHistoryShowMergeCommits` setting to specify whether merge commits will be show in file histories +- Adds deep link support for workspaces in the _GitKraken Workspaces_ view + - Deep link format: `https://gitkraken.dev/link/workspaces/{workspaceId}` + - Adds a _Share_ submenu with a _Copy Link to Workspace_ command to workspaces in the _GitKraken Workspaces_ view + +### Changed + +- Improves performance of inline blame, status bar blame, and hovers especially when working with remotes with connected integrations +- Changes the _File History_ view to follow renames and filters out merge commits by default — closes [#2104](https://github.com/gitkraken/vscode-gitlens/issues/2104) & [#2944](https://github.com/gitkraken/vscode-gitlens/issues/2944) +- Changes the _File History_ view to allow following renames while showing history across all branches (which was a previous limitation of Git) — closes [#2828](https://github.com/gitkraken/vscode-gitlens/issues/2828) +- Changes to use our own implementation of `fetch`, `push`, and `pull` Git operations, rather than delegating to VS Code to avoid limitations especially with GitKraken Workspaces. Please report any issues and you can revert this (for now) by setting `"gitlens.experimental.nativeGit"` to `"false"` in your settings +- Relaxes PR autolink detection for Azure DevOps to use `PR ` instead of `Merged PR ` — closes [#2908](https://github.com/gitkraken/vscode-gitlens/issues/2908) +- Changes wording on `Reset Stored OpenAI Key` command to `Reset Stored AI Key` to reflect support for other providers + +### Fixed + +- Fixes [#2941](https://github.com/gitkraken/vscode-gitlens/issues/2941) - Invalid Request when trying to generate a commit message using Anthropic API +- Fixes [#2940](https://github.com/gitkraken/vscode-gitlens/issues/2940) - Can't use Azure OpenAI model because i can't save the openai key because of the verification +- Fixes [#2928](https://github.com/gitkraken/vscode-gitlens/issues/2928) - Apply Changes should create new files when needed +- Fixes [#2896](https://github.com/gitkraken/vscode-gitlens/issues/2896) - Repositories view stuck in loading state +- Fixes [#2460](https://github.com/gitkraken/vscode-gitlens/issues/2460) - Gitlens Remote provider doesn't work properly in "Commit graph" view +- Fixes issue with "View as [List|Tree]" toggle not working in the _Commit Details_ view +- Fixes an issue with deep links sometimes failing to properly resolve when a matching repository without the remote is found +- Fixes an issue in the _Commit Graph_ where commits not in the history of a merge commit were showing in the same column +- Fixes `Reset Stored AI Key` command to work for the current provider +- Fixes an issue with parsing some renames in log output + +## [14.3.0] - 2023-09-07 + +### Added + +- Adds checkboxes to files in comparisons to allow for tracking review progress — closes [#836](https://github.com/gitkraken/vscode-gitlens/issues/836) +- Allows the _Commit Graph_ to be open in the panel and in the editor area simultaneously +- Adds an _Open Changes_ button to commits in the file history quick pick menu — closes [#2641](https://github.com/gitkraken/vscode-gitlens/issues/2641) thanks to [PR #2800](https://github.com/gitkraken/vscode-gitlens/pull/2800) by Omar Ghazi ([@omarfesal](https://github.com/omarfesal)) + +### Changed + +- Changes the `gitlens.graph.layout` setting to be a default preference rather than a mode change + +### Fixed + +- Fixes [#2885](https://github.com/gitkraken/vscode-gitlens/issues/2885) - Folder History not show changed files of commit +- Fixes issues with opening changes (diffs) of renamed files +- Fixes issues with deep links including when opening VS Code from the deep link + +## [14.2.1] - 2023-08-10 + +### Added + +- Adds a _Refresh_ action to the _Commit Details_ view + +### Fixed + +- Fixes [#2850](https://github.com/gitkraken/vscode-gitlens/issues/2850) - For custom remotes, the URL resulting from the branches is truncated +- Fixes [#2841](https://github.com/gitkraken/vscode-gitlens/issues/2841) - Error when trying to browse commits +- Fixes [#2847](https://github.com/gitkraken/vscode-gitlens/issues/2847) - 14.2.0 Breaks "pull" action works fine in 14.1.1 + +## [14.2.0] - 2023-08-04 + +### Added + +- Improves the _Focus_ view experience + - Unifies pull requests and issues into a single view + - Adds tabs to switch between showing Pull Requests, Issues, or All + - Adds a filter/search box to quickly find pull request or issues by title + - Adds ability to click on a branch name to show the branch on the _Commit Graph_ +- Adds a new command _Open Changed & Close Unchanged Files..._ to the command palette, the context menu of the _Commit Graph_ work-in-progress (WIP) row, and the SCM group context menu to open all changed files and close all unchanged files. +- Adds a new command _Reset Current Branch to Tip..._ to branch context menus in the _Commit Graph_ and in GitLens views to reset the current branch to the commit at the chosen branch's tip. + +### Changed + +- Changes _Compact Graph Column Layout_ context menu command to _Use Compact Graph Column_ for better clarity +- Changes _Default Graph Column Layout_ context menu command to _Use Expanded Graph Column_ for better clarity +- Improves remote parsing for better integration support for some edge cases + +### Fixed + +- Fixes [#2823](https://github.com/gitkraken/vscode-gitlens/issues/2823) - Handle stdout/stderr Buffers in shell run() — thanks to [PR #2824](https://github.com/gitkraken/vscode-gitlens/pull/2824) by Victor Hallberg ([@mogelbrod](https://github.com/mogelbrod)) +- Fixes issues with missing worktrees breaking the Worktrees view and Worktree quick pick menus + +## [14.1.1] - 2023-07-18 + +### Added + +- Adds the ability to provide a custom url to support Azure-hosted Open AI models — refs [#2743](https://github.com/gitkraken/vscode-gitlens/issues/2743) + +### Changed + +- Improves autolink URL generation by improving the "best" remote detection — refs [#2425](https://github.com/gitkraken/vscode-gitlens/issues/2425) +- Improves preserving the ref names in deeplinks to comparisons + +### Fixed + +- Fixes [#2744](https://github.com/gitkraken/vscode-gitlens/issues/2744) - GH enterprise access with _Focus_ +- Fixes deeplink comparison ordering for a better experience +- Fixes deeplinks to comparisons with working tree not resolving + +## [14.1.0] - 2023-07-13 + +### Added + +- Adds the ability to link a GitKraken Cloud workspace with an associated VS Code workspace + - Adds ability to automatically add repositories to the current VS Code workspace that were added to its associated GitKraken Cloud workspace, if desired + - Adds a _Change Linked Workspace Auto-Add Behavior..._ context menu command on the _Current Window_ and linked workspace to control the desired behavior + - Adds an _Add Repositories from Linked Workspace..._ context menu command on the _Current Window_ item to trigger this manually + - Adds a new _Open VS Code Workspace_ command to open an existing VS Code workspace associated with a GitKraken Cloud workspace + - Adds a highlight (green) to the linked GitKraken Cloud workspace when the current VS Code workspace is associated with it in the _GitKraken Workspaces_ view +- Adds deep link support for comparisons in the _Search & Compare_ view + - Deep link format: `vscode://eamodio.gitlens/r/{repoId}/compare/{ref1}[..|...]{ref2}?[url={remoteUrl}|path={repoPath}]` + - Adds a _Share_ submenu with a _Copy Link to Comparison_ command to comparisions in the _Search & Compare_ view +- Adds support for Anthropic's Claude 2 AI model +- Adds a progress notification while repositories are being added to a GitKraken Cloud workspace + +### Changed + +- Improves scrolling performance on the _Commit Graph_ +- Renames _Convert to VS Code Workspace_ to _Create VS Code Workspace_ for workspaces in the _GitKraken Workspaces_ view to better reflect the behavior of the action +- Hides _Create VS Code Workspace_ and _Locate All Repositories_ commands on empty workspaces in the _GitKraken Workspaces_ view + +### Fixed + +- Fixes [#2798](https://github.com/gitkraken/vscode-gitlens/issues/2798) - Improve response from OpenAI if key used is tied to a free account +- Fixes [#2785](https://github.com/gitkraken/vscode-gitlens/issues/2785) - Remote Provider Integration URL is broken — thanks to [PR #2786](https://github.com/gitkraken/vscode-gitlens/pull/2786) by Neil Ghosh ([@neilghosh](https://github.com/neilghosh)) +- Fixes [#2791](https://github.com/gitkraken/vscode-gitlens/issues/2791) - Unable to use contributors link in README.md — thanks to [PR #2792](https://github.com/gitkraken/vscode-gitlens/pull/2792) by Leo Dan PeÃąa ([@leo9-py](https://github.com/leo9-py)) +- Fixes [#2793](https://github.com/gitkraken/vscode-gitlens/issues/2793) - Requesting username change in contributors README page — thanks to [PR #2794](https://github.com/gitkraken/vscode-gitlens/pull/2794) by Leo Dan PeÃąa ([@leo9-py](https://github.com/leo9-py)) +- Fixes some rendering issues when scrolling in the _Commit Graph_ +- Fixes an issue with some shared workspaces not showing up in the _GitKraken Workspaces_ view when they should +- Fixes an issue when adding repositories to a workspace in the _GitKraken Workspaces_ view where the added repository would show as missing until refreshing the view + +## [14.0.1] - 2023-06-19 + +### Changed + +- Changes view's contextual title to "GL" to appear more compact when rearranging views + +### Fixed + +- Fixes [#2731](https://github.com/gitkraken/vscode-gitlens/issues/2731) - Bug on Focus View Help Popup z-order +- Fixes [#2742](https://github.com/gitkraken/vscode-gitlens/issues/2742) - Search & Compare: Element with id ... is already registered +- Fixes an issue where the links in the _Search & Compare_ view failed to open the specific search type +- Fixes an issue when searching for commits and the results contain stashes + +## [14.0.0] - 2023-06-14 + +### Added + +- Adds an all-new Welcome experience to quickly get started with GitLens and discover features — even if you are familiar with GitLens, definitely check it out! +- Adds a new streamlined _Get Started with GitLens_ walkthrough +- Adds an all-new _Home_ view for quick access to GitLens features and _GitKraken Account_ for managing your account +- Adds a new reimagined views layout — see discussion [#2721](https://github.com/gitkraken/vscode-gitlens/discussions/2721) for more details + - Rearranges the GitLens views for greater focus and productivity, including the new _GitLens Inspect_ and moved some of our views from Source Control into either _GitLens_ or _GitLens Inspect_. + - Adds a new GitLens Inspect activity bar icon focuses on providing contextual information and insights to what you're actively working on + - Adds a _Reset Views Layout_ command to reset all the GitLens views to the new default layout +- Adds an all-new _GitKraken Workspaces_ â˜ī¸ feature as a side bar view, supporting interaction with local and cloud GitKraken workspaces, lists of repositories tied to your account. + - Create, view, and manage repositories on GitKraken cloud workspaces, which are available with a GitKraken account across the range of GitKraken products + - Automatically or manually link repositories in GitKraken cloud workspaces to matching repositories on your machine + - Quickly create a GitKraken cloud workspace from the repositories in your current window + - Open a GitKraken cloud workspace as a local, persisted, VS Code workspace file (further improvements coming soon) + - Open a cloud workspace or repository in a new window (or your current window) + - See your currently open repositories in the _Current Window_ section + - Explore and interact with any repository in a GitKraken cloud workspace, some actions are currently limited to repositories which are open in your current window — ones highlighted in green + - (Coming soon) Share your GitKraken cloud workspaces with your team or organization +- Adds new _Commit Graph_ ✨ features and improvements + - Makes the _Panel_ layout the default for easy access to the Commit Graph with a dedicated details view + - Adds two new options to the graph header context menu + - `Reset Columns to Default Layout` - resets column widths, ordering, visibility, and graph column mode to default settings + - `Reset Columns to Compact Layout` - resets column widths, ordering, visibility, and graph column mode to compact settings + - Adds a _Toggle Commit Graph_ command to quickly toggle the graph on and off (requires the _Panel_ layout) + - Adds a _Toggle Maximized Commit Graph_ command to maximize and restore the graph for a quick full screen experience (requires the _Panel_ layout) + - Enables the _Minimap_ by default, as its no longer experimental, to provide a quick overview of of commit activity above the graph + - Adds ability to toggle between showing commits vs lines changed in the minimap (note: choosing lines changed requires more computation) + - Adds a legend and quick toggles for the markers shown on the minimap + - Defers the loading of the minimap to avoid impacting graph performance and adds a loading progress indicator + - Adds a `gitlens.graph.minimap.enabled` setting to specify whether to show the minimap + - Adds a `gitlens.graph.minimap.dataType` setting to specify whether to show commits or lines changed in the minimap + - Adds a `gitlens.graph.minimap.additionalTypes` setting to specify additional markers to show on the minimap + - Makes the _Changes_ column visible by default (previously hidden) + - Defers the loading of the _Changes_ column to avoid impacting graph performance and adds a loading progress indicator to the column header + - Adds a changed file count in addition to the changed lines visualization + - Improves the rendering of the changed line visualization and adds extra width to the bar for outlier changes so that they stand out a bit more + - Adds an _Open Repo on Remote_ button to left of the repo name in the graph header + - Improves contextual help on the search input as you type + - Improves tooltips on _Branch/Tag_ icons to be more uniform and descriptive + - Adds new context menu options to the _Commit Graph Settings_ (cog, above the scrollbar) to toggle which scroll marker to show + - Improves alignment of scroll markers on the scrollbar, and adds a gap between the last column and the scrollbar +- Adds the ability to choose which AI provider, OpenAI or Anthropic, and AI model are used for GitLens' experimental AI features + - Adds a _Switch AI Model_ command to the command palette and from the _Explain (AI)_ panel on the _Commit Details_ view + - Adds a `gitlens.ai.experimental.provider` setting to specify the AI provider to use (defaults to `openai`) + - Adds a `gitlens.ai.experimental.openai.model` setting to specify the OpenAI model (defaults to `gpt-3.5-turbo`) — closes [#2636](https://github.com/gitkraken/vscode-gitlens/issues/2636) thanks to [PR #2637](https://github.com/gitkraken/vscode-gitlens/pull/2637) by Daniel Rodríguez ([@sadasant](https://github.com/sadasant)) + - Adds a `gitlens.ai.experimental.anthropic.model` setting to specify the Anthropic model (defaults to `claude-v1`) +- Adds expanded deep link support + - Adds cloning, adding a remote, and fetching from the target remote when resolving a deep link + - Adds deep linking to a repository with direct file path support +- Adds the automatic restoration of all GitLens webviews when you restart VS Code +- Adds ability to control encoding for custom remote configuration — closes [#2336](https://github.com/gitkraken/vscode-gitlens/issues/2336) +- Improves performance and rendering of the _Visual File History_ and optimizes it for usage in the side bars + - Adds a _Full history_ option to the _Visual File History_ — closes [#2690](https://github.com/gitkraken/vscode-gitlens/issues/2690) + - Adds a loading progress indicator +- Adds _Reveal in File Explorer_ command to repositories +- Adds _Copy SHA_ command to stashes +- Adds new icons for virtual repositories + +### Changed + +- Changes header on _GitLens Settings_ to be consistent with the new Welcome experience +- Reduces the visual noise of currently inaccessible ✨ features in the side bars +- Performance: Improves rendering of large commits on the _Commit Details_ view +- Performance: Defers possibly duplicate repo scans at startup and waits until repo discovery is complete before attempting to find other repos +- Security: Disables Git access in Restricted Mode (untrusted) +- Security: Avoids dynamic execution in string interpolation + +### Fixed + +- Fixes [#2738](https://github.com/gitkraken/vscode-gitlens/issues/2738) - Element with id ... is already registered +- Fixes [#2728](https://github.com/gitkraken/vscode-gitlens/issues/2728) - Submodule commit graph will not open in the panel layout +- Fixes [#2734](https://github.com/gitkraken/vscode-gitlens/issues/2734) - 🐛 File History: Browse ... not working +- Fixes [#2671](https://github.com/gitkraken/vscode-gitlens/issues/2671) - Incorrect locale information provided GitLens +- Fixes [#2689](https://github.com/gitkraken/vscode-gitlens/issues/2689) - GitLens hangs on github.dev on Safari +- Fixes [#2680](https://github.com/gitkraken/vscode-gitlens/issues/2680) - Git path with spaces is not properly quoted in the command +- Fixes [#2677](https://github.com/gitkraken/vscode-gitlens/issues/2677) - Merging branch produces path error +- Fixes an issue with comparison commands on File/Line History views +- Fixes an issue with stale state on many webviews when shown after being hidden +- Fixes an issue with fetch/push/pull on the _Commit Graph_ header +- Fixes an issue where _Branch / Tag_ items on the _Commit Graph_ sometimes wouldn't expand on hover +- Fixes an issue where some command were showing up on unsupported schemes +- Fixes an issue where the file/line history views could break because of malformed URIs + +## [13.6.0] - 2023-05-11 + +### Added + +- Adds the ability to rename stashes — closes [#2538](https://github.com/gitkraken/vscode-gitlens/issues/2538) + - Adds a new _Rename Stash..._ command to the _Stashes_ view +- Adds new _Commit Graph_ features and improvements + - Adds a _Push_ or _Pull_ toolbar button depending the current branch being ahead or behind it's upstream + - Adds support for the _Commit Graph_ over [Visual Studio Live Share](https://visualstudio.microsoft.com/services/live-share/) sessions + - Adds the ability to move all of the columns, including the ones that were previously unmovable + - Automatically switches column headers from text to icons when the column's width is too small for the text to be useful + - Automatically switches the Author column to shows avatars rather than text when the column is sized to its minimum width +- Adds an experimental _Explain (AI)_ panel to the _Commit Details_ view to leverage OpenAI to provide an explanation of the changes of a commit +- Adds the ability to search stashes when using the commit search via the _Commit Graph_, _Search & Compare_ view, or the _Search Commits_ command +- Adds an _Open Visual File History_ command to the new _File History_ submenu on existing context menus +- Allows the _Repositories_ view for virtual repositories +- Honors the `git.repositoryScanIgnoredFolders` VS Code setting +- Adds _Share_, _Open Changes_, and _Open on Remote (Web)_ submenus to the new editor line numbers (gutter) context menu +- Adds an _Open Line Commit Details_ command to the _Open Changes_ submenus on editor context menus +- Adds an _Open Changes_ submenu to the row context menu on the _Commit Graph_ + +### Changed + +- Refines and reorders many of the GitLens context menus and additions to VS Code context menus + - Moves _Copy Remote \* URL_ commands from the _Copy As_ submenu into the _Share_ submenu in GitLens views + - Adds a _Share_ submenu to Source Control items + - Moves _Copy SHA_ and _Copy Message_ commands on commits from the _Copy As_ submenu into the root of the context menu + - Moves _Copy Relative Path_ command on files from the _Copy As_ submenu into the root of the context menu + - Moves file history commands into a _File History_ submenu + - Moves _Open \* on Remote_ commands into _Open on Remote (Web)_ submenu + - Renames the _Commit Changes_ submenu to _Open Changes_ + - Renames _Show Commit_ command to _Quick Show Commit_ and _Show Line Commit_ command to _Quick Show Line Commit_ for better clarity as it opens a quick pick menu +- Changes the file icons shown in many GitLens views to use the file type's theme icon (by default) rather than the status icon + - Adds a `gitlens.views.commits.files.icon` setting to specify how the _Commits_ view will display file icons + - Adds a `gitlens.views.repositories.files.icon` setting to specify how the _Repositories_ view will display file icons + - Adds a `gitlens.views.branches.files.icon` setting to specify how the _Branches_ view will display file icons + - Adds a `gitlens.views.remotes.files.icon` setting to specify how the _Remotes_ view will display file icons + - Adds a `gitlens.views.stashes.files.icon` setting to specify how the _Stashes_ view will display file icons + - Adds a `gitlens.views.tags.files.icon` setting to specify how the _Tags_ view will display file icons + - Adds a `gitlens.views.worktrees.files.icon` setting to specify how the _Worktrees_ view will display file icons + - Adds a `gitlens.views.contributors.files.icon` setting to specify how the _Contributors_ view will display file icons + - Adds a `gitlens.views.searchAndCompare.files.icon` setting to specify how the _Search & Compare_ view will display file icons +- Renames _Delete Stash..._ command to _Drop Stash..._ in the _Stashes_ view +- Removes the commit icon when hiding avatars in the _Commits_ view to allow for a more compact layout +- Limits Git CodeLens on docker files — closes [#2153](https://github.com/gitkraken/vscode-gitlens/issues/2153) +- Shows progress notification for deep links earlier in the process — closes [#2662](https://github.com/gitkraken/vscode-gitlens/issues/2662) + +### Fixed + +- Fixes [#2664](https://github.com/gitkraken/vscode-gitlens/issues/2664) - Terminal run Git command can be "corrupted" if there is previous text waiting in the terminal +- Fixes [#2660](https://github.com/gitkraken/vscode-gitlens/issues/2660) - Commands executed in the terminal fail to honor found Git path +- Fixes [#2654](https://github.com/gitkraken/vscode-gitlens/issues/2654) - Toggle zen mode not working until you restart vscode +- Fixes [#2629](https://github.com/gitkraken/vscode-gitlens/issues/2629) - When on VSCode web, add handling for failing repo discovery +- Fixes many issues with using GitLens over [Visual Studio Live Share](https://visualstudio.microsoft.com/services/live-share/) sessions +- Fixes mouse scrubbing issues with the minimap on the _Commit Graph_ +- Fixes _Refresh Repository Access_ and _Reset Repository Access Cache_ commands to always be available +- Fixes state not being restored on the Home webview +- Fixes getting the oldest unpushed commit when there is more than 1 remote +- Fixes an issue with the quick input on the _Git Command Palette_ unexpectedly going back to the previous step +- Fixes GitLens access tooltip not being visible when hovering in the _Commit Graph_ +- Fixes last fetched messaging in the _Commit Graph_ when its never been fetched + +### Removed + +- Removes "Open Commit on Remote" command from the VS Code Timeline view as it can no longer be supported — see [microsoft/vscode/#177319](https://github.com/microsoft/vscode/issues/177319) + +## [13.5.0] - 2023-04-07 + +### Added + +- Adds the ability to switch to an alternate panel layout for the _Commit Graph_ — closes [#2602](https://github.com/gitkraken/vscode-gitlens/issues/2602) and [#2537](https://github.com/gitkraken/vscode-gitlens/issues/2537) + - Adds a new context menu from the _Commit Graph Settings_ (cog) to switch between the "Editor" and "Panel" layouts + - Adds a `gitlens.graph.layout` setting to specify the layout of the _Commit Graph_ + - `editor` - Shows the _Commit Graph_ in an editor tab + - `panel` - Shows the _Commit Graph_ in the bottom panel with an additional _Commit Graph Details_ view alongside on the right +- Adds new _Commit Graph_ features and improvements + - Adds a compact layout to the Graph column of the _Commit Graph_ + - Adds a context menu option to the header to toggle between the "Compact" and "Default" layouts — closes [#2611](https://github.com/gitkraken/vscode-gitlens/pull/2611) + - Shows pull request icons on local branches when their upstream branch is associated with a pull request + - Adds tooltips to work-in-progress (WIP) and stash nodes + - Adds a "Publish Branch" context menu action to local branches without an upstream branch — closes [#2619](https://github.com/gitkraken/vscode-gitlens/pull/2619) + - Lowers the minimum width of the "Branch / Tag" column +- Adds actions to _Focus_ Pull Requests + - Switch to or create a local branch + - Create or open a worktree from the branch +- Adds a _Generate Commit Message (Experimental)..._ command to the SCM context menus + +### Changed + +- Reduces the size of the GitLens (desktop) bundle which reduces memory usage and improves startup time — ~7% smaller (1.21MB -> 1.13MB) + - Consolidates the "extension" side of all the GitLens webviews/webview-views into a unified controller and code-splits each webview/webview-view into its own bundle + - Allows for very minimal code to be loaded for each webview/webview-view until its used, so if you never use a webview you never "pay" the cost of loading it +- Changes _Open Associated Pull Request_ command to support opening associated pull requests with the current branch or the HEAD commit if no branch association was found — closes [#2559](https://github.com/gitkraken/vscode-gitlens/issues/2559) +- Improves the "pinning" of the _Commit Details_ view + - Avoids automatically pinning + - Changes the pinned state to be much more apparent +- Changes _Commit Details_ to always open diffs in the same editor group as the currently active editor — closes [#2537](https://github.com/gitkraken/vscode-gitlens/issues/2537) + +### Fixed + +- Fixes [#2597](https://github.com/gitkraken/vscode-gitlens/issues/2597) - Allow disabling "Open worktree for pull request via GitLens..." from repository context menu +- Fixes [#2612](https://github.com/gitkraken/vscode-gitlens/issues/2612) - Clarify GitLens telemetry settings +- Fixes [#2583](https://github.com/gitkraken/vscode-gitlens/issues/2583) - Regression with _Open Worktree for Pull Request via GitLens..._ command +- Fixes [#2252](https://github.com/gitkraken/vscode-gitlens/issues/2252) - "Copy As"/"Copy Remote File Url" copies %23 instead of # in case of Gitea — thanks to [PR #2603](https://github.com/gitkraken/vscode-gitlens/pull/2603) by WofWca ([@WofWca](https://github.com/WofWca)) +- Fixes [#2582](https://github.com/gitkraken/vscode-gitlens/issues/2582) - _Visual File History_ background color when in a panel +- Fixes [#2609](https://github.com/gitkraken/vscode-gitlens/issues/2609) - If you check out a branch that is hidden, GitLens should show the branch still +- Fixes [#2595](https://github.com/gitkraken/vscode-gitlens/issues/2595) - Error when stashing changes +- Fixes tooltips sometimes failing to show in _Commit Graph_ rows when the Date column is hidden +- Fixes an issue with incorrectly showing associated pull requests with branches that are partial matches of the true branch the pull request is associated with + +## [13.4.0] - 2023-03-16 + +### Added + +- Adds an experimental _Generate Commit Message (Experimental)_ command to use OpenAI to generate a commit message for staged changes + - Adds a `gitlens.experimental.generateCommitMessagePrompt` setting to specify the prompt to use to tell OpenAI how to structure or format the generated commit message — can have fun with it and make your commit messages in the style of a pirate, etc +- Adds auto-detection for `.git-blame-ignore-revs` files and excludes the commits listed within from the blame annotations +- Adds a _Open Git Worktree..._ command to jump directly to opening a worktree in the _Git Command Palette_ +- Adds a _Copy Relative Path_ context menu action for active editors and file nodes in sidebar views +- Adds the ability to see branches and tags on remote repositories (e.g. GitHub) on the _Commit Graph_ + - Currently limited to only showing them for commits on the current branch, as we aren't yet able to show all commits on all branches + +### Changed + +- Improves the display of items in the _Commit Graph_ + - When showing local branches, we now always display the upstream branches in the minimap, scrollbar markers, and graph rows + - When laying out lanes in the Graph column, we now bias to be left aligned when possible for an easier to read and compact graph visualization +- Improves _Open Worktree for Pull Request via GitLens..._ command to use the qualified remote branch name, e.g. `owner/branch`, when creating the worktree +- Removes Insiders edition in favor of the pre-release edition + +### Fixed + +- Fixes [#2550](https://github.com/gitkraken/vscode-gitlens/issues/2550) - Related pull request disappears after refresh +- Fixes [#2549](https://github.com/gitkraken/vscode-gitlens/issues/2549) - toggle code lens does not work with gitlens.codeLens.enabled == false +- Fixes [#2553](https://github.com/gitkraken/vscode-gitlens/issues/2553) - Can't add remote url with git@ format +- Fixes [#2083](https://github.com/gitkraken/vscode-gitlens/issues/2083), [#2539](https://github.com/gitkraken/vscode-gitlens/issues/2539) - Fix stashing staged changes — thanks to [PR #2540](https://github.com/gitkraken/vscode-gitlens/pull/2540) by Nafiur Rahman Khadem ([@ShafinKhadem](https://github.com/ShafinKhadem)) +- Fixes [#1968](https://github.com/gitkraken/vscode-gitlens/issues/1968) & [#1027](https://github.com/gitkraken/vscode-gitlens/issues/1027) - Fetch-> fatal: could not read Username — thanks to [PR #2481](https://github.com/gitkraken/vscode-gitlens/pull/2481) by Skyler Dawson ([@foxwoods369](https://github.com/foxwoods369)) +- Fixes [#2495](https://github.com/gitkraken/vscode-gitlens/issues/2495) - Cannot use gitlens+ feature on public repo in some folders +- Fixes [#2530](https://github.com/gitkraken/vscode-gitlens/issues/2530) - Error when creating worktrees in certain conditions +- Fixed [#2566](https://github.com/gitkraken/vscode-gitlens/issues/2566) - hide context menu in output panel — thanks to [PR #2568](https://github.com/gitkraken/vscode-gitlens/pull/2568) by hahaaha ([@hahaaha](https://github.com/hahaaha)) + +## [13.3.2] - 2023-03-06 + +### Changed + +- Reduces the size of the GitLens bundle which improves startup time + - GitLens' extension bundle for desktop (node) is now ~24% smaller (1.58MB -> 1.21MB) + - GitLens' extension bundle for web (vscode.dev/github.dev) is now ~6% smaller (1.32MB -> 1.24MB) + +### Fixed + +- Fixes [#2533](https://github.com/gitkraken/vscode-gitlens/issues/2533) - Current Branch Only graph filter sometimes fails +- Fixes [#2504](https://github.com/gitkraken/vscode-gitlens/issues/2504) - Graph header theme colors were referencing the titlebar color properties +- Fixes [#2527](https://github.com/gitkraken/vscode-gitlens/issues/2527) - shows added files for Open All Changes +- Fixes [#2530](https://github.com/gitkraken/vscode-gitlens/issues/2530) (potentially) - Error when creating worktrees in certain conditions +- Fixes an issue where trial status can be shown rather than a purchased license + ## [13.3.1] - 2023-02-24 ### Fixed @@ -16,10 +1070,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added -- ✨ Adds a preview of the all-new **Focus View**, a [GitLens+ feature](https://gitkraken.com/gitlens/plus-features) — provides you with a comprehensive list of all your most important work across your connected GitHub repos: +- ✨ Adds a preview of the all-new **Focus**, a [GitLens+ feature](https://gitkraken.com/gitlens/pro-features) — provides you with a comprehensive list of all your most important work across your connected GitHub repos: - My Pull Requests: shows all GitHub PRs opened by you, assigned to you, or awaiting your review - My Issues: shows all issues created by you, assigned to you, or that mention you - - Open it via _GitLens+: Show Focus View_ from the Command Palette + - Open it via _GitLens+: Show Focus_ from the Command Palette - Adds new _Commit Graph_ features and improvements - Adds a new experimental minimap of commit activity to the _Commit Graph_ - Adds a new experimental _Changes_ column visualizing commit changes @@ -50,9 +1104,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Changed - Greatly reduces the size of many of GitLens' bundles which improves startup time - - GitLens' extension bundle for desktop (node) is now ~18% smaller - - GitLens' extension bundle for web (vscode.dev/github.dev) is now ~37% smaller - - GitLens' Commit Graph webview bundle is now ~31% smaller + - GitLens' extension bundle for desktop (node) is now ~18% smaller (1.91MB -> 1.57MB) + - GitLens' extension bundle for web (vscode.dev/github.dev) is now ~37% smaller (2.05MB -> (1.30MB) + - GitLens' Commit Graph webview bundle is now ~31% smaller (1.03MB -> 734KB) - Changes the _Contributors_ view to be shown by default on the _GitLens_ sidebar ### Removed @@ -180,7 +1234,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Fixed - Fixes [#2339](https://github.com/gitkraken/vscode-gitlens/issues/2339) - Commit details "Autolinks" group shows wrong count -- Fixes [#2346](https://github.com/gitkraken/vscode-gitlens/issues/2346) - Multiple cursors on the same line duplicate inline annotations; thanks to [PR #2347](https://github.com/gitkraken/vscode-gitlens/pull/2347) by Yonatan Greenfeld ([@YonatanGreenfeld](https://github.com/YonatanGreenfeld)) +- Fixes [#2346](https://github.com/gitkraken/vscode-gitlens/issues/2346) - Multiple cursors on the same line duplicate inline annotations — thanks to [PR #2347](https://github.com/gitkraken/vscode-gitlens/pull/2347) by Yonatan Greenfeld ([@YonatanGreenfeld](https://github.com/YonatanGreenfeld)) - Fixes [#2344](https://github.com/gitkraken/vscode-gitlens/issues/2344) - copying abbreviated commit SHAs is not working - Fixes [#2342](https://github.com/gitkraken/vscode-gitlens/issues/2342) - Local remotes are incorrectly treated as private - Fixes [#2052](https://github.com/gitkraken/vscode-gitlens/issues/2052) - Interactive Rebase fails to start when using xonsh shell due to command quoting @@ -285,7 +1339,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added -- ✨ Adds an all-new [**Commit Graph**](https://github.com/gitkraken/vscode-gitlens#commit-graph-), a [GitLens+ feature](https://gitkraken.com/gitlens/plus-features) — helps you to easily visualize branch structure and commit history. Not only does it help you verify your changes, but also easily see changes made by others and when +- ✨ Adds an all-new [**Commit Graph**](https://github.com/gitkraken/vscode-gitlens#commit-graph-), a [GitLens+ feature](https://gitkraken.com/gitlens/pro-features) — helps you to easily visualize branch structure and commit history. Not only does it help you verify your changes, but also easily see changes made by others and when ![Commit Graph illustration](https://raw.githubusercontent.com/gitkraken/vscode-gitlens/main/images/docs/commit-graph-illustrated.png) - Adds a [**Commit Details view**](https://github.com/gitkraken/vscode-gitlens#commit-details-view-) — provides rich details for commits and stashes - Contextually updates as you navigate: @@ -520,7 +1574,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Adds (preview) VS Code for Web support! - Get the power and insights of GitLens for any GitHub repository directly in your browser on vscode.dev or github.dev -- Introducing GitLens+ features — [learn about GitLens+ features](https://gitkraken.com/gitlens/plus-features) +- Introducing GitLens+ features — [learn about GitLens+ features](https://gitkraken.com/gitlens/pro-features) - GitLens+ adds all-new, completely optional, features that enhance your current GitLens experience when you sign in with a free account. A free GitLens+ account gives you access to these new GitLens+ features on local and public repos, while a paid account allows you to use them on private repos. All other GitLens features will continue to be free without an account, so you won't lose access to any of the GitLens features you know and love, EVER. - Visual File History — a visual way to analyze and explore changes to a file @@ -884,7 +1938,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Refines the _Repositories_ view to better align its features with all the new views - Adds menu toggles, and the settings below to allow for far greater customization of the sections in the _Repositories_ view - - Adds a `gitlens.views.repositories.branches.showBranchComparison` setting to specify whether to show a comparison of the branch with a user-selected reference (branch, tag. etc) under each branch in the _Repositories_ view + - Adds a `gitlens.views.repositories.branches.showBranchComparison` setting to specify whether to show a comparison of the branch with a user-selected reference (branch, tag, etc) under each branch in the _Repositories_ view - Adds a `gitlens.views.repositories.showBranches` setting to specify whether to show the branches for each repository - Adds a `gitlens.views.repositories.showCommits` setting to specify whether to show the commits on the current branch for each repository - Adds a `gitlens.views.repositories.showContributors` setting to specify whether to show the contributors for each repository @@ -4575,7 +5629,43 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Initial release but still heavily a work in progress. -[unreleased]: https://github.com/gitkraken/vscode-gitlens/compare/v13.3.1...HEAD +[unreleased]: https://github.com/gitkraken/vscode-gitlens/compare/v15.5.1...HEAD +[15.5.1]: https://github.com/gitkraken/vscode-gitlens/compare/v15.5.0...gitkraken:v15.5.1 +[15.5.0]: https://github.com/gitkraken/vscode-gitlens/compare/v15.4.0...gitkraken:v15.5.0 +[15.4.0]: https://github.com/gitkraken/vscode-gitlens/compare/v15.3.1...gitkraken:v15.4.0 +[15.3.1]: https://github.com/gitkraken/vscode-gitlens/compare/v15.3.0...gitkraken:v15.3.1 +[15.3.0]: https://github.com/gitkraken/vscode-gitlens/compare/v15.2.3...gitkraken:v15.3.0 +[15.2.3]: https://github.com/gitkraken/vscode-gitlens/compare/v15.2.2...gitkraken:v15.2.3 +[15.2.2]: https://github.com/gitkraken/vscode-gitlens/compare/v15.2.1...gitkraken:v15.2.2 +[15.2.1]: https://github.com/gitkraken/vscode-gitlens/compare/v15.2.0...gitkraken:v15.2.1 +[15.2.0]: https://github.com/gitkraken/vscode-gitlens/compare/v15.1.0...gitkraken:v15.2.0 +[15.1.0]: https://github.com/gitkraken/vscode-gitlens/compare/v15.0.4...gitkraken:v15.1.0 +[15.0.4]: https://github.com/gitkraken/vscode-gitlens/compare/v15.0.3...gitkraken:v15.0.4 +[15.0.3]: https://github.com/gitkraken/vscode-gitlens/compare/v15.0.2...gitkraken:v15.0.3 +[15.0.2]: https://github.com/gitkraken/vscode-gitlens/compare/v15.0.1...gitkraken:v15.0.2 +[15.0.1]: https://github.com/gitkraken/vscode-gitlens/compare/v15.0.0...gitkraken:v15.0.1 +[15.0.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.9.0...gitkraken:v15.0.0 +[14.9.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.8.2...gitkraken:v14.9.0 +[14.8.2]: https://github.com/gitkraken/vscode-gitlens/compare/v14.8.1...gitkraken:v14.8.2 +[14.8.1]: https://github.com/gitkraken/vscode-gitlens/compare/v14.8.0...gitkraken:v14.8.1 +[14.8.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.7.0...gitkraken:v14.8.0 +[14.7.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.6.1...gitkraken:v14.7.0 +[14.6.1]: https://github.com/gitkraken/vscode-gitlens/compare/v14.6.0...gitkraken:v14.6.1 +[14.6.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.5.1...gitkraken:v14.6.0 +[14.5.1]: https://github.com/gitkraken/vscode-gitlens/compare/v14.5.0...gitkraken:v14.5.1 +[14.5.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.4.0...gitkraken:v14.5.0 +[14.4.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.3.0...gitkraken:v14.4.0 +[14.3.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.2.1...gitkraken:v14.3.0 +[14.2.1]: https://github.com/gitkraken/vscode-gitlens/compare/v14.2.0...gitkraken:v14.2.1 +[14.2.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.1.1...gitkraken:v14.2.0 +[14.1.1]: https://github.com/gitkraken/vscode-gitlens/compare/v14.1.0...gitkraken:v14.1.1 +[14.1.0]: https://github.com/gitkraken/vscode-gitlens/compare/v14.0.1...gitkraken:v14.1.0 +[14.0.1]: https://github.com/gitkraken/vscode-gitlens/compare/v14.0.0...gitkraken:v14.0.1 +[14.0.0]: https://github.com/gitkraken/vscode-gitlens/compare/v13.6.0...gitkraken:v14.0.0 +[13.6.0]: https://github.com/gitkraken/vscode-gitlens/compare/v13.5.0...gitkraken:v13.6.0 +[13.5.0]: https://github.com/gitkraken/vscode-gitlens/compare/v13.4.0...gitkraken:v13.5.0 +[13.4.0]: https://github.com/gitkraken/vscode-gitlens/compare/v13.3.2...gitkraken:v13.4.0 +[13.3.2]: https://github.com/gitkraken/vscode-gitlens/compare/v13.3.1...gitkraken:v13.3.2 [13.3.1]: https://github.com/gitkraken/vscode-gitlens/compare/v13.3.0...gitkraken:v13.3.1 [13.3.0]: https://github.com/gitkraken/vscode-gitlens/compare/v13.2.0...gitkraken:v13.3.0 [13.2.0]: https://github.com/gitkraken/vscode-gitlens/compare/v13.1.1...gitkraken:v13.2.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa0573e320d3c..0c9089314ff6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,15 +19,15 @@ git clone https://github.com/gitkraken/vscode-gitlens.git Prerequisites - [Git](https://git-scm.com/), `>= 2.7.2` -- [NodeJS](https://nodejs.org/), `>= 16.14.2` -- [yarn](https://yarnpkg.com/), `>= 1.22.19` +- [NodeJS](https://nodejs.org/), `>= v20.11.1` +- [pnpm](https://pnpm.io/), `>= 8.x` (install using [corepack](https://nodejs.org/docs/latest-v20.x/api/corepack.html)) ### Dependencies From a terminal, where you have cloned the repository, execute the following command to install the required dependencies: ``` -yarn +pnpm install ``` ### Build @@ -35,7 +35,7 @@ yarn From a terminal, where you have cloned the repository, execute the following command to re-build the project from scratch: ``` -yarn run rebuild +pnpm run rebuild ``` 👉 **NOTE!** This will run a complete rebuild of the project. @@ -43,7 +43,7 @@ yarn run rebuild Or to just run a quick build, use: ``` -yarn run build +pnpm run build ``` ### Watch @@ -51,7 +51,7 @@ yarn run build During development you can use a watcher to make builds on changes quick and easy. From a terminal, where you have cloned the repository, execute the following command: ``` -yarn run watch +pnpm run watch ``` Or use the provided `watch` task in VS Code, execute the following from the command palette (be sure there is no `>` at the start): @@ -68,7 +68,7 @@ This will first do an initial full build and then watch for file changes, compil ### Formatting -This project uses [prettier](https://prettier.io/) for code formatting. You can run prettier across the code by calling `yarn run pretty` from a terminal. +This project uses [prettier](https://prettier.io/) for code formatting. You can run prettier across the code by calling `pnpm run pretty` from a terminal. To format the code as you make changes you can install the [Prettier - Code formatter](https://marketplace.visualstudio.com/items/esbenp.prettier-vscode) extension. @@ -80,7 +80,7 @@ Add the following to your User Settings to run prettier: ### Linting -This project uses [ESLint](https://eslint.org/) for code linting. You can run ESLint across the code by calling `yarn run lint` from a terminal. Warnings from ESLint show up in the `Errors and Warnings` quick box and you can navigate to them from inside VS Code. +This project uses [ESLint](https://eslint.org/) for code linting. You can run ESLint across the code by calling `pnpm run lint` from a terminal. Warnings from ESLint show up in the `Errors and Warnings` quick box and you can navigate to them from inside VS Code. To lint the code as you make changes you can install the [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) extension. @@ -89,13 +89,13 @@ To lint the code as you make changes you can install the [ESLint](https://market To generate a production bundle (without packaging) run the following from a terminal: ``` -yarn run bundle +pnpm run bundle ``` To generate a VSIX (installation package) run the following from a terminal: ``` -yarn run package +pnpm run package ``` ### Debugging @@ -104,13 +104,23 @@ yarn run package 1. Open the `vscode-gitlens` folder 2. Ensure the required [dependencies](#dependencies) are installed -3. Choose the `Watch & Run` launch configuration from the launch dropdown in the Run and Debug viewlet and press `F5`. +3. Choose the `Watch & Run` launch configuration from the launch dropdown in the Run and Debug viewlet and press `F5` +4. A new VS Code "Extension Development Host" window will open with the extension loaded and ready for debugging + 1. If the "Extension Development Host" window opened without a folder/workspace with a repository (required for most GitLens functionality), you will need to open one and then stop and restart the debug session + +In order to see any code changes reflected in the "Extension Development Host" window, you will need to restart the debug session, e.g. using the "Restart" button in the debug toolbar or by pressing `[Ctrl|Cmd]+Shift+F5`. Although, if the code changes are purely within a webview, you can refresh the webview by clicking the refresh button in the toolbar associated with the webview. + +_Note: If you see a pop-up with a message similar to "The task cannot be tracked. Make sure to have a problem matcher defined.", you will need to install the [TypeScript + Webpack Problem Matchers](https://marketplace.visualstudio.com/items?itemName=amodio.tsl-problem-matcher) extension._ #### Using VS Code (desktop webworker) 1. Open the `vscode-gitlens` folder 2. Ensure the required [dependencies](#dependencies) are installed -3. Choose the `Watch & Run (web)` launch configuration from the launch dropdown in the Run and Debug viewlet and press `F5`. +3. Choose the `Watch & Run (web)` launch configuration from the launch dropdown in the Run and Debug viewlet and press `F5` +4. A new VS Code "Extension Development Host" window will open with the extension loaded and ready for debugging + 1. If the "Extension Development Host" window opened without a folder/workspace with a repository (required for most GitLens functionality), you will need to open one and then stop and restart the debug session + +In order to see any code changes reflected in the "Extension Development Host" window, you will need to restart the debug session, e.g. using the "Restart" button in the debug toolbar or by pressing `[Ctrl|Cmd]+Shift+F5`. Although, if the code changes are purely within a webview, you can refresh the webview by clicking the refresh button in the toolbar associated with the webview. #### Using VS Code for the Web (locally) @@ -123,7 +133,7 @@ See https://code.visualstudio.com/api/extension-guides/web-extensions#test-your- #### Using VS Code for the Web (vscode.dev) -See https://code.visualstudio.com/api/extension-guides/web-extensions#test-your-web-extension-in-on-vscode.dev +See https://code.visualstudio.com/api/extension-guides/web-extensions#test-your-web-extension-in-vscode.dev 1. Open the `vscode-gitlens` folder 2. Ensure the required [dependencies](#dependencies) are installed @@ -142,15 +152,23 @@ If a pull request is submitted which contains changes to files in or under any d ### Update the CHANGELOG -The [Change Log](CHANGELOG.md) is updated manually and an entry should be added for each change. Changes are grouped in lists by `added`, `changed` or `fixed`. +The [Change Log](CHANGELOG.md) is updated manually and an entry should be added for each change. Changes are grouped in lists by `added`, `changed`, `removed`, or `fixed`. Entries should be written in future tense: -> - Adds [Gravatar](https://en.gravatar.com/) support to gutter and hover blame annotations +- Be sure to give yourself much deserved credit by adding your name and user in the entry -Be sure to give yourself much deserved credit by adding your name and user in the entry: - -> - Adds `gitlens.statusBar.alignment` settings to control the alignment of the status bar — thanks to [PR #72](https://github.com/gitkraken/vscode-gitlens/pull/72) by Zack Schuster ([@zackschuster](https://github.com/zackschuster))! +> Added +> +> - Adds awesome feature — closes [#\](https://github.com/gitkraken/vscode-gitlens/issues/) thanks to [PR #\](https://github.com/gitkraken/vscode-gitlens/issues/) by Your Name ([@\](https://github.com/)) +> +> Changed +> +> - Changes or improves an existing feature — closes [#\](https://github.com/gitkraken/vscode-gitlens/issues/) thanks to [PR #\](https://github.com/gitkraken/vscode-gitlens/issues/) by Your Name ([@\](https://github.com/)) +> +> Fixed +> +> - Fixes [#\](https://github.com/gitkraken/vscode-gitlens/issues/) a bug or regression — thanks to [PR #\](https://github.com/gitkraken/vscode-gitlens/issues/) by Your Name ([@\](https://github.com/)) ### Update the README @@ -160,7 +178,9 @@ If this is your first contribution to GitLens, please give yourself credit by ad ## Publishing -### Versioning +### Stable Releases + +#### Versioning GitLens version changes are bucketed into two types: @@ -169,40 +189,60 @@ GitLens version changes are bucketed into two types: Note: `major` version bumps are only considered for more special circumstances. -#### Updating the CHANGELOG +#### Preparing a Normal Release -All recent changes are listed under `## [Unreleased]`. This title and corresponding link at the bottom of the page will need to be updated. +Before publishing a new release, do the following: -The title should be updated to the upcoming version and the release date (YYYY-MM-DD): +1. Create a GitHub milestone for any potential patch releases, named `{major}.{minor}-patch` with a description of `Work intended for any patch releases before the {major}.{minor} release` +2. Create a GitHub milestone for the next release, `{major}.{minor+1}` with a description of `Work intended for the {release-month} {release-year} release` and a set the appropriate due date +3. Ensure all items in the `{major}.{minor}` GitHub milestone are closed and verified or moved into one of the above milestones +4. Close the `{major}.{minor}` and `{major}.{minor-1}-patch` GitHub milestones -```markdown - +Then, use the [prep-release](scripts/prep-release.js) script to prepare a new release. The script updates the [package.json](package.json) and [CHANGELOG.md](CHANGELOG.md) appropriately, commits the changes as `Bumps to v{major}.{minor}.{patch}`, and creates a `v{major}.{minor}.{patch}` tag which when pushed will trigger the CI to publish a release. -## [Unreleased] +1. Ensure you are on the `main` branch and have a clean working tree +2. Ensure the [CHANGELOG.md](CHANGELOG.md) has been updated with the release notes +3. Run `pnpm run prep-release` and enter the desired `{major}.{minor}.{patch}` version when prompted +4. Review the `Bumps to v{major}.{minor}.{patch}` commit +5. Run `git push --follow-tags` to push the commit and tag - +Pushing the `v{major}.{minor}.{patch}` tag will trigger the [Publish Stable workflow](.github/workflows/cd-stable.yml) to automatically package the extension, create a [GitHub release](https://github.com/gitkraken/vscode-gitlens/releases/latest), and deploy it to the [VS Marketplace](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens). -## [12.1.0] - 2022-06-14 -``` +If the action fails and retries are unsuccessful, the VSIX can be built locally with `pnpm run package` and uploaded manually to the marketplace. A GitHub release can also be [created manually](https://github.com/gitkraken/vscode-gitlens/releases/new) using `v{major}.{minor}.{patch}` as the title and the notes from the [CHANGELOG.md](CHANGELOG.md) with the VSIX attached. + +#### Preparing a Patch Release + +Before publishing a new release, do the following: + +1. Ensure all items in the `{major}.{minor}-patch` GitHub milestone are closed and verified +2. Create, if needed, a `release/{major}.{minor}` branch from the latest `v{major}.{minor}.{patch}` tag +3. Cherry-pick the desired commits from `main` into the `release/{major}.{minor}` branch +4. Follow steps 2-5 in [Preparing a Normal Release](#preparing-a-normal-release) above +5. Manually update the [CHANGELOG.md](CHANGELOG.md) on `main` with the patch release notes -Stage this file so it will be included with the version commit. +Note: All patch releases for the same `{major}.{minor}` version use the same `release/{major}.{minor}` branch -#### Version Commit +### Pre-releases -Run `yarn version` and enter the upcoming version when prompted. +The [Publish Pre-release workflow](.github/workflows/cd-pre.yml) is automatically run every AM unless no new changes have been committed to `main`. This workflow can also be manually triggered by running the `Publish Pre-release` workflow from the Actions tab, no more than once per hour (because of the versioning scheme). -Once the commit is completed, run `git push --follow-tags` to push the version commit and the newly generated tags. +### Insiders (deprecated use pre-release instead) -### GitHub Actions and Deployment +The Publish Insiders workflow is no longer available and was replaced with the pre-release edition. -After the version commit and new tags are pushed to GitHub, the [Publish Stable workflow](.github/workflows/cd-stable.yml) will be triggered, which will automatically package the extension and deploy it to the [VS Marketplace](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens). The [release notes](https://github.com/gitkraken/vscode-gitlens/releases/latest) _should_ be generated during the action, but if not, this can be done manually using the notes from the [Change Log](CHANGELOG.md). +## Updating GL Icons -If the action fails, the VSIX will need to be built locally with `yarn package` and uploaded manually in the marketplace. +To add new icons to the GL Icons font follow the steps below: -### Pre-release edition (currently disabled until VS Code's marketplace supports pre-releases) +- Add new SVG icons to the `images/icons` folder +- Append entries for the new icons to the end of the `images/icons/template/mapping.json` file + - Entries should be in the format of `: ` +- Optimize and build the icons by running the following from a terminal: -The [Publish Pre-release workflow](.github/workflows/cd-pre.yml) is automatically run every AM unless no new changes have been committed to `main`. + ``` + pnpm run icons:svgo + pnpm run build:icons -### Insiders edition + ``` -The [Publish Insiders workflow](.github/workflows/cd-insiders.yml) is automatically run every AM unless no new changes have been committed to `main`. +Once you've finshed copy the new `glicons.woff2?` URL from `src/webviews/apps/shared/glicons.scss` and search and replace the old references with the new one. diff --git a/LICENSE b/LICENSE index a8bb71439ade0..8dc4c71f020f8 100644 --- a/LICENSE +++ b/LICENSE @@ -8,7 +8,7 @@ which are covered by LICENSE.plus. The MIT License (MIT) -Copyright (c) 2021-2023 Axosoft, LLC dba GitKraken ("GitKraken") +Copyright (c) 2021-2024 Axosoft, LLC dba GitKraken ("GitKraken") Copyright (c) 2016-2021 Eric Amodio Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/LICENSE.plus b/LICENSE.plus index 814b362324cca..7d3d6faa994dd 100644 --- a/LICENSE.plus +++ b/LICENSE.plus @@ -1,6 +1,6 @@ GitLens+ License -Copyright (c) 2021-2023 Axosoft, LLC dba GitKraken ("GitKraken") +Copyright (c) 2021-2024 Axosoft, LLC dba GitKraken ("GitKraken") With regard to the software set forth in or under any directory named "plus". diff --git a/README.insiders.md b/README.insiders.md deleted file mode 100644 index cfd3bd5af063d..0000000000000 --- a/README.insiders.md +++ /dev/null @@ -1 +0,0 @@ -> **This is the insiders edition of GitLens for early feedback, and testing. It works best with [VS Code Insiders](https://code.visualstudio.com/insiders).** diff --git a/README.md b/README.md index 08911b8844e6d..ec0f4f53a1d8f 100644 --- a/README.md +++ b/README.md @@ -1,1128 +1,341 @@ -

-
- GitLens Logo -

- -> GitLens **supercharges** Git inside VS Code and unlocks **untapped knowledge** within each repository. It helps you to **visualize code authorship** at a glance via Git blame annotations and CodeLens, **seamlessly navigate and explore** Git repositories, **gain valuable insights** via rich visualizations and powerful comparison commands, and so much more. - -

-
- Watch the GitLens Getting Started video -
-

- -

- Open What's New in GitLens 13 -
- or read the change log -

+# GitLens — Supercharge Git in VS Code -# GitLens - -[GitLens](https://gitkraken.com/gitlens?utm_source=gitlens-extension&utm_medium=in-app-links&utm_campaign=gitlens-logo-links 'Learn more about GitLens') is an [open-source](https://github.com/gitkraken/vscode-gitlens 'Open GitLens on GitHub') extension for [Visual Studio Code](https://code.visualstudio.com). - -GitLens simply helps you **better understand code**. Quickly glimpse into whom, why, and when a line or code block was changed. Jump back through history to **gain further insights** as to how and why the code evolved. Effortlessly explore the history and evolution of a codebase. - -GitLens is **powerful**, **feature rich**, and [highly customizable](#gitlens-settings- 'Jump to the GitLens settings docs') to meet your needs. Do you find CodeLens intrusive or the current line blame annotation distracting — no problem, quickly turn them off or change how they behave via the interactive [_GitLens Settings_ editor](#configuration 'Jump to Configuration'). For advanced customizations, refer to the [GitLens docs](#gitlens-settings- 'Jump to GitLens settings') and edit your [user settings](https://code.visualstudio.com/docs/getstarted/settings 'Open User settings'). - -Here are just some of the **features** that GitLens provides, - -- effortless [**revision navigation**](#revision-navigation- 'Jump to Revision Navigation') (backwards and forwards) through the history of a file -- an unobtrusive [**current line blame**](#current-line-blame- 'Jump to Current Line Blame') annotation at the end of the line showing the commit and author who last modified the line, with more detailed blame information accessible on [**hover**](#hovers- 'Jump to Hovers') -- [**authorship CodeLens**](#git-codelens- 'Jump to Git CodeLens') showing the most recent commit and number of authors at the top of files and/or on code blocks -- a [**status bar blame**](#status-bar-blame- 'Jump to Status Bar Blame') annotation showing the commit and author who last modified the current line -- on-demand **file annotations** in the editor, including - - [**blame**](#file-blame- 'Jump to File Blame') — shows the commit and author who last modified each line of a file - - [**changes**](#file-changes- 'Jump to File Changes') — highlights any local (unpublished) changes or lines changed by the most recent commit - - [**heatmap**](#file-heatmap- 'Jump to File Heatmap') — shows how recently lines were changed, relative to all the other changes in the file and to now (hot vs. cold) -- many rich **Side Bar views** - - a [**_Commit Details_ view**](#commits-details-view- 'Jump to the Commits Details view') to provide rich details for commits and stashes - - a [**_Commits_ view**](#commits-view- 'Jump to the Commits view') to visualize, explore, and manage Git commits - - a [**_Repositories_ view**](#repositories-view- 'Jump to the Repositories view') to visualize, explore, and manage Git repositories - - a [**_File History_ view**](#file-history-view- 'Jump to the File History view') to visualize, navigate, and explore the revision history of the current file or just the selected lines of the current file - - a [**_Line History_ view**](#line-history-view- 'Jump to the Line History view') to visualize, navigate, and explore the revision history of the selected lines of the current file - - a [**_Branches_ view**](#branches-view- 'Jump to the Branches view') to visualize, explore, and manage Git branches - - a [**_Remotes_ view**](#remotes-view- 'Jump to the Remotes view') to visualize, explore, and manage Git remotes and remote branches - - a [**_Stashes_ view**](#stashes-view- 'Jump to the Stashes view') to visualize, explore, and manage Git stashes - - a [**_Tags_ view**](#tags-view- 'Jump to the Tags view') to visualize, explore, and manage Git tags - - a [**_Contributors_ view**](#contributors-view- 'Jump to the Contributors view') to visualize, navigate, and explore contributors - - a [**_Search & Compare_ view**](#search--compare-view- 'Jump to the Search & Compare view') to search and explore commit histories by message, author, files, id, etc, or visualize comparisons between branches, tags, commits, and more -- a [**Git Command Palette**](#git-command-palette- 'Jump to the Git Command Palette') to provide guided (step-by-step) access to many common Git commands, as well as quick access to - - [commits](#quick-commit-access- 'Jump to Quick Commit Access') — history and search - - [stashes](#quick-stash-access- 'Jump to Quick Stash Access') - - [status](#quick-status-access- 'Jump to Quick Status Access') — current branch and working tree status -- a user-friendly [**interactive rebase editor**](#interactive-rebase-editor- 'Jump to the Interactive Rebase Editor') to easily configure an interactive rebase session -- [**terminal links**](#terminal-links- 'Jump to Terminal Links') — `ctrl+click` on autolinks in the integrated terminal to quickly jump to more details for commits, branches, tags, and more -- rich [**remote provider integrations**](#remote-provider-integrations- 'Jump to Remote Provider Integrations') — GitHub, GitLab, Gitea, Gerrit, GoogleSource, Bitbucket, Azure DevOps - - issue and pull request auto-linking - - rich hover information provided for linked issues and pull requests (GitHub only) - - associates pull requests with branches and commits (GitHub only) -- many [**powerful commands**](#powerful-commands- 'Jump to Powerful Commands') for navigating and comparing revisions and more -- customizable [**menus & toolbars**](#menus--toolbars- 'Jump to Menus & Toolbars') for control over where menu and toolbar items are shown -- user-defined [**modes**](#modes- 'Jump to Modes') for quickly toggling between sets of settings -- and so much more 😁 - -# GitLens+ Features [#](#gitlens+-features- 'GitLens+ Features') - -All-new, powerful, additional features that enhance your GitLens experience. - -[GitLens+ features](https://gitkraken.com/gitlens/plus-features?utm_source=gitlens-extension&utm_medium=in-app-links&utm_campaign=gitlens-plus-links) are free for local and public repos, no account required, while upgrading to GitLens Pro gives you access on private repos. - -> All other GitLens features can always be used on any repo. - -## Does this affect existing features? - -No, the introduction of GitLens+ features has no impact on existing GitLens features, so you won't lose access to any of the GitLens features you know and love. In fact, we are heavily investing in enhancing and expanding the GitLens feature set. Additionally, GitLens+ features are freely available to everyone for local and public repos, while upgrading to GitLens Pro gives you access on private repos. - -## Commit Graph [#](#commit-graph- 'Commit Graph') - -

- Commit Graph -

- -The _Commit Graph_ helps you easily visualize and keep track of all work in progress. Not only does it help you verify your changes, but also easily see changes made by others and when. Selecting a row within the graph will open in-depth information about a commit or stash in the new [Commit Details view](#commit-details-view-). - -Use the rich commit search to find exactly what you're looking for. It's powerful filters allow you to search by a specific commit, message, author, a changed file or files, or even a specific code change. - -## Visual File History view [#](#visual-file-history-view- 'Visual File History view') - -

- Visual File History view -

+> Supercharge Git and unlock **untapped knowledge** within your repository to better **understand**, **write**, and **review** code. Focus, collaborate, accelerate. -The _Visual File History_ view allows you to quickly see the evolution of a file, including when changes were made, how large they were, and who made them. +[GitLens](https://gitkraken.com/gitlens?utm_source=gitlens-extension&utm_medium=in-app-links&utm_campaign=gitlens-logo-links 'Learn more about GitLens') is a powerful [open-source](https://github.com/gitkraken/vscode-gitlens 'Open GitLens on GitHub') extension for [Visual Studio Code](https://code.visualstudio.com). -Use it to quickly find when the most impactful changes were made to a file or who best to talk to about file changes and more. +GitLens supercharges your Git experience in VS Code. Maintaining focus is critical, extra time spent context switching or missing context disrupts your flow. GitLens is the ultimate tool for making Git work for you, designed to improve focus, productivity, and collaboration with a powerful set of tools to help you and your team better understand, write, and review code. -Authors who have contributed changes to the file are on the left y-axis to create a swim-lane of their commits over time (the x-axis). Commit are plotted as color-coded (per-author) bubbles, whose size represents the relative magnitude of the changes. +GitLens sets itself apart from other Git tools through its deep level of integration, versatility, and ease of use. GitLens sits directly within your editor, reducing context switching and promoting a more efficient workflow. We know Git is hard and strive to make it as easy as possible while also going beyond the basics with rich visualizations and step-by-step guidance and safety, just to name a few. -Additionally, each commit's additions and deletions are visualized as color-coded, stacked, vertical bars, whose height represents the number of affected lines (right y-axis). Added lines are shown in green, while deleted lines are red. +> 💡 **After September 27th, Launchpad will require GitLens Pro**. [Upgrade to Pro](https://gitkraken.dev/purchase/checkout?source=gitlens&product=gitlens&promoCode=GLLAUNCHPAD24&context=marketplace) before then and save more than 75%. 💡 -## Worktrees view [#](#worktrees-view- 'Worktrees view') +## Getting Started -

- Worktrees view +

+ Watch the GitLens Getting Started video

-Worktrees help you multitask by minimizing the context switching between branches, allowing you to easily work on different branches of a repository simultaneously. - -Avoid interrupting your work in progress when needing to review a pull request. Simply create a new worktree and open it in a new VS Code window, all without impacting your other work. - -You can create multiple working trees, each of which can be opened in individual windows or all together in a single workspace. - -# Features - -## Revision Navigation [#](#revision-navigation- 'Revision Navigation') +Install GitLens by clicking `Install` on the banner above, or from the Extensions side bar in VS Code, by searching for GitLens. -

- Revision Navigation -

+Use `Switch to Pre-Release Version` on the extension banner to live on the edge and be the first to experience new features. -- Adds an _Open Changes with Previous Revision_ command (`gitlens.diffWithPrevious`) to compare the current file or revision with the previous commit revision -- Adds an _Open Changes with Next Revision_ command (`gitlens.diffWithNext`) to compare the current file or revision with the next commit revision -- Adds an _Open Line Changes with Previous Revision_ command (`gitlens.diffLineWithPrevious`) to compare the current file or revision with the previous line commit revision -- Adds an _Open Changes with Working File_ command (`gitlens.diffWithWorking`) to compare the current revision or most recent commit revision of the current file with the working tree -- Adds an _Open Line Changes with Working File_ command (`gitlens.diffLineWithWorking`) to compare the commit revision of the current line with the working tree -- Adds an _Open Changes with Branch or Tag..._ command (`gitlens.diffWithRevisionFrom`) to compare the current file or revision with another revision of the same file on the selected reference -- Adds an _Open Changes with Revision..._ command (`gitlens.diffWithRevision`) to compare the current file or revision with another revision of the same file +## Is GitLens Free? -## Current Line Blame [#](#current-line-blame- 'Current Line Blame') +All features are free to use on all repos, **except** for features, -

- Current Line Blame -

+- marked with a `Pro` require a [trial or paid plan](https://www.gitkraken.com/gitlens/pricing) for use on privately-hosted repos +- marked with a `Preview` require a GitKraken account, with access level based on your [plan](https://www.gitkraken.com/gitlens/pricing), e.g. Free, Pro, etc -- Adds an unobtrusive, [customizable](#current-line-blame-settings- 'Jump to the Current Line Blame settings'), and [themable](#themable-colors- 'Jump to the Themable Colors'), **blame annotation** at the end of the current line - - Contains the author, date, and message of the current line's most recent commit (by [default](#current-line-blame-settings- 'Jump to the Current Line Blame settings')) - - Adds a _Toggle Line Blame_ command (`gitlens.toggleLineBlame`) to toggle the blame annotation on and off +See the [FAQ](#is-gitlens-free-to-use 'Jump to FAQ') for more details. -## Git CodeLens [#](#git-codelens- 'Git CodeLens') +[Features](#discover-powerful-features 'Jump to Discover Powerful Features') +| [Labs](#gitkraken-labs 'Jump to GitKraken Labs') +| [Pro](#ready-for-gitlens-pro 'Jump to Ready for GitLens Pro?') +| [FAQ](#faq 'Jump to FAQ') +| [Support and Community](#support-and-community 'Jump to Support and Community') +| [Contributing](#contributing 'Jump to Contributing') +| [Contributors](#contributors- 'Jump to Contributors') +| [License](#license 'Jump to License') -

- Git CodeLens -

+# Discover Powerful Features -- Adds Git authorship **CodeLens** to the top of the file and on code blocks ([optional](#git-codelens-settings- 'Jump to the Git CodeLens settings'), on by default) +Quickly glimpse into when, why, and by whom a line or code block was changed. Zero-in on the most important changes and effortlessly navigate through history to gain further insights as to how a file or individual line's code evolved. Visualize code authorship at a glance via Git blame annotations and Git CodeLens. Seamlessly explore Git repositories with the visually-rich Commit Graph. Gain valuable insights via GitLens Inspect, and much more. - - **Recent Change** — author and date of the most recent commit for the file or code block - - Click the CodeLens to show a **commit file details quick pick menu** with commands for comparing, navigating and exploring commits, and more (by [default](#git-codelens-settings- 'Jump to the Git CodeLens settings')) - - **Authors** — number of authors of the file or code block and the most prominent author (if there is more than one) +- [**Blame, CodeLens, and Hovers**](#blame-codelens-and-hovers) — Gain a deeper understanding of how code changed and by whom through in-editor code annotations and rich hovers. +- [**File Annotations**](#file-annotations) — Toggle on-demand whole file annotations to see authorship, recent changes, and a heatmap. +- [**Revision Navigation**](#revision-navigation) — Explore the history of a file to see how the code evolved over time. +- [**Side Bar Views**](#side-bar-views) — Powerful views into Git that don't come in the box. +- [**Commit Graph `Pro`**](#commit-graph-pro) — Visualize your repository and keep track of all work in progress. +- [**Launchpad `Preview`**](#launchpad-preview) — Stay focused and keep your team unblocked. +- [**Code Suggest `Preview`**](#code-suggest-preview) — Free your code reviews from unnecessary restrictions. +- [**Cloud Patches `Preview`**](#cloud-patches-preview) — Easily and securely share code with your teammates. +- [**Worktrees `Pro`**](#worktrees-pro) — Simultaneously work on different branches of a repository. +- [**Visual File History `Pro`**](#visual-file-history-pro) — Identify the most impactful changes to a file and by whom. +- [**GitKraken Workspaces `Preview`**](#gitkraken-workspaces-preview) — Easily group and manage multiple repositories. +- [**Interactive Rebase Editor**](#interactive-rebase-editor) — Visualize and configure interactive rebase operations with a user-friendly editor. +- [**Comprehensive Commands**](#comprehensive-commands) — A rich set of commands to help you do everything you need. +- [**Integrations**](#integrations) — Simplify your workflow and quickly gain insights via integration with your Git hosting services. - - Click the CodeLens to toggle the file Git blame annotations on and off of the whole file (by [default](#git-codelens-settings- 'Jump to the Git CodeLens settings')) - - Will be hidden if the author of the most recent commit is also the only author of the file or block, to avoid duplicate information and reduce visual noise +## Blame, CodeLens, and Hovers - - Provides [customizable](#git-codelens-settings- 'Jump to the Git CodeLens settings') click behavior for each CodeLens — choose between one of the following - - Toggle file blame annotations on and off - - Compare the commit with the previous commit - - Show a quick pick menu with details and commands for the commit - - Show a quick pick menu with file details and commands for the commit - - Show a quick pick menu with the commit history of the file - - Show a quick pick menu with the commit history of the current branch +Gain a deeper understanding of how code changed and by whom through in-editor code annotations and rich hovers. -- Adds a _Toggle Git CodeLens_ command (`gitlens.toggleCodeLens`) with a shortcut of `shift+alt+b` to toggle the CodeLens on and off +### Inline and Status Bar Blame -## Status Bar Blame [#](#status-bar-blame- 'Status Bar Blame') +Provides historical context about line changes through unobtrusive **blame annotation** at the end of the current line and on the status bar. -

+

+ Inline Line Blame +
Inline blame annotations
+
+
Status Bar Blame -

- -- Adds a [customizable](#status-bar-settings- 'Jump to the Status Bar Blame settings') **Git blame annotation** showing the commit and author who last modified the current line to the **status bar** ([optional](#status-bar-settings- 'Jump to the Status Bar Blame settings'), on by default) +
Status bar blame annotations
+
- - Contains the commit author and date (by [default](#status-bar-settings- 'Jump to the Status Bar Blame settings')) - - Click the status bar item to show a **commit details quick pick menu** with commands for comparing, navigating and exploring commits, and more (by [default](#status-bar-settings- 'Jump to the Status Bar Blame settings')) +💡 Use the `Toggle Line Blame` and `Toggle Git CodeLens` commands from the Command Palette to turn the annotations on and off. - - Provides [customizable](#status-bar-settings- 'Jump to the Status Bar Blame settings') click behavior — choose between one of the following - - Toggle file blame annotations on and off - - Toggle CodeLens on and off - - Compare the line commit with the previous commit - - Compare the line commit with the working tree - - Show a quick pick menu with details and commands for the commit (default) - - Show a quick pick menu with file details and commands for the commit - - Show a quick pick menu with the commit history of the file - - Show a quick pick menu with the commit history of the current branch +### Git CodeLens -## Hovers [#](#hovers- 'Hovers') +Adds contextual and actionable authorship information at the top of each file and at the beginning of each block of code. -### Current Line Hovers +- **Recent Change** — author and date of the most recent commit for the file or code block +- **Authors** — number of authors of the file or code block and the most prominent author (if there is more than one) -

- Current Line Hovers -

- -- Adds [customizable](#hover-settings- 'Jump to the Hover settings') Git blame hovers accessible over the current line - -### Details Hover - -

- Current Line Details Hover -

- -- Adds a **details hover** annotation to the current line to show more commit details ([optional](#hover-settings- 'Jump to the Hover settings'), on by default) - - Provides **automatic issue linking** to Bitbucket, Gerrit, GoogleSource, Gitea, GitHub, GitLab, and Azure DevOps in commit messages - - Provides a **quick-access command bar** with _Open Changes_, _Blame Previous Revision_, _Open on Remote_, _Invite to Live Share_ (if available), and _Show More Actions_ command buttons - - Click the commit SHA to execute the _Show Commit_ command - -### Changes (diff) Hover - -

- Current Line Changes (diff) Hover -

- -- Adds a **changes (diff) hover** annotation to the current line to show the line's previous version ([optional](#hover-settings- 'Jump to the Hover settings'), on by default) - - Click the **Changes** to execute the _Open Changes_ command - - Click the current and previous commit SHAs to execute the _Show Commit_ command - -### Annotation Hovers - -

- Annotation Hovers -

- -- Adds [customizable](#hover-settings- 'Jump to the Hover settings') Git blame hovers accessible when annotating - -### Details Hover +### Rich Hovers -

- Annotations Details Hover -

+Hover over blame annotations to reveal rich details and actions. -- Adds a **details hover** annotation to each line while annotating to show more commit details ([optional](#hover-settings- 'Jump to the Hover settings'), on by default) - - Provides **automatic issue linking** to Bitbucket, Gerrit, GoogleSource, Gitea, GitHub, GitLab, and Azure DevOps in commit messages - - Provides a **quick-access command bar** with _Open Changes_, _Blame Previous Revision_, _Open on Remote_, _Invite to Live Share_ (if available), and _Show More Actions_ command buttons - - Click the commit SHA to execute the _Show Commit_ command - -### Changes (diff) Hover - -

- Annotations Changes (diff) Hover -

+
+ Current Line Hovers +
-- Adds a **changes (diff) hover** annotation to each line while annotating to show the line's previous version ([optional](#hover-settings- 'Jump to the Hover settings'), on by default) - - Click the **Changes** to execute the _Open Changes_ command - - Click the current and previous commit SHAs to execute the _Show Commit_ command +## File Annotations -## File Blame [#](#file-blame- 'File Blame') +Use on-demand whole file annotations to see authorship, recent changes, and a heatmap. Annotations are rendered as visual indicators directly in the editor. -

+

File Blame -

- -- Adds on-demand, [customizable](#file-blame-settings- 'Jump to the File Blame settings'), and [themable](#themable-colors- 'Jump to Themable Colors'), **file blame annotations** to show the commit and author who last modified each line of a file - - Contains the commit message and date, by [default](#file-blame-settings- 'Jump to the File Blame settings') - - Adds a **heatmap** (age) indicator on right edge (by [default](#file-blame-settings- 'Jump to the File Blame settings')) of the file to provide an easy, at-a-glance way to tell how recently lines were changed ([optional](#file-blame-settings- 'Jump to the File Blame settings'), on by default) - - See the [file heatmap](#file-Heatmap- 'Jump to File Heatmap') section below for more details - - Adds a _Toggle File Blame_ command (`gitlens.toggleFileBlame`) with a shortcut of `alt+b` to toggle the blame annotations on and off - - Press `Escape` to turn off the annotations - -## File Changes [#](#file-changes- 'File Changes') - -

+

File Blame annotations
+
+
File Changes -

- -- Adds an on-demand, [customizable](#file-changes-settings- 'Jump to the File Changes settings') and [themable](#themable-colors- 'Jump to Themable Colors'), **file changes annotation** to highlight any local (unpublished) changes or lines changed by the most recent commit - - Adds _Toggle File Changes_ command (`gitlens.toggleFileChanges`) to toggle the changes annotations on and off - - Press `Escape` to turn off the annotations - -## File Heatmap [#](#file-heatmap- 'File Heatmap') - -

+

File Changes annotations
+
+
File Heatmap -

- -- Adds an on-demand **heatmap** to the edge of the file to show how recently lines were changed - - The indicator's [customizable](#file-heatmap-settings- 'Jump to the File Heatmap settings') color will either be hot or cold based on the age of the most recent change (cold after 90 days by [default](#file-heatmap-settings- 'Jump to the File Heatmap settings')) - - The indicator's brightness ranges from bright (newer) to dim (older) based on the relative age, which is calculated from the median age of all the changes in the file - - Adds _Toggle File Heatmap Annotations_ command (`gitlens.toggleFileHeatmap`) to toggle the heatmap on and off - - Press `Escape` to turn off the annotations - -## Side Bar Views [#](#side-bar-views- 'Side Bar Views') +
File Heatmap annotations
+
-GitLens adds many side bar views to provide additional rich functionality. The default layout (location) of these views can be quickly customized via the _GitLens: Set Views Layout_ (`gitlens.setViewsLayout`) command from the [_Command Palette_](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette). +💡 On an active file, use the `Toggle File Blame`, `Toggle File Changes`, and `Toggle File Heatmap` commands from the Command Palette to turn the annotations on and off. -- _Source Control Layout_ — shows all the views together on the Source Control side bar -- _GitLens Layout_ — shows all the views together on the GitLens side bar +## Revision Navigation -

- Views Layout: Source Control - Views Layout: GitLens -

- -### Commit Details View [#](#commit-details-view- 'Commits Details view') +With just a click of a button, you can navigate backwards and forwards through the history of any file. Compare changes over time and see the revision history of the whole file or an individual line. -

- Commits Details view -

+
+ Revision Navigation +
-The _Commit Details_ view provides rich details for commits and stashes: author, commit ID, autolinks to pull requests and issues, changed files, and more. +## Side Bar Views -These will show contextually as you navigate: +Our views are arranged for focus and productivity, although you can easily drag them around to suit your needs. -- lines in the text editor -- commits in the [Commit Graph](#commit-graph-), [Visual File History](#visual-file-history-view-), or [Commits view](#commits-view-) -- stashes in the [Stashes view](#stashes-view-) +
+ Side Bar views +
GitLens Inspect as shown above has been manually moved into the Secondary Side Bar
+
-Alternatively, you can search for or choose a commit directly from the view. +💡 Use the `Reset Views Layout` command to quickly get back to the default layout. -**For optimal usage, we highly recommended dragging this view to the Secondary Side Bar.** +### GitLens Inspect -### Commits View [#](#commits-view- 'Commits view') +An x-ray or developer tools Inspect into your code, focused on providing contextual information and insights to what you're actively working on. -

- Commits view -

+- **Inspect** — See rich details of a commit or stash. +- **Line History** — Jump through the revision history of the selected line(s). +- **File History** — Explore the revision history of a file, folder, or selected lines. +- [**Visual File History `Pro`**](#visual-file-history-pro) — Quickly see the evolution of a file, including when changes were made, how large they were, and who made them. +- **Search & Compare** — Search and explore for a specific commit, message, author, changed file or files, or even a specific code change, or visualize comparisons between branches, tags, commits, and more. -A [customizable](#commits-view-settings- 'Jump to the Commits view settings') view to visualize, explore, and manage Git commits. - -The _Commits_ view lists all of the commits on the current branch, and additionally provides: - -- a toggle to switch between showing all commits or just your own commits -- a toggle to change the file layout: list, tree, auto -- a branch comparison tool (**Compare <current branch> with <branch, tag, or ref>**) — [optionally](#commits-view-settings- 'Jump to the Commits view settings') shows a comparison of the current branch (or working tree) to a user-selected reference - - **Behind** — lists the commits that are missing from the current branch (i.e. behind) but exist in the selected reference - - **# files changed** — lists all of the files changed in the behind commits - - **Ahead** — lists the commits that the current branch has (i.e. ahead) but are missing in the selected reference - - **# files changed** — lists all of the files changed in the ahead commits - - **# files changed** — lists all of the files changed between the compared references -- the current branch status — shows the upstream status of the current branch - - **Publish <current branch> to <remote>** — shown when the current branch has not been published to a remote - - **Up to date with <remote>** — shown when the current branch is up to date with the upstream remote - - **Changes to pull from <remote>** — lists all of the commits waiting to be pulled when the current branch has commits that are waiting to be pulled from the upstream remote - - **Changes to push to <remote>** — lists of all the files changed in the unpublished commits when the current branch has (unpublished) commits that waiting to be pushed to the upstream remote - - **Merging into <branch>** or **Resolve conflicts before merging into <branch>** — lists any conflicted files. Conflicted files show comparisons with the common base of the current and incoming changes to aid in resolving the conflict by making it easier to see where changes originated - ![Merging](https://raw.githubusercontent.com/gitkraken/vscode-gitlens/main/images/docs/commits-view-merge.png) - - **Rebasing <branch>** or **Resolve conflicts to continue rebasing <branch>** — shows the number of rebase steps left, the commit the rebase is paused at, and lists any conflicted files. Conflicted files show comparisons with the common base of the current and incoming changes to aid in resolving the conflict by making it easier to see where changes originated - ![Rebasing](https://raw.githubusercontent.com/gitkraken/vscode-gitlens/main/images/docs/commits-view-rebase.png) -- any associated pull request — shows any opened or merged pull request associated with the current branch - ---- - -### Repositories View [#](#repositories-view- 'Repositories view') - -

- Repositories view -

+### GitLens -A hidden by default, [customizable](#repositories-view-settings- 'Jump to the Repositories view settings') view to visualize, explore, and manage Git repositories. - -The Repositories view lists opened Git repositories, and additionally provides: - -- a toggle to automatically refresh the repository on changes -- a toggle to change the file layout: list, tree, auto -- an icon overlay indicator to show the current branch's upstream status (if available) - - _No dot_ — no changes or the branch is unpublished - - _Green dot_ — has changes unpushed (ahead) - - _Red dot_ — has changes unpulled (behind) - - _Yellow dot_ — both unpushed and unpulled changes -- a branch comparison tool (**Compare <current branch> with <branch, tag, or ref>**) — [optionally](#repositories-view-settings- 'Jump to the Repositories view settings') shows a comparison of the current branch (or working tree) to a user-selected reference - - **Behind** — lists the commits that are missing from the current branch (i.e. behind) but exist in the selected reference - - **# files changed** — lists all of the files changed between the compared references - - **Ahead** — lists the commits that the current branch has (i.e. ahead) but are missing in the selected reference - - **# files changed** — lists all of the files changed between the compared references -- **# files changed** — [optionally](#repositories-view-settings- 'Jump to the Repositories view settings') lists all of the files changed in the working tree -- the current branch status — [optionally](#repositories-view-settings- 'Jump to the Repositories view settings') shows the upstream status of the current branch - - **Publish <current branch> to remote** — shown when the current branch has not been published to a remote - - **Up to date with <remote>** — shown when the current branch is up to date with the upstream remote - - **Changes to pull from <remote>** — lists all of the unpulled commits and all of the files changed in them, when the current branch has commits that are waiting to be pulled from the upstream remote - - **Changes to push to <remote>** — lists of all the unpublished commits and all of the files changed in them, when the current branch has commits that waiting to be pushed to the upstream remote - - **Merging into <branch>** or **Resolve conflicts before merging into <branch>** — lists any conflicted files. Conflicted files show comparisons with the common base of the current and incoming changes to aid in resolving the conflict by making it easier to see where changes originated - - **Rebasing <branch>** or **Resolve conflicts to continue rebasing <branch>** — shows the number of rebase steps left, the commit the rebase is paused at, and lists any conflicted files. Conflicted files show comparisons with the common base of the current and incoming changes to aid in resolving the conflict by making it easier to see where changes originated -- any associated pull request — [optionally](#repositories-view-settings- 'Jump to the Repositories view settings') shows any opened or merged pull request associated with the current branch -- **Commits** — [optionally](#repositories-view-settings- 'Jump to the Repositories view settings') shows the current branch commits, similar to the [Commits view](#commits-view- 'Commits view') -- **Branches** — [optionally](#repositories-view-settings- 'Jump to the Repositories view settings') shows the local branches, similar to the [Branches view](#branches-view- 'Branches view') -- **Remotes** — [optionally](#repositories-view-settings- 'Jump to the Repositories view settings') shows the remotes and remote branches, similar to the [Remotes view](#remotes-view- 'Remotes view') -- **Stashes** — [optionally](#repositories-view-settings- 'Jump to the Repositories view settings') shows the stashes, similar to the [Stashes view](#stashes-view- 'Stashes view') -- **Tags** — [optionally](#repositories-view-settings- 'Jump to the Repositories view settings') shows the tags, similar to the [Tags view](#tags-view- 'Tags view') -- **Contributors** — [optionally](#repositories-view-settings- 'Jump to the Repositories view settings') shows the contributors, similar to the [Contributors view](#contributors-view- 'Contributors view') -- **Incoming Activity** (Experimental) — [optionally](#repositories-view-settings- 'Jump to the Repositories view settings') shows any incoming activity, which lists the command, branch (if available), and date of recent incoming activity (merges and pulls) to your local repository - ---- - -### File History View [#](#file-history-view- 'File History view') - -

- File History view -

+Quick access to many GitLens features. Also the home of GitKraken teams and collaboration services (e.g. GitKraken Workspaces), help, and support. -A [customizable](#file-history-view-settings- 'Jump to the File History view settings') view to visualize, navigate, and explore the revision history of the current file or just the selected lines of the current file. +- **Home** — Quick access to many features. +- [**Cloud Patches `Preview`**](#cloud-patches-preview) — Easily and securely share code with your teammates +- [**GitKraken Workspaces `Preview`**](#gitkraken-workspaces-preview) — Easily group and manage multiple repositories together, accessible from anywhere, streamlining your workflow. +- **GitKraken Account** — Power-up with GitKraken Cloud Services. -The file history view lists all of the commits that changed the current file on the current branch, and additionally provides: +### Source Control -- a toggle to pin (pause) the automatic tracking of the current editor -- a toggle to switch between file and line history, i.e. show all commits of the current file, or just the selected lines of the current file -- the ability to change the current base branch or reference when computing the file or line history -- (file history only) a toggle to follow renames across the current file -- (file history only) a toggle to show commits from all branches rather than just from the current base branch or reference -- merge conflict status when applicable - - **Merge Changes** — show comparisons with the common base of the current and incoming changes to aid in resolving the conflict by making it easier to see where changes originated - ![Merge Conflicts](https://raw.githubusercontent.com/gitkraken/vscode-gitlens/main/images/docs/file-history-view-merge-conflict.png) +Shows additional views that are focused on exploring and managing your repositories. ---- +- **Commits** — Comprehensive view of the current branch commit history, including unpushed changes, upstream status, quick comparisons, and more. +- **Branches** — Manage and navigate branches. +- **Remotes** — Manage and navigate remotes and remote branches. +- **Stashes** — Save and restore changes you are not yet ready to commit. +- **Tags** — Manage and navigate tags. +- [**Worktrees `Pro`**](#worktrees-pro) — Simultaneously work on different branches of a repository. +- **Contributors** — Ordered list of contributors, providing insights into individual contributions and involvement. +- **Repositories** — Unifies the above views for more efficient management of multiple repositories. -### Line History View [#](#line-history-view- 'Line History view') +### (Bottom) Panel -

- Line History view -

+Convenient and easy access to the Commit Graph with a dedicated details view. -A hidden by default, [customizable](#line-history-view-settings- 'Jump to the Line History view settings') view to visualize, navigate, and explore the revision history of the selected lines of the current file. +- [**Commit Graph `Pro`**](#commit-graph-pro) — Visualize your repository and keep track of all work in progress. -The line history view lists all of the commits that changed the selected lines of the current file on the current branch, and additionally provides: +## Commit Graph `Pro` -- a toggle to pin (pause) the automatic tracking of the current editor -- the ability to change the current base branch or reference when computing the line history -- merge conflict status when applicable - - **Merge Changes** — show comparisons with the common base of the current and incoming changes to aid in resolving the conflict by making it easier to see where changes originated +Easily visualize your repository and keep track of all work in progress. ---- +Use the rich commit search to find exactly what you're looking for. Its powerful filters allow you to search by a specific commit, message, author, a changed file or files, or even a specific code change. [Learn more](https://gitkraken.com/solutions/commit-graph?utm_source=gitlens-extension&utm_medium=in-app-links) -### Branches view [#](#branches-view- 'Branches View') +
+ Commit Graph +
-

- Branches view -

+💡Quickly toggle the Graph via the `Toggle Commit Graph` command. -A [customizable](#branches-view-settings- 'Jump to the Branches view settings') view to visualize, explore, and manage Git branches. - -The _Branches_ view lists all of the local branches, and additionally provides: - -- a toggle to change the branch layout: list or tree -- a toggle to change the file layout: list, tree, auto -- an icon overlay indicator to show the branch's upstream status (if available) - - _No dot_ — no changes or the branch is unpublished - - _Green dot_ — has changes unpushed (ahead) - - _Red dot_ — has changes unpulled (behind) - - _Yellow dot_ — both unpushed and unpulled changes -- status indicators (decorations), on the right, and themeable colorizations - - `✓` — indicates that the branch is the current branch - - `▲` + green colorization — indicates that the branch has unpushed changes (ahead) - - `â–ŧ` + red colorization — indicates that the branch has unpulled changes (behind) - - `â–ŧ▲` + yellow colorization — indicates that the branch has diverged from its upstream; meaning it has both unpulled and unpushed changes - - `▲+` + green colorization — indicates that the branch hasn't yet been published to an upstream remote - - `!` + dark red colorization — indicates that the branch has a missing upstream (e.g. the upstream branch was deleted) -- a branch comparison tool (**Compare <branch> with <branch, tag, or ref>**) — [optionally](#branches-view-settings- 'Jump to the Branches view settings') shows a comparison of the branch to a user-selected reference - - **Behind** — lists the commits that are missing from the branch (i.e. behind) but exist in the selected reference - - **# files changed** — lists all of the files changed in the behind commits - - **Ahead** — lists the commits that the branch has (i.e. ahead) but are missing in the selected reference - - **# files changed** — lists all of the files changed in the ahead commits - - **# files changed** — lists all of the files changed between the compared references -- the branch status — shows the upstream status of the branch - - **Publish <branch> to <remote>** — shown when the current branch has not been published to a remote - - **Changes to push to <remote>** — lists of all the files changed in the unpublished commits when the branch has (unpublished) commits that waiting to be pushed to the upstream remote - - **Changes to pull from <remote>** — lists all of the commits waiting to be pulled when the branch has commits that are waiting to be pulled from the upstream remote -- any associated pull request — shows any pull request associated with the branch - ---- - -### Remotes view [#](#remotes-view- 'Remotes View') - -

- Remotes view -

+💡Maximize the Graph via the `Toggle Maximized Commit Graph` command. -A [customizable](#remotes-view-settings- 'Jump to the Remotes view settings') view to visualize, explore, and manage Git remotes and remote branches. +## Launchpad `Preview` -The _Remotes_ view lists all of the remotes and their remote branches, and additionally provides: +Launchpad brings all of your GitHub pull requests into a unified, actionable list to better track work in progress, pending work, reviews, and more. Stay focused and take action on the most important items to keep your team unblocked. [Learn more](https://gitkraken.com/solutions/launchpad?utm_source=gitlens-extension&utm_medium=in-app-links) -- a toggle to change the branch layout: list or tree -- a toggle to change the file layout: list, tree, auto -- a toggle to connect to a supported remote providers to enable a rich integration with pull requests, issues, avatars, and more +
+ Launchpad +
---- +## Code Suggest `Preview` -### Stashes View [#](#stashes-view- 'Stashes View') +Liberate your code reviews from GitHub's restrictive, comment-only feedback style. Like suggesting changes on a Google-doc, suggest code changes from where you're already coding — your IDE and on anything in your project, not just on the lines of code changed in the PR. [Learn more](https://gitkraken.com/solutions/code-suggest?utm_source=gitlens-extension&utm_medium=in-app-links) -

- Stashes view -

+
+ Code Suggest +
-A [customizable](#stashes-view-settings- 'Jump to the Stashes view settings') view to visualize, explore, and manage Git stashes. +## Cloud Patches `Preview` -The _Stashes_ view lists all of the stashes, and additionally provides: +Easily and securely share code changes with your teammates or other developers by creating a Cloud Patch from your WIP, commit or stash and sharing the generated link. Use Cloud Patches to collaborate early for feedback on direction, approach, and more, to minimize rework and streamline your workflow. [Learn more](https://gitkraken.com/solutions/cloud-patches?utm_source=gitlens-extension&utm_medium=in-app-links) -- a toggle to change the file layout: list, tree, auto +## Worktrees `Pro` ---- +Efficiently multitask by minimizing the context switching between branches, allowing you to easily work on different branches of a repository simultaneously. -### Tags View [#](#tags-view- 'Tags View') +Avoid interrupting your work in progress when needing to review a pull request. Simply create a new worktree and open it in a new VS Code window, all without impacting your other work. -

- Tags view -

+
+ Worktrees view +
-A [customizable](#tags-view-settings- 'Jump to the Tags view settings') view to visualize, explore, and manage Git tags. +## GitKraken Workspaces `Preview` -The _Tags_ view lists all of the tags, and additionally provides: +GitKraken Workspaces allow you to easily group and manage multiple repositories together, accessible from anywhere, streamlining your workflow. Create workspaces just for yourself or share (coming soon in GitLens) them with your team for faster onboarding and better collaboration. [Learn more](https://gitkraken.com/solutions/workspaces?utm_source=gitlens-extension&utm_medium=in-app-links) -- a toggle to change the tag layout: list or tree -- a toggle to change the file layout: list, tree, auto +## Visual File History `Pro` ---- +Quickly see the evolution of a file, including when changes were made, how large they were, and who made them. Use it to quickly find when the most impactful changes were made to a file or who best to talk to about file changes and more. -### Contributors View [#](#contributors-view- 'Contributors View') +
+ Visual File History view +
-

- Contributors view -

+## Interactive Rebase Editor -A hidden by default, [customizable](#contributors-view-settings- 'Jump to the Contributors view settings') view to visualize, navigate, and explore contributors. +Easily visualize and configure interactive rebase operations with the intuitive and user-friendly Interactive Rebase Editor. Simply drag & drop to reorder commits and select which ones you want to edit, squash, or drop. -The _Contributors_ view lists all of the contributors, and additionally provides: +
+ Interactive Rebase Editor +
-- a toggle to change the file layout: list, tree, auto +## Comprehensive Commands ---- +Stop worrying about memorizing Git commands; GitLens provides a rich set of commands to help you do everything you need. -### Search & Compare View [#](#search--compare-view- 'Search & Compare View') +### Git Command Palette -

- Search & Compare view -

+A guided, step-by-step experience for quickly and safely executing Git commands. -A hidden by default, [customizable](#search--compare-view-settings- 'Jump to the Search & Compare view settings') view to search and explore commit histories by message, author, files, id, etc, or visualize comparisons between branches, tags, commits, and more. - -The _Search & Compare_ view lists pinnable (saved) results for searching commit histories or for comparison operations, and additionally provides: - -- a toggle to keep previous results when new results are added -- a toggle to change the file layout: list, tree, auto -- pinnable search — lists all of the commits that match the search query - - Search results can be provided by the following commands - - _Search Commits_ command (`gitlens.showCommitSearch`) can search - - by message — use `` to find commits with messages that match `` — See [Git docs](https://git-scm.com/docs/git-log#Documentation/git-log.txt---grepltpatterngt 'Open Git docs') - - or, by author — use `@` to find commits with authors that match `` — See [Git docs](https://git-scm.com/docs/git-log#Documentation/git-log.txt---authorltpatterngt 'Open Git docs') - - or, by commit SHA — use `#` to find a commit with SHA of `` — See [Git docs](https://git-scm.com/docs/git-log#Documentation/git-log.txt-ltrevisionrangegt 'Open Git docs') - - or, by files — use `:` to find commits with file names that match `` — See [Git docs](https://git-scm.com/docs/git-log#Documentation/git-log.txt---ltpathgt82308203 'Open Git docs') - - or, by changes — use `~` to find commits with differences whose patch text contains added/removed lines that match `` — See [Git docs](https://git-scm.com/docs/git-log#Documentation/git-log.txt--Gltregexgt 'Open Git docs') - - _Show File History_ command (`gitlens.showQuickFileHistory`) - - _Show Commit_ command (`gitlens.showQuickCommitDetails`) -- pinnable comparison — shows a comparison of the two user-selected references - - **Behind** — lists the commits that are missing from the branch (i.e. behind) but exist in the selected reference - - **# files changed** — lists all of the files changed in the behind commits - - **Ahead** — lists the commits that the branch has (i.e. ahead) but are missing in the selected reference - - **# files changed** — lists all of the files changed in the ahead commits - - **# files changed** — lists all of the files changed between the compared references - - Comparison results can be provided by the following commands - - _Compare with Upstream_ command (`gitlens.views.compareWithUpstream`) - - _Compare with Working Tree_ command (`gitlens.views.compareWithWorking`) - - _Compare with HEAD_ command (`gitlens.views.compareWithHead`) - - _Compare with Selected_ command (`gitlens.views.compareWithSelected`) - - _Compare Ancestry with Working Tree_ command (`gitlens.views.compareAncestryWithWorking`) - -## Git Command Palette [#](#git-command-palette- 'Git Command Palette') - -

+

Git Command Palette -

+
-- Adds a [customizable](#git-command-palette-settings- 'Jump to the Git Command Palette settings') _Git Command Palette_ command (`gitlens.gitCommands`) to provide guided (step-by-step) access to many common Git commands, as well as quick access to commit history and search, stashes, and more +### Quick Access Commands - - Quickly navigate and execute Git commands through easy-to-use menus where each command can require an explicit confirmation step before executing +Use a series of new commands to: -### Quick Commit Access [#](#quick-commit-access- 'Quick Commit Access') +- Explore the commit history of branches and files +- Quickly search for and navigate to (and action upon) commits +- Explore a file of a commit +- View and explore your stashes +- Visualize the current repository status -- Adds a _Show Branch History_ command (`gitlens.showQuickBranchHistory`) to show a quick pick menu to explore the commit history of the selected branch -- Adds a _Show Current Branch History_ command (`gitlens.showQuickRepoHistory`) to show a quick pick menu to explore the commit history of the current branch +# Integrations -

- Branch History Quick Pick Menu -

+Context switching kills productivity. GitLens not only reveals buried knowledge within your repository, it also brings additional context from issues and pull requests providing you with a wealth of information and insights at your fingertips. -- Adds a _Show File History_ command (`gitlens.showQuickFileHistory`) to show quick pick menu to explore the commit history of the current file +Simplify your workflow and quickly gain insights with automatic linking of issues and pull requests across multiple Git hosting services including GitHub, GitHub Enterprise `Pro`, GitLab, GitLab Self-Managed `Pro`, Jira, Gitea, Gerrit, Google Source, Bitbucket, Bitbucket Server, Azure DevOps, and custom servers. -

- File History Quick Pick Menu -

+All integrations provide automatic linking, while rich integrations with GitHub, GitLab and Jira offer detailed hover information for autolinks, and correlations between pull requests, branches, and commits, as well as user avatars for added context. -- Adds a _Search Commits_ command (`gitlens.showCommitSearch`) to show quick pick menu to search for commits - - by message — use `` to find commits with messages that match `` — See [Git docs](https://git-scm.com/docs/git-log#Documentation/git-log.txt---grepltpatterngt 'Open Git docs') - - or, by author — use `@` to find commits with authors that match `` — See [Git docs](https://git-scm.com/docs/git-log#Documentation/git-log.txt---authorltpatterngt 'Open Git docs') - - or, by commit SHA — use `#` to find a commit with id of `` — See [Git docs](https://git-scm.com/docs/git-log#Documentation/git-log.txt-ltrevisionrangegt 'Open Git docs') - - or, by files — use `:` to find commits with file names that match `` — See [Git docs](https://git-scm.com/docs/git-log#Documentation/git-log.txt---ltpathgt82308203 'Open Git docs') - - or, by changes — use `~` to find commits with differences whose patch text contains added/removed lines that match `` — See [Git docs](https://git-scm.com/docs/git-log#Documentation/git-log.txt--Gltregexgt 'Open Git docs') +## Define your own autolinks -

- Commit Search Quick Pick Menu -

+Use autolinks to linkify external references, like Jira issues or Zendesk tickets, in commit messages. -- Adds a _Show Commit_ command (`gitlens.showQuickCommitDetails`) to show a quick pick menu to explore a commit and take action upon it +# GitKraken Labs -

- Commit Details Quick Pick Menu -

+Our incubator for experimentation and exploration with the community to gather early reactions and feedback. Below are some of our current experiments. -- Adds a _Show Line Commit_ command (`gitlens.showQuickCommitFileDetails`) to show a quick pick menu to explore a file of a commit and take action upon it +## đŸ§ĒAI Explain Commit -

- Commit File Details Quick Pick Menu -

+Use the Explain panel on the **Inspect** view to leverage AI to help you understand the changes introduced by a commit. -### Quick Stash Access [#](#quick-stash-access- 'Quick Stash Access') +## đŸ§ĒAutomatically Generate Commit Message -- Adds a _Show Stashes_ command (`gitlens.showQuickStashList`) to show a quick pick menu to explore your stashes +Use the `Generate Commit Message` command from the Source Control view's context menu to automatically generate a commit message for your staged changes by leveraging AI. -

- Stashes Quick Pick Menu -

-

- Stash Details Quick Pick Menu -

+# Ready for GitLens Pro? -### Quick Status Access [#](#quick-status-access- 'Quick Status Access') +When you're ready to unlock the full potential of GitLens and our [DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links) and enjoy all the benefits on your privately-hosted repos, consider upgrading to GitLens Pro. With GitLens Pro, you'll gain access to [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) on privately-hosted repos. -- Adds a _Show Repository Status_ command (`gitlens.showQuickRepoStatus`) to show a quick pick menu to for visualizing the current repository status +To learn more about the pricing and the additional features offered with GitLens Pro, visit the [GitLens Pricing page](https://www.gitkraken.com/gitlens/pricing?utm_source=gitlens-extension&utm_medium=in-app-links). Upgrade to GitLens Pro today and take your Git workflow to the next level! -

- Repository Status Quick Pick Menu -

+# FAQ -## Interactive Rebase Editor [#](#interactive-rebase-editor- 'Interactive Rebase Editor') +## Is GitLens free to use? -

- Interactive Rebase Editor -

+Yes. All features are free to use on all repos, **except** for `Pro` features, which require a [trial or paid plan](https://www.gitkraken.com/gitlens/pricing?utm_source=gitlens-extension&utm_medium=in-app-links). -- Adds a user-friendly interactive rebase editor to more easily configure an interactive rebase session - - Quickly re-order, edit, squash, and drop commits - - Includes drag & drop support! -- To use this directly from your terminal, e.g. when running `git rebase -i`, +While GitLens offers a remarkable set of free features, a subset of `Pro` features tailored for professional developers and teams, require a trial or paid plan for use on privately-hosted repos — use on local or publicly-hosted repos is free for everyone. Additionally `Preview` features may require a paid plan in the future and some, if cloud-backed, require a GitKraken account. - - set VS Code as your default Git editor - - `git config --global core.editor "code --wait"` - - or, to only affect rebase, set VS Code as your Git rebase editor - - `git config --global sequence.editor "code --wait"` +Preview `Pro` features instantly for free for 3 days without an account, or start a free GitLens Pro trial to get an additional 7 days and gain access to `Pro` features to experience the full power of GitLens. - > To use the Insiders edition of VS Code, replace `code` in the above with `code-insiders` +## Are `Pro` and `Preview` features free to use? -## Terminal Links [#](#terminal-links- 'Terminal Links') +`Pro` features are free for use on local and publicly-hosted repos, while a paid plan is required for use on privately-hosted repos. `Preview` features may require a paid plan in the future. -

- Terminal Links -

+## Where can I find pricing? -- [Optionally](#terminal-links-settings- 'Jump to the Terminal Links settings') adds autolinks for branches, tags, and commit ranges in the integrated terminal to quickly explore their commit history -- [Optionally](#terminal-links-settings- 'Jump to the Terminal Links settings') adds autolinks for commits in the integrated terminal to quickly explore the commit and take action upon it +Visit the [GitLens Pricing page](https://www.gitkraken.com/gitlens/pricing?utm_source=gitlens-extension&utm_medium=in-app-links) for detailed pricing information and feature matrix for plans. -## Remote Provider Integrations [#](#remote-provider-integrations- 'Remote Provider Integrations') +# Support and Community -GitLens provides integrations with many Git hosting services, including GitHub, GitHub Enterprise, GitLab, GitLab self-managed, Gitea, Gerrit, GoogleSource, Bitbucket, Bitbucket Server, and Azure DevOps. You can also define [custom remote providers](#remote-provider-integration-settings- 'Jump to the Remote Provider Integration settings') or [remote providers with custom domains](#remote-provider-integration-settings- 'Jump to the Remote Provider Integration settings') as well. +Support documentation can be found on the [GitLens Help Center](https://help.gitkraken.com/gitlens/gitlens-home/). If you need further assistance or have any questions, there are various support channels and community forums available for GitLens: -All Git host integrations provide issue and pull request auto-linking, while rich integrations (e.g. GitHub & GitLab) provide more detailed hover information for auto-linked issues and pull requests, pull requests associated with branches and commits, and avatars. +## Issue Reporting and Feature Requests -Additionally, these integrations provide commands to copy the URL of or open files, commits, branches, and the repository on the remote provider. +Found a bug? Have a feature request? Reach out on our [GitHub Issues page](https://github.com/gitkraken/vscode-gitlens/issues). -- _Open File from Remote_ command (`gitlens.openFileFromRemote`) — opens the local file from a URL of a file on a remote provider -- _Open File on Remote_ command (`gitlens.openFileOnRemote`) — opens a file or revision on the remote provider -- _Copy Remote File URL_ command (`gitlens.copyRemoteFileUrlToClipboard`) — copies the URL of a file or revision on the remote provider -- _Open File on Remote From..._ command (`gitlens.openFileOnRemoteFrom`) — opens a file or revision on a specific branch or tag on the remote provider -- _Copy Remote File URL From..._ command (`gitlens.copyRemoteFileUrlFrom`) — copies the URL of a file or revision on a specific branch or tag the remote provider -- _Open Commit on Remote_ command (`gitlens.openCommitOnRemote`) — opens a commit on the remote provider -- _Copy Remote Commit URL_ command (`gitlens.copyRemoteCommitUrl`) — copies the URL of a commit on the remote provider -- _Open Branch on Remote_ command (`gitlens.openBranchOnRemote`) — opens the branch on the remote provider -- _Open Current Branch on Remote_ command (`gitlens.openCurrentBranchOnRemote`) — opens the current branch on the remote provider -- _Copy Remote Branch URL_ command (`gitlens.copyRemoteBranchUrl`) — copies the URL of a branch on the remote provider -- _Open Branches on Remote_ command (`gitlens.openBranchesOnRemote`) — opens the branches on the remote provider -- _Copy Remote Branches URL_ command (`gitlens.copyRemoteBranchesUrl`) — copies the URL of the branches on the remote provider -- _Open Comparison on Remote_ command (`gitlens.openComparisonOnRemote`) — opens the comparison on the remote provider -- _Copy Remote Comparison URL_ command (`gitlens.copyRemoteComparisonUrl`) — copies the URL of the comparison on the remote provider -- _Open Pull Request_ command (`gitlens.openPullRequestOnRemote`) — opens the pull request on the remote provider -- _Copy Pull Request URL_ command (`gitlens.copyRemotePullRequestUrl`) — copies the URL of the pull request on the remote provider -- _Open Repository on Remote_ command (`gitlens.openRepoOnRemote`) — opens the repository on the remote provider -- _Copy Remote Repository URL_ command (`gitlens.copyRemoteRepositoryUrl`) — copies the URL of the repository on the remote provider +## Discussions -## Powerful Commands [#](#powerful-commands- 'Powerful Commands') +Join the GitLens community on [GitHub Discussions](https://github.com/gitkraken/vscode-gitlens/discussions) to connect with other users, share your experiences, and discuss topics related to GitLens. -- Adds an _Add Co-authors_ command (`gitlens.addAuthors`) to add a co-author to the commit message input box +## GitKraken Support -- Adds a _Copy SHA_ command (`gitlens.copyShaToClipboard`) to copy the commit SHA of the current line to the clipboard or from the most recent commit to the current branch, if there is no current editor -- Adds a _Copy Message_ command (`gitlens.copyMessageToClipboard`) to copy the commit message of the current line to the clipboard or from the most recent commit to the current branch, if there is no current editor +For any issues or inquiries related to GitLens, you can reach out to the GitKraken support team via the [official support page](https://support.gitkraken.com/). They will be happy to assist you with any problems you may encounter. -- Adds a _Copy Current Branch_ command (`gitlens.copyCurrentBranch`) to copy the name of the current branch to the clipboard +With GitLens Pro, you gain access to priority email support from our customer success team, ensuring higher priority and faster response times. Custom onboarding and training are also available to help you and your team quickly get up and running with a GitLens Pro plan. -- Adds a _Switch to Another Branch_ (`gitlens.views.switchToAnotherBranch`) command — to quickly switch the current branch +# Contributing -- Adds a _Compare References..._ command (`gitlens.compareWith`) to compare two selected references -- Adds a _Compare HEAD with..._ command (`gitlens.compareHeadWith`) to compare the index (HEAD) with the selected reference -- Adds a _Compare Working Tree with..._ command (`gitlens.compareWorkingWith`) to compare the working tree with the selected reference +GitLens is an open-source project that greatly benefits from the contributions and feedback from its community. -- Adds an _Open Changes (difftool)_ command (`gitlens.externalDiff`) to open the changes of a file or set of files with the configured git difftool -- Adds an _Open All Changes (difftool)_ command (`gitlens.externalDiffAll`) to open all working changes with the configured git difftool -- Adds an _Open Directory Compare (difftool)_ command (`gitlens.diffDirectoryWithHead`) to compare the working tree with HEAD with the configured Git difftool -- Adds an _Open Directory Compare (difftool) with..._ command (`gitlens.diffDirectory`) to compare the working tree with the selected reference with the configured Git difftool +Your contributions, feedback, and engagement in the GitLens community are invaluable, and play a significant role in shaping the future of GitLens. Thank you for your support! -- Adds an _Open File_ command (`gitlens.openWorkingFile`) to open the working file for the current file revision -- Adds an _Open Revision..._ command (`gitlens.openFileRevision`) to open the selected revision for the current file -- Adds an _Open Revision from..._ command (`gitlens.openFileRevisionFrom`) to open the revision of the current file from the selected reference -- Adds an _Open Blame Prior to Change_ command (`gitlens.openBlamePriorToChange`) to open the blame of prior revision of the selected line in the current file +## Code Contributions -- Adds a _Open Changed Files_ command (`gitlens.openChangedFiles`) to open any files with working tree changes -- Adds a _Close Unchanged Files_ command (`gitlens.closeUnchangedFiles`) to close any files without working tree changes +Want to contribute to GitLens? Follow the [CONTRIBUTING](https://github.com/gitkraken/vscode-gitlens/blob/main/CONTRIBUTING.md) docs to get started. -- Adds an _Enable Debug Logging_ command (`gitlens.enableDebugLogging`) to enable debug logging to the GitLens output channel -- Adds a _Disable Debug Logging_ command (`gitlens.disableDebugLogging`) to disable debug logging to the GitLens output channel +## Documentation Contributions -## Menus & Toolbars [#](#menus--toolbars- 'Menus & Toolbars') +Contributions to the documentation are greatly appreciated. If you find any areas that can be improved or have suggestions for new documentation, you can submit them as pull requests to the [GitLens Docs](https://github.com/gitkraken/gitlens-docs) repository. -

- Menus & Toolbars -

- -GitLens provides [customizable](#menu--toolbar-settings-) menu and toolbar contributions to put you in control over where GitLens' commands are shown. The easiest way to configure these settings is via the GitLens [**interactive settings editor**](#configuration- 'Jump to Configuration'). - -For example, if you uncheck the _Add to the editor group toolbar_ you will see the following items removed from the toolbar: - -

- Editor Group Toolbar example -

- -You can also expand each group to control each area more granularly. - -## Modes [#](#modes- 'Modes') - -GitLens supports [user-defined](#modes-settings- 'Jump to the Modes settings') modes for quickly toggling between sets of settings. - -- Adds _Switch Mode_ command (`gitlens.switchMode`) to quickly switch the active mode -- Adds a _Zen_ mode which for a zen-like experience, disables many visual features - - Adds _Toggle Zen Mode_ command (`gitlens.toggleZenMode`) to toggle Zen mode -- Adds a _Review_ mode which for reviewing code, enables many visual features - - Adds _Toggle Review Mode_ command (`gitlens.toggleReviewMode`) to toggle Review mode -- Adds the active mode to the **status bar** ([optional](#modes-settings- 'Jump to the Modes settings'), on by default) - -# Configuration [#](#configuration- 'Configuration') - -

- GitLens Interactive Settings -

+# Contributors -GitLens provides a rich **interactive settings editor**, an easy-to-use interface, to configure many of GitLens' powerful features. It can be accessed via the _GitLens: Open Settings_ (`gitlens.showSettingsPage`) command from the [_Command Palette_](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette). - -For more advanced customizations, refer to the [settings documentation](#gitlens-settings- 'Jump to the GitLens settings docs') below. - -# GitLens Settings [#](#gitlens-settings- 'GitLens Settings') - -GitLens is highly customizable and provides many configuration settings to allow the personalization of almost all features. - -## Current Line Blame Settings [#](#current-line-blame-settings- 'Current Line Blame Settings') - -| Name | Description | -| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.currentLine.dateFormat` | Specifies how to format absolute dates (e.g. using the `${date}` token) for the current line blame annotations. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats | -| `gitlens.currentLine.enabled` | Specifies whether to provide a blame annotation for the current line, by default. Use the _Toggle Line Blame Annotations_ command (`gitlens.toggleLineBlame`) to toggle the annotations on and off for the current window | -| `gitlens.currentLine.format` | Specifies the format of the current line blame annotation. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `gitlens.currentLine.dateFormat` setting | -| `gitlens.currentLine.uncommittedChangesFormat` | Specifies the uncommitted changes format of the current line blame annotation. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `gitlens.currentLine.dateFormat` setting

**NOTE**: Setting this to an empty string will disable current line blame annotations for uncommitted changes. | -| `gitlens.currentLine.pullRequests.enabled` | Specifies whether to provide information about the Pull Request (if any) that introduced the commit in the current line blame annotation. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.currentLine.scrollable` | Specifies whether the current line blame annotation can be scrolled into view when it is outside the viewport. **NOTE**: Setting this to `false` will inhibit the hovers from showing over the annotation; Set `gitlens.hovers.currentLine.over` to `line` to enable the hovers to show anywhere over the line. | - -## Git CodeLens Settings [#](#git-codelens-settings- 'Git CodeLens Settings') - -| Name | Description | -| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.codeLens.authors.command` | Specifies the command to be executed when an _authors_ CodeLens is clicked, set to (`gitlens.toggleFileBlame`) by default. Can be set to `false` to disable click actions on the CodeLens.

`gitlens.toggleFileBlame` - toggles file blame annotations
`gitlens.toggleFileHeatmap` - toggles file heatmap
`gitlens.toggleFileChanges` - toggles file changes since before the commit
`gitlens.toggleFileChangesOnly` - toggles file changes from the commit
`gitlens.diffWithPrevious` - opens changes with the previous revision
`gitlens.revealCommitInView` - reveals the commit in the Side Bar
`gitlens.showCommitsInView` - searches for commits within the range
`gitlens.showQuickCommitDetails` - shows details of the commit
`gitlens.showQuickCommitFileDetails` - show file details of the commit
`gitlens.showQuickFileHistory` - shows the current file history
`gitlens.showQuickRepoHistory` - shows the current branch history
`gitlens.openCommitOnRemote` - opens the commit on the remote service (when available)
`gitlens.copyRemoteCommitUrl` - copies the remote commit URL to the clipboard (when available)
`gitlens.openFileOnRemote` - opens the file revision on the remote service (when available)
`gitlens.copyRemoteFileUrl` - copies the remote file URL to the clipboard (when available) | -| `gitlens.codeLens.authors.enabled` | Specifies whether to provide an _authors_ CodeLens, showing number of authors of the file or code block and the most prominent author (if there is more than one) | -| `gitlens.codeLens.enabled` | Specifies whether to provide any Git CodeLens, by default. Use the _Toggle Git CodeLens_ command (`gitlens.toggleCodeLens`) to toggle the Git CodeLens on and off for the current window | -| `gitlens.codeLens.includeSingleLineSymbols` | Specifies whether to provide any Git CodeLens on symbols that span only a single line | -| `gitlens.codeLens.recentChange.command` | Specifies the command to be executed when a _recent change_ CodeLens is clicked, set to (`gitlens.showQuickCommitFileDetails`) by default. Can be set to `false` to disable click actions on the CodeLens.

`gitlens.toggleFileBlame` - toggles file blame annotations
`gitlens.toggleFileHeatmap` - toggles file heatmap
`gitlens.toggleFileChanges` - toggles file changes since before the commit
`gitlens.toggleFileChangesOnly` - toggles file changes from the commit
`gitlens.diffWithPrevious` - opens changes with the previous revision
`gitlens.revealCommitInView` - reveals the commit in the Side Bar
`gitlens.showCommitsInView` - searches for commits within the range
`gitlens.showQuickCommitDetails` - shows details of the commit
`gitlens.showQuickCommitFileDetails` - show file details of the commit
`gitlens.showQuickFileHistory` - shows the current file history
`gitlens.showQuickRepoHistory` - shows the current branch history
`gitlens.openCommitOnRemote` - opens the commit on the remote service (when available)
`gitlens.copyRemoteCommitUrl` - copies the remote commit URL to the clipboard (when available)
`gitlens.openFileOnRemote` - opens the file revision on the remote service (when available)
`gitlens.copyRemoteFileUrl` - copies the remote file URL to the clipboard (when available) | -| `gitlens.codeLens.recentChange.enabled` | Specifies whether to provide a _recent change_ CodeLens, showing the author and date of the most recent commit for the file or code block | -| `gitlens.codeLens.scopes` | Specifies where Git CodeLens will be shown in the document

`document` - adds CodeLens at the top of the document
`containers` - adds CodeLens at the start of container-like symbols (modules, classes, interfaces, etc)
`blocks` - adds CodeLens at the start of block-like symbols (functions, methods, etc) lines | -| `gitlens.codeLens.symbolScopes` | Specifies a set of document symbols where Git CodeLens will or will not be shown in the document. Prefix with `!` to avoid providing a Git CodeLens for the symbol. Must be a member of [`SymbolKind`](https://code.visualstudio.com/docs/extensionAPI/vscode-api#_a-namesymbolkindaspan-classcodeitem-id660symbolkindspan) | - -## Status Bar Settings [#](#status-bar-settings- 'Status Bar Settings') - -| Name | Description | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `gitlens.statusBar.alignment` | Specifies the blame alignment in the status bar

`left` - aligns to the left
`right` - aligns to the right | -| `gitlens.statusBar.command` | Specifies the command to be executed when the blame status bar item is clicked

`gitlens.toggleFileBlame` - toggles file blame annotations
`gitlens.toggleFileHeatmap` - toggles file heatmap
`gitlens.toggleFileChanges` - toggles file changes since before the commit
`gitlens.toggleFileChangesOnly` - toggles file changes from the commit
`gitlens.diffWithPrevious` - opens changes with the previous revision
`gitlens.revealCommitInView` - reveals the commit in the Side Bar
`gitlens.showCommitsInView` - searches for commits within the range
`gitlens.showQuickCommitDetails` - shows details of the commit
`gitlens.showQuickCommitFileDetails` - show file details of the commit
`gitlens.showQuickFileHistory` - shows the current file history
`gitlens.showQuickRepoHistory` - shows the current branch history
`gitlens.openCommitOnRemote` - opens the commit on the remote service (when available)
`gitlens.copyRemoteCommitUrl` - copies the remote commit URL to the clipboard (when available)
`gitlens.openFileOnRemote` - opens the file revision on the remote service (when available)
`gitlens.copyRemoteFileUrl` - copies the remote file URL to the clipboard (when available) | -| `gitlens.statusBar.dateFormat` | Specifies how to format absolute dates (e.g. using the `${date}` token) in the blame information in the status bar. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats | -| `gitlens.statusBar.enabled` | Specifies whether to provide blame information in the status bar | -| `gitlens.statusBar.format` | Specifies the format of the blame information in the status bar. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `gitlens.statusBar.dateFormat` setting | -| `gitlens.statusBar.pullRequests.enabled` | Specifies whether to provide information about the Pull Request (if any) that introduced the commit in the status bar. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.statusBar.reduceFlicker` | Specifies whether to avoid clearing the previous blame information when changing lines to reduce status bar "flashing" | -| `gitlens.statusBar.tooltipFormat` | Specifies the format (in markdown) of hover shown over the blame information in the status bar. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs | - -## Hover Settings [#](#hover-settings- 'Hover Settings') - -| Name | Description | -| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `gitlens.hovers.annotations.changes` | Specifies whether to provide a _changes (diff)_ hover for all lines when showing blame annotations | -| `gitlens.hovers.annotations.details` | Specifies whether to provide a _commit details_ hover for all lines when showing blame annotations | -| `gitlens.hovers.annotations.enabled` | Specifies whether to provide any hovers when showing blame annotations | -| `gitlens.hovers.annotations.over` | Specifies when to trigger hovers when showing blame annotations

`annotation` - only shown when hovering over the line annotation
`line` - shown when hovering anywhere over the line | -| `gitlens.hovers.avatars` | Specifies whether to show avatar images in hovers | -| `gitlens.hovers.avatarSize` | Specifies the size of the avatar images in hovers | -| `gitlens.hovers.changesDiff` | Specifies whether to show just the changes to the line or the set of related changes in the _changes (diff)_ hover

`line` - Shows only the changes to the line
`hunk` - Shows the set of related changes | -| `gitlens.hovers.currentLine.changes` | Specifies whether to provide a _changes (diff)_ hover for the current line | -| `gitlens.hovers.currentLine.details` | Specifies whether to provide a _commit details_ hover for the current line | -| `gitlens.hovers.currentLine.enabled` | Specifies whether to provide any hovers for the current line | -| `gitlens.hovers.currentLine.over` | Specifies when to trigger hovers for the current line

`annotation` - only shown when hovering over the line annotation
`line` - shown when hovering anywhere over the line | -| `gitlens.hovers.detailsMarkdownFormat` | Specifies the format (in markdown) of the _commit details_ hover. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs | -| `gitlens.hovers.enabled` | Specifies whether to provide any hovers | -| `gitlens.hovers.autolinks.enabled` | Specifies whether to automatically link external resources in commit messages | -| `gitlens.hovers.autolinks.enhanced` | Specifies whether to lookup additional details about automatically link external resources in commit messages. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.hovers.pullRequests.enabled` | Specifies whether to provide information about the Pull Request (if any) that introduced the commit in the hovers. Requires a connection to a supported remote service (e.g. GitHub) | - -## View Settings [#](#view-settings- 'View Settings') - -| Name | Description | -| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.views.defaultItemLimit` | Specifies the default number of items to show in a view list. Use 0 to specify no limit | -| `gitlens.views.formats.commits.label` | Specifies the format of commits in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs | -| `gitlens.views.formats.commits.description` | Specifies the description format of commits in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs | -| `gitlens.views.formats.files.label` | Specifies the format of a file in the views. See [_File Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#file-tokens) in the GitLens docs | -| `gitlens.views.formats.files.description` | Specifies the description format of a file in the views. See [_File Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#file-tokens) in the GitLens docs | -| `gitlens.views.formats.stashes.label` | Specifies the format of stashes in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs | -| `gitlens.views.formats.stashes.description` | Specifies the description format of stashes in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs | -| `gitlens.views.pageItemLimit` | Specifies the number of items to show in a each page when paginating a view list. Use 0 to specify no limit | -| `gitlens.views.showRelativeDateMarkers` | Specifies whether to show relative date markers (_Less than a week ago_, _Over a week ago_, _Over a month ago_, etc) on revision (commit) histories in the views | - -## Commits View Settings [#](#commits-view-settings- 'Commits View Settings') - -See also [View Settings](#view-settings- 'Jump to the View settings') - -| Name | Description | -| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.views.commits.avatars` | Specifies whether to show avatar images instead of commit (or status) icons in the _Commits_ view | -| `gitlens.views.commits.files.compact` | Specifies whether to compact (flatten) unnecessary file nesting in the _Commits_ view.
Only applies when `gitlens.views.commits.files.layout` is set to `tree` or `auto` | -| `gitlens.views.commits.files.layout` | Specifies how the _Commits_ view will display files

`auto` - automatically switches between displaying files as a `tree` or `list` based on the `gitlens.views.commits.files.threshold` value and the number of files at each nesting level
`list` - displays files as a list
`tree` - displays files as a tree | -| `gitlens.views.commits.files.threshold` | Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Commits_ view
Only applies when `gitlens.views.commits.files.layout` is set to `auto` | -| `gitlens.views.commits.pullRequests.enabled` | Specifies whether to query for pull requests associated with the current branch and commits in the _Commits_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.commits.pullRequests.showForBranches` | Specifies whether to query for pull requests associated with the current branch in the _Commits_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.commits.pullRequests.showForCommits` | Specifies whether to show pull requests (if any) associated with commits in the _Commits_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.commits.reveal` | Specifies whether to reveal commits in the _Commits_ view, otherwise they will be revealed in the _Repositories_ view | -| `gitlens.views.commits.showBranchComparison` | Specifies whether to show a comparison of the current branch or the working tree with a user-selected reference (branch, tag. etc) in the _Commits_ view

`false` - hides the branch comparison
`branch` - compares the current branch with a user-selected reference
`working` - compares the working tree with a user-selected reference | - -## Repositories View Settings [#](#repositories-view-settings- 'Repositories View Settings') - -See also [View Settings](#view-settings- 'Jump to the View settings') - -| Name | Description | -| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.views.repositories.avatars` | Specifies whether to show avatar images instead of commit (or status) icons in the _Repositories_ view | -| `gitlens.views.repositories.autoRefresh` | Specifies whether to automatically refresh the _Repositories_ view when the repository or the file system changes | -| `gitlens.views.repositories.autoReveal` | Specifies whether to automatically reveal repositories in the _Repositories_ view when opening files | -| `gitlens.views.repositories.branches.layout` | Specifies how the _Repositories_ view will display branches

`list` - displays branches as a list
`tree` - displays branches as a tree when branch names contain slashes `/` | -| `gitlens.views.repositories.branches.showBranchComparison` | Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag. etc) under each branch in the _Repositories_ view | -| `gitlens.views.repositories.compact` | Specifies whether to show the _Repositories_ view in a compact display density | -| `gitlens.views.repositories.files.compact` | Specifies whether to compact (flatten) unnecessary file nesting in the _Repositories_ view. Only applies when `gitlens.views.repositories.files.layout` is set to `tree` or `auto` | -| `gitlens.views.repositories.files.layout` | Specifies how the _Repositories_ view will display files

`auto` - automatically switches between displaying files as a `tree` or `list` based on the `gitlens.views.repositories.files.threshold` value and the number of files at each nesting level
`list` - displays files as a list
`tree` - displays files as a tree | -| `gitlens.views.repositories.files.threshold` | Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Repositories_ view. Only applies when `gitlens.views.repositories.files.layout` is set to `auto` | -| `gitlens.views.repositories.includeWorkingTree` | Specifies whether to include working tree file status for each repository in the _Repositories_ view | -| `gitlens.views.repositories.showBranchComparison` | Specifies whether to show a comparison of a user-selected reference (branch, tag. etc) to the current branch or the working tree in the _Repositories_ view | -| `gitlens.views.repositories.showBranches` | Specifies whether to show the branches for each repository in the _Repositories_ view | -| `gitlens.views.repositories.showCommits` | Specifies whether to show the commits on the current branch for each repository in the _Repositories_ view | -| `gitlens.views.repositories.showContributors` | Specifies whether to show the contributors for each repository in the _Repositories_ view | -| `gitlens.views.repositories.showIncomingActivity` | Specifies whether to show the experimental incoming activity for each repository in the _Repositories_ view | -| `gitlens.views.repositories.showRemotes` | Specifies whether to show the remotes for each repository in the _Repositories_ view | -| `gitlens.views.repositories.showStashes` | Specifies whether to show the stashes for each repository in the _Repositories_ view | -| `gitlens.views.repositories.showTags` | Specifies whether to show the tags for each repository in the _Repositories_ view | -| `gitlens.views.repositories.showUpstreamStatus` | Specifies whether to show the upstream status of the current branch for each repository in the _Repositories_ view | - -## File History View Settings [#](#file-history-view-settings- 'File History View Settings') - -See also [View Settings](#view-settings- 'Jump to the View settings') - -| Name | Description | -| ----------------------------------- | ------------------------------------------------------------------------------------------ | -| `gitlens.views.fileHistory.avatars` | Specifies whether to show avatar images instead of status icons in the _File History_ view | - -## Line History View Settings [#](#line-history-view-settings- 'Line History View Settings') - -See also [View Settings](#view-settings- 'Jump to the View settings') - -| Name | Description | -| ----------------------------------- | ------------------------------------------------------------------------------------------ | -| `gitlens.views.lineHistory.avatars` | Specifies whether to show avatar images instead of status icons in the _Line History_ view | - -## Branches View Settings [#](#branches-view-settings- 'Branches View Settings') - -See also [View Settings](#view-settings- 'Jump to the View settings') - -| Name | Description | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.views.branches.avatars` | Specifies whether to show avatar images instead of commit (or status) icons in the _Branches_ view | -| `gitlens.views.branches.branches.layout` | Specifies how the _Branches_ view will display branches

`list` - displays branches as a list
`tree` - displays branches as a tree | -| `gitlens.views.branches.files.compact` | Specifies whether to compact (flatten) unnecessary file nesting in the _Branches_ view.
Only applies when `gitlens.views.commits.files.layout` is set to `tree` or `auto` | -| `gitlens.views.branches.files.layout` | Specifies how the _Branches_ view will display files

`auto` - automatically switches between displaying files as a `tree` or `list` based on the `gitlens.views.commits.files.threshold` value and the number of files at each nesting level
`list` - displays files as a list
`tree` - displays files as a tree | -| `gitlens.views.branches.files.threshold` | Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Branches_ view
Only applies when `gitlens.views.commits.files.layout` is set to `auto` | -| `gitlens.views.branches.pullRequests.enabled` | Specifies whether to query for pull requests associated with each branch and commits in the _Branches_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.branches.pullRequests.showForBranches` | Specifies whether to query for pull requests associated with each branch in the _Branches_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.branches.pullRequests.showForCommits` | Specifies whether to show pull requests (if any) associated with commits in the _Branches_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.branches.reveal` | Specifies whether to reveal branches in the _Branches_ view, otherwise they will be revealed in the _Repositories_ view | -| `gitlens.views.branches.showBranchComparison` | Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag. etc) in the _Branches_ view

`false` - hides the branch comparison
`branch` - compares the current branch with a user-selected reference | - -## Remotes View Settings [#](#remotes-view-settings- 'Remotes View Settings') - -See also [View Settings](#view-settings- 'Jump to the View settings') - -| Name | Description | -| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `gitlens.views.remotes.avatars` | Specifies whether to show avatar images instead of commit (or status) icons in the _Remotes_ view | -| `gitlens.views.remotes.branches.layout` | Specifies how the _Remotes_ view will display branches

`list` - displays branches as a list
`tree` - displays branches as a tree | -| `gitlens.views.remotes.files.compact` | Specifies whether to compact (flatten) unnecessary file nesting in the _Remotes_ view.
Only applies when `gitlens.views.commits.files.layout` is set to `tree` or `auto` | -| `gitlens.views.remotes.files.layout` | Specifies how the _Remotes_ view will display files

`auto` - automatically switches between displaying files as a `tree` or `list` based on the `gitlens.views.commits.files.threshold` value and the number of files at each nesting level
`list` - displays files as a list
`tree` - displays files as a tree | -| `gitlens.views.remotes.files.threshold` | Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Remotes_ view
Only applies when `gitlens.views.commits.files.layout` is set to `auto` | -| `gitlens.views.remotes.pullRequests.enabled` | Specifies whether to query for pull requests associated with each branch and commits in the _Remotes_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.remotes.pullRequests.showForBranches` | Specifies whether to query for pull requests associated with each branch in the _Remotes_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.remotes.pullRequests.showForCommits` | Specifies whether to show pull requests (if any) associated with commits in the _Remotes_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.remotes.reveal` | Specifies whether to reveal remotes in the _Remotes_ view, otherwise they will be revealed in the _Repositories_ view | -| `gitlens.views.remotes.showBranchComparison` | Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag. etc) in the _Remotes_ view

`false` - hides the branch comparison
`branch` - compares the current branch with a user-selected reference | - -## Stashes View Settings [#](#stashes-view-settings- 'Stashes View Settings') - -See also [View Settings](#view-settings- 'Jump to the View settings') - -| Name | Description | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `gitlens.views.stashes.files.compact` | Specifies whether to compact (flatten) unnecessary file nesting in the _Stashes_ view.
Only applies when `gitlens.views.commits.files.layout` is set to `tree` or `auto` | -| `gitlens.views.stashes.files.layout` | Specifies how the _Stashes_ view will display files

`auto` - automatically switches between displaying files as a `tree` or `list` based on the `gitlens.views.commits.files.threshold` value and the number of files at each nesting level
`list` - displays files as a list
`tree` - displays files as a tree | -| `gitlens.views.stashes.files.threshold` | Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Stashes_ view
Only applies when `gitlens.views.commits.files.layout` is set to `auto` | -| `gitlens.views.stashes.reveal` | Specifies whether to reveal stashes in the _Stashes_ view, otherwise they will be revealed in the _Repositories_ view | - -## Tags View Settings [#](#tags-view-settings- 'Tags View Settings') - -See also [View Settings](#view-settings- 'Jump to the View settings') - -| Name | Description | -| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.views.tags.avatars` | Specifies whether to show avatar images instead of commit (or status) icons in the _Tags_ view | -| `gitlens.views.tags.branches.layout` | Specifies how the _Tags_ view will display tags

`list` - displays tags as a list
`tree` - displays tags as a tree | -| `gitlens.views.tags.files.compact` | Specifies whether to compact (flatten) unnecessary file nesting in the _Tags_ view.
Only applies when `gitlens.views.commits.files.layout` is set to `tree` or `auto` | -| `gitlens.views.tags.files.layout` | Specifies how the _Tags_ view will display files

`auto` - automatically switches between displaying files as a `tree` or `list` based on the `gitlens.views.commits.files.threshold` value and the number of files at each nesting level
`list` - displays files as a list
`tree` - displays files as a tree | -| `gitlens.views.tags.files.threshold` | Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Tags_ view
Only applies when `gitlens.views.commits.files.layout` is set to `auto` | -| `gitlens.views.tags.reveal` | Specifies whether to reveal tags in the _Tags_ view, otherwise they will be revealed in the _Repositories_ view | - -## Worktrees View Settings [#](#worktrees-view-settings- 'Worktrees View Settings') - -See also [View Settings](#view-settings- 'Jump to the View settings') - -| Name | Description | -| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.views.worktrees.avatars` | Specifies whether to show avatar images instead of commit (or status) icons in the _Worktrees_ view | -| `gitlens.views.worktrees.files.compact` | Specifies whether to compact (flatten) unnecessary file nesting in the _Worktrees_ view.
Only applies when `gitlens.views.commits.files.layout` is set to `tree` or `auto` | -| `gitlens.views.worktrees.files.layout` | Specifies how the _Worktrees_ view will display files

`auto` - automatically switches between displaying files as a `tree` or `list` based on the `gitlens.views.commits.files.threshold` value and the number of files at each nesting level
`list` - displays files as a list
`tree` - displays files as a tree | -| `gitlens.views.worktrees.files.threshold` | Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Worktrees_ view
Only applies when `gitlens.views.commits.files.layout` is set to `auto` | -| `gitlens.views.worktrees.pullRequests.enabled` | Specifies whether to query for pull requests associated with the worktree branch and commits in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.worktrees.pullRequests.showForBranches` | Specifies whether to query for pull requests associated with the worktree branch in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.worktrees.pullRequests.showForCommits` | Specifies whether to show pull requests (if any) associated with commits in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.worktrees.reveal` | Specifies whether to reveal worktrees in the _Worktrees_ view, otherwise they will be revealed in the _Repositories_ view | -| `gitlens.views.worktrees.showBranchComparison` | Specifies whether to show a comparison of the worktree branch with a user-selected reference (branch, tag. etc) in the _Worktrees_ view

`false` - hides the branch comparison
`branch` - compares the current branch with a user-selected reference | - -## Contributors View Settings [#](#contributors-view-settings- 'Contributors View Settings') - -See also [View Settings](#view-settings- 'Jump to the View settings') - -| Name | Description | -| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.views.contributors.avatars` | Specifies whether to show avatar images instead of commit (or status) icons in the _Contributors_ view | -| `gitlens.views.contributors.files.compact` | Specifies whether to compact (flatten) unnecessary file nesting in the _Contributors_ view.
Only applies when `gitlens.views.commits.files.layout` is set to `tree` or `auto` | -| `gitlens.views.contributors.files.layout` | Specifies how the _Contributors_ view will display files

`auto` - automatically switches between displaying files as a `tree` or `list` based on the `gitlens.views.commits.files.threshold` value and the number of files at each nesting level
`list` - displays files as a list
`tree` - displays files as a tree | -| `gitlens.views.contributors.files.threshold` | Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Contributors_ view
Only applies when `gitlens.views.commits.files.layout` is set to `auto` | -| `gitlens.views.contributors.pullRequests.enabled` | Specifies whether to query for pull requests associated with the current branch and commits in the _Contributors_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.contributors.pullRequests.showForCommits` | Specifies whether to show pull requests (if any) associated with the current branch in the _Contributors_ view. Requires a connection to a supported remote service (e.g. GitHub) | -| `gitlens.views.contributors.showAllBranches` | Specifies whether to show commits from all branches in the _Contributors_ view | -| `gitlens.views.contributors.showStatistics` | Specifies whether to show contributor statistics in the _Contributors_ view. This can take a while to compute depending on the repository size | - -## Search & Compare View Settings [#](#search-&-compare-view-settings- 'Search & Compare View Settings') - -See also [View Settings](#view-settings- 'Jump to the View settings') - -| Name | Description | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.views.compare.files.threshold` | Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Search Commits_ view
Only applies when `gitlens.views.compare.files.layout` is set to `auto` | -| `gitlens.views.compare.avatars` | Specifies whether to show avatar images instead of commit (or status) icons in the _Compare_ view | -| `gitlens.views.compare.files.compact` | Specifies whether to compact (flatten) unnecessary file nesting in the _Compare_ view. Only applies when `gitlens.views.compare.files.layout` is set to `tree` or `auto` | -| `gitlens.views.compare.files.layout` | Specifies how the _Compare_ view will display files

`auto` - automatically switches between displaying files as a `tree` or `list` based on the `gitlens.views.compare.files.threshold` value and the number of files at each nesting level
`list` - displays files as a list
`tree` - displays files as a tree | -| `gitlens.views.compare.files.threshold` | Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Compare_ view. Only applies when `gitlens.views.compare.files.layout` is set to `auto` | -| `gitlens.views.search.avatars` | Specifies whether to show avatar images instead of commit (or status) icons in the _Search Commits_ view | -| `gitlens.views.search.files.compact` | Specifies whether to compact (flatten) unnecessary file nesting in the _Search Commits_ view
Only applies when `gitlens.views.compare.files.layout` is set to `tree` or `auto` | -| `gitlens.views.search.files.layout` | Specifies how the _Search Commits_ view will display files
`auto` - automatically switches between displaying files as a `tree` or `list` based on the `gitlens.views.compare.files.threshold` value and the number of files at each nesting level
`list` - displays files as a list
`tree` - displays files as a tree | - -## File Blame Settings [#](#file-blame-settings- 'File Blame Settings') - -| Name | Description | -| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.blame.avatars` | Specifies whether to show avatar images in the file blame annotations | -| `gitlens.blame.compact` | Specifies whether to compact (deduplicate) matching adjacent file blame annotations | -| `gitlens.blame.dateFormat` | Specifies how to format absolute dates (e.g. using the `${date}` token) in file blame annotations. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats | -| `gitlens.blame.format` | Specifies the format of the file blame annotations. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `gitlens.blame.dateFormat` setting | -| `gitlens.blame.heatmap.enabled` | Specifies whether to provide a heatmap indicator in the file blame annotations | -| `gitlens.blame.heatmap.location` | Specifies where the heatmap indicators will be shown in the file blame annotations

`left` - adds a heatmap indicator on the left edge of the file blame annotations
`right` - adds a heatmap indicator on the right edge of the file blame annotations | -| `gitlens.blame.highlight.enabled` | Specifies whether to highlight lines associated with the current line | -| `gitlens.blame.highlight.locations` | Specifies where the associated line highlights will be shown

`gutter` - adds an indicator to the gutter
`line` - adds a full-line highlight background color
`overview` - adds an indicator to the scroll bar | -| `gitlens.blame.ignoreWhitespace` | Specifies whether to ignore whitespace when comparing revisions during blame operations | -| `gitlens.blame.separateLines` | Specifies whether file blame annotations will have line separators | -| `gitlens.blame.toggleMode` | Specifies how the file blame annotations will be toggled

`file` - toggles each file individually
`window` - toggles the window, i.e. all files at once | - -## File Changes Settings [#](#file-changes-settings- 'File Changes Settings') - -| Name | Description | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.changes.locations` | Specifies where the indicators of the file changes annotations will be shown

`gutter` - adds an indicator to the gutter
`line` - adds a full-line highlight background color
`overview` - adds an indicator to the scroll bar | -| `gitlens.changes.toggleMode` | Specifies how the file changes annotations will be toggled

`file` - toggles each file individually
`window` - toggles the window, i.e. all files at once | - -## File Heatmap Settings [#](#file-heatmap-settings- 'File Heatmap Settings') - -| Name | Description | -| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.heatmap.ageThreshold` | Specifies the age of the most recent change (in days) after which the file heatmap annotations will be cold rather than hot (i.e. will use `gitlens.heatmap.coldColor` instead of `gitlens.heatmap.hotColor`) | -| `gitlens.heatmap.coldColor` | Specifies the base color of the file heatmap annotations when the most recent change is older (cold) than the `gitlens.heatmap.ageThreshold` value | -| `gitlens.heatmap.hotColor` | Specifies the base color of the file heatmap annotations when the most recent change is newer (hot) than the `gitlens.heatmap.ageThreshold` value | -| `gitlens.heatmap.locations` | Specifies where the indicators of the file heatmap annotations will be shown

`gutter` - adds an indicator to the gutter
`line` - adds a full-line highlight background color
`overview` - adds an indicator to the scroll bar | -| `gitlens.heatmap.toggleMode` | Specifies how the file heatmap annotations will be toggled

`file` - toggles each file individually
`window` - toggles the window, i.e. all files at once | - -## Git Command Palette Settings [#](#git-command-palette-settings- 'Git Command Palette Settings') - -| Name | Description | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.gitCommands.closeOnFocusOut` | Specifies whether to dismiss the _Git Commands Palette_ when focus is lost (if not, press `ESC` to dismiss) | -| `gitlens.gitCommands.search.matchAll` | Specifies whether to match all or any commit message search patterns | -| `gitlens.gitCommands.search.matchCase` | Specifies whether to match commit search patterns with or without regard to casing | -| `gitlens.gitCommands.search.matchRegex` | Specifies whether to match commit search patterns using regular expressions | -| `gitlens.gitCommands.search.showResultsInSideBar` | Specifies whether to show the commit search results directly in the quick pick menu, in the Side Bar, or will be based on the context | -| `gitlens.gitCommands.skipConfirmations` | Specifies which (and when) Git commands will skip the confirmation step, using the format: `git-command-name:(menu/command)` | -| `gitlens.gitCommands.sortBy` | Specifies how Git commands are sorted in the _Git Command Palette_

`name` - sorts commands by name
`usage` - sorts commands by last used date | - -## Terminal Links Settings [#](#terminal-links-settings- 'Terminal Links Settings') - -| Name | Description | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.terminalLinks.enabled` | Specifies whether to enable terminal links — autolinks in the integrated terminal to quickly jump to more details for commits, branches, tags, and more | - -## Remote Provider Integration Settings [#](#remote-provider-integration-settings- 'Remote Provider Integration Settings') - -| Name | Description | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.integrations.enabled` | Specifies whether to enable rich integrations with any supported remote services | -| `gitlens.remotes` | Specifies custom remote services to be matched with Git remotes to detect custom domains for built-in remote services or provide support for custom remote services

Supported Types (e.g. `"type": "GitHub"`):
  • "GitHub"
  • "GitLab"
  • "Gerrit"
  • "GoogleSource"
  • "Gitea"
  • "AzureDevOps"
  • "Bitbucket"
  • "BitbucketServer"
  • "Custom"
Example:
`"gitlens.remotes": [{ "domain": "git.corporate-url.com", "type": "GitHub" }]`

Example:
`"gitlens.remotes": [{ "regex": "ssh:\/\/(my\.company\.com):1234\/git\/(.+)", "type": "GitHub" }]`

Example:
`"gitlens.remotes": [{`
    `"domain": "git.corporate-url.com",`
    `"type": "Custom",`
    `"name": "My Company",`
    `"protocol": "https",`
    `"urls": {`
        `"repository": "https://git.corporate-url.com/${repo}",`
        `"branches": "https://git.corporate-url.com/${repo}/branches",`
        `"branch": "https://git.corporate-url.com/${repo}/commits/${branch}",`
        `"commit": "https://git.corporate-url.com/${repo}/commit/${id}",`
        `"file": "https://git.corporate-url.com/${repo}?path=${file}${line}",`
        `"fileInBranch": "https://git.corporate-url.com/${repo}/blob/${branch}/${file}${line}",`
        `"fileInCommit": "https://git.corporate-url.com/${repo}/blob/${id}/${file}${line}",`
        `"fileLine": "#L${line}",`
        `"fileRange": "#L${start}-L${end}"`
        `}`
    `}]`

Example:
`"gitlens.remotes": [{`
    `"regex": "ssh:\\/\\/(my\\.company\\.com):1234\\/git\\/(.+)",`
    `"type": "Custom",`
    `"name": "My Company",`
    `"protocol": "https",`
    `"urls": {`
        `"repository": "https://my.company.com/projects/${repoBase}/repos/${repoPath}",`
        `"branches": "https://my.company.com/projects/${repoBase}/repos/${repoPath}/branches",`
        `"branch": "https://my.company.com/projects/${repoBase}/repos/${repoPath}/commits/${branch}",`
        `"commit": "https://my.company.com/projects/${repoBase}/repos/${repoPath}/commit/${id}",`
        `"file": "https://my.company.com/projects/${repoBase}/repos/${repoPath}?path=${file}${line}",`
        `"fileInBranch": "https://my.company.com/projects/${repoBase}/repos/${repoPath}/blob/${branch}/${file}${line}",`
        `"fileInCommit": "https://my.company.com/projects/${repoBase}/repos/${repoPath}/blob/${id}/${file}${line}",`
        `"fileLine": "#L${line}",`
        `"fileRange": "#L${start}-L${end}"`
        `}`
    `}]` | - -## Date & Time Settings [#](#date--time-settings- 'Date & Time Settings') - -| Name | Description | -| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.defaultDateFormat` | Specifies how absolute dates will be formatted by default. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats | -| `gitlens.defaultDateLocale` | Specifies the locale, a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag#List_of_major_primary_language_subtags), to use for date formatting, defaults to the VS Code locale. Use `system` to follow the current system locale, or choose a specific locale, e.g `en-US` — US English, `en-GB` — British English, `de-DE` — German, 'ja-JP = Japanese, etc. | -| `gitlens.defaultDateShortFormat` | Specifies how short absolute dates will be formatted by default. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats | -| `gitlens.defaultDateSource` | Specifies whether commit dates should use the authored or committed date | -| `gitlens.defaultDateStyle` | Specifies how dates will be displayed by default | -| `gitlens.defaultTimeFormat` | Specifies how times will be formatted by default. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats | - -## Menu & Toolbar Settings [#](#menu--toolbar-settings- 'Menu & Toolbar Settings') - -| Name | Description | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.menus` | Specifies which commands will be added to which menus | -| `gitlens.fileAnnotations.command` | Specifies whether the file annotations button in the editor title shows a menu or immediately toggles the specified file annotations
`null` (default) - shows a menu to choose which file annotations to toggle
`blame` - toggles file blame annotations
`heatmap` - toggles file heatmap annotations
`changes` - toggles file changes annotations | - -## Keyboard Shortcut Settings [#](#keyboard-shortcut-settings- 'Keyboard Shortcut Settings') - -| Name | Description | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `gitlens.keymap` | Specifies the keymap to use for GitLens shortcut keys

`alternate` - adds an alternate set of shortcut keys that start with `Alt` (⌥ on macOS)
`chorded` - adds a chorded set of shortcut keys that start with `Ctrl+Shift+G` (⌥⌘G on macOS)
`none` - no shortcut keys will be added | - -## Modes Settings [#](#modes-settings- 'Modes Settings') - -| Name | Description | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.mode.active` | Specifies the active GitLens mode, if any | -| `gitlens.mode.statusBar.enabled` | Specifies whether to provide the active GitLens mode in the status bar | -| `gitlens.mode.statusBar.alignment` | Specifies the active GitLens mode alignment in the status bar

`left` - aligns to the left
`right` - aligns to the right | -| `gitlens.modes` | Specifies the user-defined GitLens modes

Example — adds heatmap annotations to the _Reviewing_ mode
`"gitlens.modes": { "review": { "annotations": "heatmap" } }`

Example — adds a new _Annotating_ mode with blame annotations
`"gitlens.modes": {`
    `"annotate": {`
        `"name": "Annotating",`
        `"statusBarItemName": "Annotating",`
        `"description": "for root cause analysis",`
        `"annotations": "blame",`
        `"codeLens": false,`
        `"currentLine": false,`
        `"hovers": true`
    `}`
`}` | - -## Autolink Settings [#](#autolink-settings- 'Autolink Settings') - -| Name | Description | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `gitlens.autolinks` | Specifies autolinks to external resources in commit messages. Use `` as the variable for the reference number

Example to autolink Jira issues: (e.g. `JIRA-123 âŸļ https://jira.company.com/issue?query=123`)
`"gitlens.autolinks": [{ "prefix": "JIRA-", "url": "https://jira.company.com/issue?query=" }]` | - -## Misc Settings [#](#misc-settings- 'Misc Settings') - -| Name | Description | -| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.defaultGravatarsStyle` | Specifies the style of the gravatar default (fallback) images

`identicon` - a geometric pattern
`mp` - a simple, cartoon-style silhouetted outline of a person (does not vary by email hash)
`monsterid` - a monster with different colors, faces, etc
`retro` - 8-bit arcade-style pixelated faces
`robohash` - a robot with different colors, faces, etc
`wavatar` - a face with differing features and backgrounds | -| `gitlens.liveshare.allowGuestAccess` | Specifies whether to allow guest access to GitLens features when using Visual Studio Live Share | -| `gitlens.outputLevel` | Specifies how much (if any) output will be sent to the GitLens output channel | -| `gitlens.showWelcomeOnInstall` | Specifies whether to show the Welcome (Quick Setup) experience on first install | -| `gitlens.showWhatsNewAfterUpgrades` | Specifies whether to show the What's New notification after upgrading to new feature releases | -| `gitlens.sortBranchesBy` | Specifies how branches are sorted in quick pick menus and views | -| `gitlens.sortContributorsBy` | Specifies how contributors are sorted in quick pick menus and views | -| `gitlens.sortTagsBy` | Specifies how tags are sorted in quick pick menus and views | -| `gitlens.advanced.abbreviatedShaLength` | Specifies the length of abbreviated commit SHAs (shas) | -| `gitlens.advanced.abbreviateShaOnCopy` | Specifies whether to copy full or abbreviated commit SHAs to the clipboard. Abbreviates to the length of `gitlens.advanced.abbreviatedShaLength`.. | -| `gitlens.advanced.blame.customArguments` | Specifies additional arguments to pass to the `git blame` command | -| `gitlens.advanced.blame.delayAfterEdit` | Specifies the time (in milliseconds) to wait before re-blaming an unsaved document after an edit. Use 0 to specify an infinite wait | -| `gitlens.advanced.blame.sizeThresholdAfterEdit` | Specifies the maximum document size (in lines) allowed to be re-blamed after an edit while still unsaved. Use 0 to specify no maximum | -| `gitlens.advanced.caching.enabled` | Specifies whether git output will be cached — changing the default is not recommended | -| `gitlens.advanced.commitOrdering` | Specifies the order by which commits will be shown. If unspecified, commits will be shown in reverse chronological order

`date` - shows commits in reverse chronological order of the commit timestamp
`author-date` - shows commits in reverse chronological order of the author timestamp
`topo` - shows commits in reverse chronological order of the commit timestamp, but avoids intermixing multiple lines of history | -| `gitlens.advanced.externalDiffTool` | Specifies an optional external diff tool to use when comparing files. Must be a configured [Git difftool](https://git-scm.com/docs/git-config#Documentation/git-config.txt-difftool). | -| `gitlens.advanced.externalDirectoryDiffTool` | Specifies an optional external diff tool to use when comparing directories. Must be a configured [Git difftool](https://git-scm.com/docs/git-config#Documentation/git-config.txt-difftool). | -| `gitlens.advanced.fileHistoryFollowsRenames` | Specifies whether file histories will follow renames -- will affect how merge commits are shown in histories | -| `gitlens.advanced.fileHistoryShowAllBranches` | Specifies whether file histories will show commits from all branches | -| `gitlens.advanced.maxListItems` | Specifies the maximum number of items to show in a list. Use 0 to specify no maximum | -| `gitlens.advanced.maxSearchItems` | Specifies the maximum number of items to show in a search. Use 0 to specify no maximum | -| `gitlens.advanced.messages` | Specifies which messages should be suppressed | -| `gitlens.advanced.quickPick.closeOnFocusOut` | Specifies whether to dismiss quick pick menus when focus is lost (if not, press `ESC` to dismiss) | -| `gitlens.advanced.repositorySearchDepth` | Specifies how many folders deep to search for repositories. Defaults to `git.repositoryScanMaxDepth` | -| `gitlens.advanced.similarityThreshold` | Specifies the amount (percent) of similarity a deleted and added file pair must have to be considered a rename | -| `gitlens.strings.codeLens.unsavedChanges.recentChangeAndAuthors` | Specifies the string to be shown in place of both the _recent change_ and _authors_ CodeLens when there are unsaved changes | -| `gitlens.strings.codeLens.unsavedChanges.recentChangeOnly` | Specifies the string to be shown in place of the _recent change_ CodeLens when there are unsaved changes | -| `gitlens.strings.codeLens.unsavedChanges.authorsOnly` | Specifies the string to be shown in place of the _authors_ CodeLens when there are unsaved changes | - -## Themable Colors [#](#themable-colors- 'Themable Colors') - -GitLens defines a set of themable colors which can be provided by vscode themes or directly by the user using [`workbench.colorCustomizations`](https://code.visualstudio.com/docs/getstarted/themes#_customize-a-color-theme). - -| Name | Description | -| ------------------------------------------ | ------------------------------------------------------------------------------------- | -| `gitlens.gutterBackgroundColor` | Specifies the background color of the file blame annotations | -| `gitlens.gutterForegroundColor` | Specifies the foreground color of the file blame annotations | -| `gitlens.gutterUncommittedForegroundColor` | Specifies the foreground color of an uncommitted line in the file blame annotations | -| `gitlens.trailingLineBackgroundColor` | Specifies the background color of the trailing blame annotation | -| `gitlens.trailingLineForegroundColor` | Specifies the foreground color of the trailing blame annotation | -| `gitlens.lineHighlightBackgroundColor` | Specifies the background color of the associated line highlights in blame annotations | -| `gitlens.lineHighlightOverviewRulerColor` | Specifies the scroll bar color of the associated line highlights in blame annotations | - -# Contributors 🙏❤ - -A big thanks to the people that have contributed to this project: +A big thanks to the people that have contributed to this project đŸ™â¤ī¸: - Zeeshan Adnan ([@zeeshanadnan](https://github.com/zeeshanadnan)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=zeeshanadnan) - Alex ([@deadmeu](https://github.com/deadmeu)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=deadmeu) @@ -1142,16 +355,21 @@ A big thanks to the people that have contributed to this project: - Ash Clarke ([@ashclarke](https://github.com/ashclarke)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=ashclarke) - Travis Collins ([@TravisTX](https://github.com/TravisTX)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=TravisTX) - Matt Cooper ([@vtbassmatt](https://github.com/vtbassmatt)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=vtbassmatt) +- Skyler Dawson ([@foxwoods369](https://github.com/foxwoods369)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=foxwoods369) - Andrii Dieiev ([@IllusionMH](https://github.com/IllusionMH)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=IllusionMH) - egfx-notifications ([@egfx-notifications](https://github.com/egfx-notifications)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=egfx-notifications) - Segev Finer ([@segevfiner](https://github.com/segevfiner)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=segevfiner) - Cory Forsyth ([@bantic](https://github.com/bantic)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=bantic) - John Gee ([@shadowspawn](https://github.com/shadowspawn)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=shadowspawn) - Geoffrey ([@g3offrey](https://github.com/g3offrey)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=g3offrey) +- Omar Ghazi ([@omarfesal](https://github.com/omarfesal)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=omarfesal) +- Neil Ghosh ([@neilghosh](https://github.com/neilghosh)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=neilghosh) - Guillaume Rozan ([@grozan](https://github.com/grozan)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=grozan) - Guillem GonzÃĄlez Vela ([@guillemglez](https://github.com/guillemglez)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=guillemglez) - Vladislav Guleaev ([@vguleaev](https://github.com/vguleaev)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=vguleaev) - Dmitry Gurovich ([@yrtimiD](https://github.com/yrtimiD)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=yrtimiD) +- hahaaha ([@hahaaha](https://github.com/hahaaha)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=hahaaha) +- Victor Hallberg ([@mogelbrod](https://github.com/mogelbrod)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=mogelbrod) - Ken Hom ([@kh0m](https://github.com/kh0m)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=kh0m) - Yukai Huang ([@Yukaii](https://github.com/Yukaii)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=Yukaii) - Justin Hutchings ([@jhutchings1](https://github.com/jhutchings1)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=jhutchings1) @@ -1160,6 +378,7 @@ A big thanks to the people that have contributed to this project: - jogo- ([@jogo-](https://github.com/jogo-)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=jogo-) - Nils K ([@septatrix](https://github.com/septatrix)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=septatrix) - Chris Kaczor ([@ckaczor](https://github.com/ckaczor)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=ckaczor) +- Aidos Kanapyanov ([@aidoskanapyanov](https://github.com/aidoskanapyanov)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=aidoskanapyanov) - Allan Karlson ([@bees4ever](https://github.com/bees4ever)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=bees4ever) - Nafiur Rahman Khadem ([@ShafinKhadem](https://github.com/ShafinKhadem)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=ShafinKhadem) - Mathew King ([@MathewKing](https://github.com/MathewKing)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=MathewKing) @@ -1169,7 +388,7 @@ A big thanks to the people that have contributed to this project: - Kwok ([@mankwok](https://github.com/mankwok)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=mankwok) - Marc Lasson ([@mlasson](https://github.com/mlasson)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=mlasson) - John Letey ([@johnletey](https://github.com/johnletey)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=johnletey) -- Stanislav Lvovsky ([@slavik-lvovsky](https://github.com/slavik-lvovsky)) — [contributions]((https://github.com/gitkraken/vscode-gitlens/commits?author=slavik-lvovsky) +- Stanislav Lvovsky ([@slavik-lvovsky](https://github.com/slavik-lvovsky)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=slavik-lvovsky) - Peng Lyu ([@rebornix](https://github.com/rebornix)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=rebornix) - CÊdric Malard ([@cmalard](https://github.com/cmalard)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=cmalard) - Asif Kamran Malick ([@akmalick](https://github.com/akmalick)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=akmalick) @@ -1184,11 +403,13 @@ A big thanks to the people that have contributed to this project: - Kevin Paxton ([kpaxton](https://github.com/kpaxton)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=kpaxton) - Connor Peet ([@connor4312](https://github.com/connor4312)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=connor4312) - Maxim Pekurin ([@pmaxim25](https://github.com/pmaxim25)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=pmaxim25) -- Leo Dan PeÃąa ([@amouxaden](https://github.com/amouxaden)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=amouxaden) +- Leo Dan PeÃąa ([@leo9-py](https://github.com/leo9-py)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=leo9-py) +- Aman Prakash ([@gitgoap](https://github.com/gitgoap)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=gitgoap) - Arunprasad Rajkumar ([@arajkumar](https://github.com/arajkumar)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=arajkumar) - David Rees ([@studgeek](https://github.com/studgeek)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=studgeek) - Rickard ([@rickardp](https://github.com/rickardp)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=rickardp) - Johannes Rieken ([@jrieken](https://github.com/jrieken)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=jrieken) +- Daniel Rodríguez ([@sadasant](https://github.com/sadasant)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=sadasant) - Guillaume Rozan ([@rozangu1](https://github.com/rozangu1)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=rozangu1) - ryenus ([@ryenus](https://github.com/ryenus)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=ryenus) - Felipe Santos ([@felipecrs](https://github.com/felipecrs)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=felipecrs) @@ -1220,6 +441,14 @@ A big thanks to the people that have contributed to this project: - Yan Zhang ([@Eskibear](https://github.com/Eskibear)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=Eskibear) - Zyck ([@qzyse2017](https://github.com/qzyse2017)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=qzyse2017) - Yonatan Greenfeld ([@YonatanGreenfeld](https://github.com/YonatanGreenfeld)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=YonatanGreenfeld) +- WofWca ([@WofWca](https://github.com/WofWca)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=WofWca) +- ä¸č§æœˆ ([@nooooooom](https://github.com/nooooooom)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=nooooooom) +- Ian Chamberlain ([@ian-h-chamberlain](https://github.com/ian-h-chamberlain)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=ian-h-chamberlain) +- Brandon Cheng ([@gluxon](https://github.com/gluxon)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=gluxon) +- yutotnh ([@yutotnh](https://github.com/yutotnh)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=yutotnh) +- may ([@m4rch3n1ng](https://github.com/m4rch3n1ng)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=m4rch3n1ng) +- bm-w ([@bm-w](https://github.com/bm-w)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=bm-w) +- Tyler Johnson ([@TJohnsonSE](https://github.com/TJohnsonSE)) — [contributions](https://github.com/gitkraken/vscode-gitlens/commits?author=TJohnsonSE) Also special thanks to the people that have provided support, testing, brainstorming, etc: diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 0ceaabd780c16..74a79ca5c964f 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -3,29 +3,168 @@ GitLens THIRD-PARTY SOFTWARE NOTICES AND INFORMATION This project incorporates components from the projects listed below. -1. @microsoft/fast-element version 1.11.0 (https://github.com/Microsoft/fast) -2. @microsoft/fast-react-wrapper version 0.3.16-0 (https://github.com/Microsoft/fast) -3. @octokit/core version 4.2.0 (https://github.com/octokit/core.js) -4. @opentelemetry/api version 1.4.0 (https://github.com/open-telemetry/opentelemetry-js) -5. @opentelemetry/exporter-trace-otlp-http version 0.35.1 (https://github.com/open-telemetry/opentelemetry-js) -6. @opentelemetry/sdk-trace-base version 1.9.1 (https://github.com/open-telemetry/opentelemetry-js) -7. @vscode/codicons version 0.0.32 (https://github.com/microsoft/vscode-codicons) -8. @vscode/webview-ui-toolkit version 1.2.1 (https://github.com/microsoft/vscode-webview-ui-toolkit) -9. ansi-regex version 6.0.1 (https://github.com/chalk/ansi-regex) -10. billboard.js version 3.7.4 (https://github.com/naver/billboard.js) -11. https-proxy-agent version 5.0.1 (https://github.com/TooTallNate/node-https-proxy-agent) -12. iconv-lite version 0.6.3 (https://github.com/ashtuchkin/iconv-lite) -13. lit version 2.6.1 (https://github.com/lit/lit) -14. lodash-es version 4.17.21 (https://github.com/lodash/lodash) -15. microsoft/vscode (https://github.com/microsoft/vscode) -16. node-fetch version 2.6.9 (https://github.com/bitinn/node-fetch) -17. os-browserify version 0.3.0 (https://github.com/CoderPuppy/os-browserify) -18. path-browserify version 1.0.1 (https://github.com/browserify/path-browserify) -19. react-dom version 16.8.4 (https://github.com/facebook/react) -20. react version 16.8.4 (https://github.com/facebook/react) -21. sindresorhus/is-fullwidth-code-point (https://github.com/sindresorhus/is-fullwidth-code-point) -22. sindresorhus/string-width (https://github.com/sindresorhus/string-width) -23. sortablejs version 1.15.0 (https://github.com/SortableJS/Sortable) +1. @gk-nzaytsev/fast-string-truncated-width version 1.1.0 (https://github.com/nzaytsev/fast-string-truncated-width) +2. @lit/context version 1.1.2 (https://github.com/lit/lit) +3. @lit/react version 1.0.5 (https://github.com/lit/lit) +4. @lit/task version 1.0.1 (https://github.com/lit/lit) +5. @microsoft/fast-element version 1.13.0 (https://github.com/Microsoft/fast) +6. @microsoft/fast-foundation version 2.49.6 (https://github.com/Microsoft/fast) +7. @microsoft/fast-react-wrapper version 0.3.24 (https://github.com/Microsoft/fast) +8. @octokit/graphql version 8.1.1 (https://github.com/octokit/graphql.js) +9. @octokit/request-error version 6.1.4 (https://github.com/octokit/request-error.js) +10. @octokit/request version 9.1.3 (https://github.com/octokit/request.js) +11. @octokit/types version 13.5.0 (https://github.com/octokit/types.ts) +12. @opentelemetry/api version 1.9.0 (https://github.com/open-telemetry/opentelemetry-js) +13. @opentelemetry/exporter-trace-otlp-http version 0.53.0 (https://github.com/open-telemetry/opentelemetry-js) +14. @opentelemetry/resources version 1.26.0 (https://github.com/open-telemetry/opentelemetry-js) +15. @opentelemetry/sdk-trace-base version 1.26.0 (https://github.com/open-telemetry/opentelemetry-js) +16. @opentelemetry/semantic-conventions version 1.27.0 (https://github.com/open-telemetry/opentelemetry-js) +17. @shoelace-style/shoelace version 2.17.1 (https://github.com/shoelace-style/shoelace) +18. @vscode/codicons version 0.0.36 (https://github.com/microsoft/vscode-codicons) +19. @vscode/webview-ui-toolkit version 1.4.0 (https://github.com/microsoft/vscode-webview-ui-toolkit) +20. ansi-regex version 6.1.0 (https://github.com/chalk/ansi-regex) +21. billboard.js version 3.13.0 (https://github.com/naver/billboard.js) +22. https-proxy-agent version 5.0.1 (https://github.com/TooTallNate/node-https-proxy-agent) +23. iconv-lite version 0.6.3 (https://github.com/ashtuchkin/iconv-lite) +24. lit version 3.2.0 (https://github.com/lit/lit) +25. marked version 14.1.2 (https://github.com/markedjs/marked) +26. microsoft/vscode (https://github.com/microsoft/vscode) +27. node-fetch version 2.7.0 (https://github.com/bitinn/node-fetch) +28. os-browserify version 0.3.0 (https://github.com/CoderPuppy/os-browserify) +29. path-browserify version 1.0.1 (https://github.com/browserify/path-browserify) +30. react-dom version 16.8.4 (https://github.com/facebook/react) +31. react version 16.8.4 (https://github.com/facebook/react) +32. sindresorhus/is-fullwidth-code-point (https://github.com/sindresorhus/is-fullwidth-code-point) +33. sindresorhus/string-width (https://github.com/sindresorhus/string-width) +34. sortablejs version 1.15.0 (https://github.com/SortableJS/Sortable) +35. tslib version 2.7.0 (https://github.com/Microsoft/tslib) + +%% @gk-nzaytsev/fast-string-truncated-width NOTICES AND INFORMATION BEGIN HERE +========================================= +The MIT License (MIT) + +Copyright (c) 2024-present Fabio Spampinato + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +========================================= +END OF @gk-nzaytsev/fast-string-truncated-width NOTICES AND INFORMATION + +%% @lit/context NOTICES AND INFORMATION BEGIN HERE +========================================= +BSD 3-Clause License + +Copyright (c) 2021 Google LLC. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +========================================= +END OF @lit/context NOTICES AND INFORMATION + +%% @lit/react NOTICES AND INFORMATION BEGIN HERE +========================================= +BSD 3-Clause License + +Copyright (c) 2017 Google LLC. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +========================================= +END OF @lit/react NOTICES AND INFORMATION + +%% @lit/task NOTICES AND INFORMATION BEGIN HERE +========================================= +BSD 3-Clause License + +Copyright (c) 2017 Google LLC. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +========================================= +END OF @lit/task NOTICES AND INFORMATION %% @microsoft/fast-element NOTICES AND INFORMATION BEGIN HERE ========================================= @@ -78,88 +217,632 @@ A pre-bundled script that contains all APIs needed to build web components with ``` -The markup above always references the latest release. When deploying to production, you will want to ship with a specific version. Here's an example of the markup for that: +The markup above always references the latest release. When deploying to production, you will want to ship with a specific version. Here's an example of the markup for that: + +```html + +``` + +:::note +For simplicity, examples throughout the documentation will assume the library has been installed from NPM, but you can always replace the import location with the CDN URL. +::: + +:::tip +Looking for a quick guide on building components? Check out [our Cheat Sheet](../resources/cheat-sheet.md#building-components). +::: +========================================= +END OF @microsoft/fast-element NOTICES AND INFORMATION + +%% @microsoft/fast-foundation NOTICES AND INFORMATION BEGIN HERE +========================================= +# FAST Foundation + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![npm version](https://badge.fury.io/js/%40microsoft%2Ffast-foundation.svg)](https://badge.fury.io/js/%40microsoft%2Ffast-foundation) + +The `fast-foundation` package is a library of Web Component classes, templates, and other utilities intended to be composed into registered Web Components by design systems (e.g. Fluent Design, Material Design, etc.). The exports of this package can generally be thought of as un-styled base components that implement semantic and accessible markup and behavior. + +This package does not export Web Components registered as [custom elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements) - it exports parts and pieces intended to be *composed* into Web Components, allowing you to implement your own design language by simply applying CSS styles and behaviors without having to write all the JavaScript that's involved in building production-quality component implementations. + +## Installation + +### From NPM + +To install the `fast-foundation` library, use either `npm` or `yarn` as follows: + +```shell +npm install --save @microsoft/fast-foundation +``` + +```shell +yarn add @microsoft/fast-foundation +``` + +Within your JavaScript or TypeScript code, you can then import library APIs like this: + +```ts +import { Anchor } from '@microsoft/fast-foundation'; +``` + +Looking for a setup that integrates with a particular front-end framework or bundler? Check out [our integration docs](https://fast.design/docs/integrations/introduction). + +### From CDN + +A pre-bundled script that contains all APIs needed to use FAST Foundation is available on CDN. You can use this script by adding [`type="module"`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) to the script element and then importing from the CDN. + +```html + + + + + + + +``` + +The markup above always references the latest release. When deploying to production, you will want to ship with a specific version. Here's an example of the markup for that: + +```html + +``` + +:::note +For simplicity, examples throughout the documentation will assume the library has been installed from NPM, but you can always replace the import location with the CDN URL. +::: +========================================= +END OF @microsoft/fast-foundation NOTICES AND INFORMATION + +%% @microsoft/fast-react-wrapper NOTICES AND INFORMATION BEGIN HERE +========================================= +# FAST React Wrapper + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![npm version](https://badge.fury.io/js/%40microsoft%2Ffast-react-wrapper.svg)](https://badge.fury.io/js/%40microsoft%2Ffast-react-wrapper) + +The `fast-react-wrapper` package contains a utility that enables automatically wrapping Web Components in a React component for ease of integration into React projects. + +## Installation + +### From NPM + +To install the `fast-react-wrapper` library, use either `npm` or `yarn` as follows: + +```shell +npm install --save @microsoft/fast-react-wrapper +``` + +```shell +yarn add @microsoft/fast-react-wrapper +``` + +Within your JavaScript or TypeScript code, you can then and use the wrapper like this: + +```ts +import React from 'react'; +import { provideReactWrapper } from '@microsoft/fast-react-wrapper'; + +const { wrap } = provideReactWrapper(React); + +const MyComponent = wrap(MyComponent); +``` + +For additional wrapper settings and more information on integrating with Design Systems, see [our integration docs](https://fast.design/docs/integrations/react). +========================================= +END OF @microsoft/fast-react-wrapper NOTICES AND INFORMATION + +%% @octokit/graphql NOTICES AND INFORMATION BEGIN HERE +========================================= +The MIT License + +Copyright (c) 2018 Octokit contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +========================================= +END OF @octokit/graphql NOTICES AND INFORMATION + +%% @octokit/request-error NOTICES AND INFORMATION BEGIN HERE +========================================= +The MIT License + +Copyright (c) 2019 Octokit contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +========================================= +END OF @octokit/request-error NOTICES AND INFORMATION + +%% @octokit/request NOTICES AND INFORMATION BEGIN HERE +========================================= +The MIT License + +Copyright (c) 2018 Octokit contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +========================================= +END OF @octokit/request NOTICES AND INFORMATION + +%% @octokit/types NOTICES AND INFORMATION BEGIN HERE +========================================= +MIT License Copyright (c) 2019 Octokit contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +========================================= +END OF @octokit/types NOTICES AND INFORMATION + +%% @opentelemetry/api NOTICES AND INFORMATION BEGIN HERE +========================================= + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +========================================= +END OF @opentelemetry/api NOTICES AND INFORMATION + +%% @opentelemetry/exporter-trace-otlp-http NOTICES AND INFORMATION BEGIN HERE +========================================= + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. -```html - -``` + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." -:::note -For simplicity, examples throughout the documentation will assume the library has been installed from NPM, but you can always replace the import location with the CDN URL. -::: + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. -:::tip -Looking for a quick guide on building components? Check out [our Cheat Sheet](../resources/cheat-sheet.md#building-components). -::: -========================================= -END OF @microsoft/fast-element NOTICES AND INFORMATION + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. -%% @microsoft/fast-react-wrapper NOTICES AND INFORMATION BEGIN HERE -========================================= -# FAST React Wrapper + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![npm version](https://badge.fury.io/js/%40microsoft%2Ffast-react-wrapper.svg)](https://badge.fury.io/js/%40microsoft%2Ffast-react-wrapper) + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: -The `fast-react-wrapper` package contains a utility that enables automatically wrapping Web Components in a React component for ease of integration into React projects. + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and -## Installation + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and -### From NPM + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and -To install the `fast-react-wrapper` library, use either `npm` or `yarn` as follows: + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. -```shell -npm install --save @microsoft/fast-react-wrapper -``` + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. -```shell -yarn add @microsoft/fast-react-wrapper -``` + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. -Within your JavaScript or TypeScript code, you can then and use the wrapper like this: + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. -```ts -import React from 'react'; -import { provideReactWrapper } from '@microsoft/fast-react-wrapper'; + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. -const { wrap } = provideReactWrapper(React); + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. -const MyComponent = wrap(MyComponent); -``` + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. -For additional wrapper settings and more information on integrating with Design Systems, see [our integration docs](https://fast.design/docs/integrations/react). -========================================= -END OF @microsoft/fast-react-wrapper NOTICES AND INFORMATION + END OF TERMS AND CONDITIONS -%% @octokit/core NOTICES AND INFORMATION BEGIN HERE -========================================= -The MIT License + APPENDIX: How to apply the Apache License to your work. -Copyright (c) 2019 Octokit contributors + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + Copyright [yyyy] [name of copyright owner] -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ========================================= -END OF @octokit/core NOTICES AND INFORMATION +END OF @opentelemetry/exporter-trace-otlp-http NOTICES AND INFORMATION -%% @opentelemetry/api NOTICES AND INFORMATION BEGIN HERE +%% @opentelemetry/resources NOTICES AND INFORMATION BEGIN HERE ========================================= Apache License Version 2.0, January 2004 @@ -364,9 +1047,9 @@ END OF @octokit/core NOTICES AND INFORMATION limitations under the License. ========================================= -END OF @opentelemetry/api NOTICES AND INFORMATION +END OF @opentelemetry/resources NOTICES AND INFORMATION -%% @opentelemetry/exporter-trace-otlp-http NOTICES AND INFORMATION BEGIN HERE +%% @opentelemetry/sdk-trace-base NOTICES AND INFORMATION BEGIN HERE ========================================= Apache License Version 2.0, January 2004 @@ -571,9 +1254,9 @@ END OF @opentelemetry/api NOTICES AND INFORMATION limitations under the License. ========================================= -END OF @opentelemetry/exporter-trace-otlp-http NOTICES AND INFORMATION +END OF @opentelemetry/sdk-trace-base NOTICES AND INFORMATION -%% @opentelemetry/sdk-trace-base NOTICES AND INFORMATION BEGIN HERE +%% @opentelemetry/semantic-conventions NOTICES AND INFORMATION BEGIN HERE ========================================= Apache License Version 2.0, January 2004 @@ -778,7 +1461,20 @@ END OF @opentelemetry/exporter-trace-otlp-http NOTICES AND INFORMATION limitations under the License. ========================================= -END OF @opentelemetry/sdk-trace-base NOTICES AND INFORMATION +END OF @opentelemetry/semantic-conventions NOTICES AND INFORMATION + +%% @shoelace-style/shoelace NOTICES AND INFORMATION BEGIN HERE +========================================= +Copyright (c) 2020 A Beautiful Site, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +========================================= +END OF @shoelace-style/shoelace NOTICES AND INFORMATION %% @vscode/codicons NOTICES AND INFORMATION BEGIN HERE ========================================= @@ -1451,58 +2147,55 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ========================================= END OF lit NOTICES AND INFORMATION -%% lodash-es NOTICES AND INFORMATION BEGIN HERE +%% marked NOTICES AND INFORMATION BEGIN HERE ========================================= -Copyright OpenJS Foundation and other contributors +# License information -Based on Underscore.js, copyright Jeremy Ashkenas, -DocumentCloud and Investigative Reporters & Editors +## Contribution License Agreement -This software consists of voluntary contributions made by many -individuals. For exact contribution history, see the revision history -available at https://github.com/lodash/lodash +If you contribute code to this project, you are implicitly allowing your code +to be distributed under the MIT license. You are also implicitly verifying that +all code is your original work. `` -The following license applies to all parts of this software except as -documented below: +## Marked -==== +Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) +Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. -==== +## Markdown -Copyright and related rights for sample code are waived via CC0. Sample -code is defined as all source code displayed within the prose of the -documentation. +Copyright Š 2004, John Gruber +http://daringfireball.net/ +All rights reserved. -CC0: http://creativecommons.org/publicdomain/zero/1.0/ +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: -==== +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +* Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. -Files located in the node_modules and vendor directories are externally -maintained libraries used by this software which have their own -licenses; we recommend you read them, as their terms may differ from the -terms above. +This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage. ========================================= -END OF lodash-es NOTICES AND INFORMATION +END OF marked NOTICES AND INFORMATION %% microsoft/vscode NOTICES AND INFORMATION BEGIN HERE ========================================= @@ -1721,4 +2414,21 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ========================================= -END OF sortablejs NOTICES AND INFORMATION \ No newline at end of file +END OF sortablejs NOTICES AND INFORMATION + +%% tslib NOTICES AND INFORMATION BEGIN HERE +========================================= +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +========================================= +END OF tslib NOTICES AND INFORMATION \ No newline at end of file diff --git a/docs/links.md b/docs/links.md new file mode 100644 index 0000000000000..ca6adae2ba476 --- /dev/null +++ b/docs/links.md @@ -0,0 +1,251 @@ +# Supported Links into GitLens + +This document covers the various VSCode link formats that work with GitLens, along with their formats, parameters and descriptions. + +## Contents + +### Repository Items + +- [Repository](#repository 'Jump to Repository') +- [Branch](#branch 'Jump to Branch') +- [Commit](#commit 'Jump to Commit') +- [Tag](#tag 'Jump to Tag') +- [Comparison](#comparison 'Jump to Comparison') +- [File/Lines](#filelines 'Jump to File/Lines') + +### GitKraken Cloud Items + +- [Cloud Patch/Code Suggestion](#cloud-patchcode-suggestion 'Jump to Cloud Patch/Code Suggestion') +- [Cloud Workspace](#cloud-workspace 'Jump to Cloud Workspace') + +### GitKraken Account Links + +- [Login](#login 'Jump to Login') + +## Notation + +The following are used in link notation in this document: + +- _[option1|option2]_ notation means that either _option1_ or _option2_ can be used in the deep link. The actual deep link does not include the _[]_ or the _|_ symbols. + +- _(contents)_ notation means that the _contents_ within the _()_ are optional in the deep link. The actual deep link does not include the _()_ symbols if the contents are included. + +- _{reference}_ is a short-form reference to some content previously defined in the document. For example, if we define _branchLink_ as _b_ and _prefix_ as _vscode://eamodio.gitlens/link_, then the notation _{prefix}/{branchLink}_ is short-form for _vscode://eamodio.gitlens/link/b_. The reference name and _{}_ should not be included in the link. + +## Repository Item Deep Links + +### Common References + +- _{prefix}_ = _vscode://eamodio.gitlens/link_ + +- _{remoteUrl}_ is the pull URL of a git remote, including the .git part. You can see this url when, for example, choosing “Clone” on the repo’s/remote’s page in GitHub. + +- _{repoPath}_ is the local disk path to a git remote on the link creator’s machine. + +- _{repoId}_ is the first commit SHA of the repo (the full SHA, not just the short version). This field is not required to locate the repo (remote url or repo path can be used, one of which should also be on the link). If it is not known, the value should be set to _-_. + +- _{baseQuery}_ = _[url={remoteUrl}|path={repoPath}]_ + +### Notes + +- **Repository Matching**: To find a matching repository in repo item deep links, we first check the list of GitLens' known/open repositories in state. We use the repo’s disk path first, if provided, and then the remote URL, if provided, and then the repo ID (first commit SHA), if provided, to find a match within this list. If no matches are found, we check the shared GK folder on the user’s machine to match against the remote URL provided. This shared folder contains a mapping of remote URL to disk path on machine. If matches are found there, we offer the user the option to open one of those matching repos in a prompt. + +- **Remote URL**: Make sure you set a remote url on the deep link in which the target the link is pointing to exists. + +### Repository + +#### Description + +Used to open a (remote) repository in GitLens. Once the repository is opened, the Commit Graph will open to that repository. + +#### Format + +_{prefix}/r/{repoId}?{baseQuery}_ + +#### Example Usage + +Right click a remote in the Remotes view and choose "Share -> Copy Link to Repository", and share with a teammate. When they access the link, GitLens will help them clone the repository, open it in GitLens, add the remote you linked, and open the Commit Graph to the repository, step-by-step. + +### Branch + +#### Description + +Used to open a (remote) branch in GitLens. Once the branch's repository is opened, GitLens will open the Commit Graph to that repository and select the branch, or perform another action if specified (see _{action}_ in _References_ below). + +#### Format + +_{prefix}/r/{repoId}/b/{branchName}?{baseQuery}(&action={action})_ + +#### References + +- _{branchName}_ is the name of the branch. Note that the remote name should not be included. Instead, _{remoteUrl}_ is used to determine the remote for the branch. So if the branch _test_ is located on _origin_, for example, _{branchName}_ should just be _test_ and the remote url of _origin_ should be used for the _{remoteUrl}_ parameter. You should not set _{branchName}_ to _origin/test_ in this example. + +- _{action}_ is an optional query parameter that represents the action to take on the branch target. By default, the action on all repository item deep links, including branch deep links, is to open the commit graph and select the row pertaining to the item. This parameter allows the link to complete other actions instead: + + - _switch_: Switch to the branch (with options to checkout, create a new local branch if desired, or create/open a worktree). + + - _switch-to-pr_: Does everything that the _switch_ action does, but also opens the inspect overview, which contains details about pull requests related to the branch. + + - _switch-to-and-suggest-pr_: Does everything that the _switch-to-pr_ action does, but also opens the form to submit a new code suggestion. + + - _switch-to-pr-worktree_: Does everything that the _switch-to-pr_ action does, but always chooses to open the branch in a worktree, creating a new one if needed and creating a new local branch if needed. For creating the local branch and worktree, default options are chosen. The worktree is then opened in a new window. + +#### Example Usage + +Right click a branch in the Commit Graph and choose "Share -> Copy Link to Branch", select a remote to copy the branch for, and share with a teammate. When they access the link, GitLens will help them clone the repository, open it in GitLens, add the remote you linked, and open the Commit Graph to the branch, step-by-step. + +### Commit + +#### Description + +Used to open a (remote) commit in GitLens. Once the commit's repository is opened, GitLens will open the Commit Graph to that repository and select the commit. + +#### Format + +_{prefix}/r/{repoId}/c/{commitSha}?{baseQuery}_ + +#### References + +- _{commitSha}_ is the full SHA of the commit. + +#### Example Usage + +Right click a commit in the Commit Graph and choose "Share -> Copy Link to Commit", select a remote to copy the commit for, and share with a teammate. When they access the link, GitLens will help them clone the repository, open it in GitLens, add the remote you linked, and open the Commit Graph to the commit, step-by-step. + +### Tag + +#### Description + +Used to open a (remote) tag in GitLens. Once the tag's repository is opened, GitLens will open the Commit Graph to that repository and select the tag. + +#### Format + +_{prefix}/r/{repoId}/t/{tagName}?{baseQuery}_ + +#### References + +- _{tagName}_ is the name of the tag. Note that the remote name should not be included. Instead, _{remoteUrl}_ is used to determine the remote for the tag. So if the tag _15.2.0_ is located on _origin_, for example, _{tagName}_ should just be _15.2.0_ and the remote url of _origin_ should be used for the _{remoteUrl}_ parameter. You should not set _{tagName}_ to _origin/15.2.0_ in this example. + +#### Example Usage + +Right click a tag in the Tags View and choose "Share -> Copy Link to Tag", select a remote to copy the tag for, and share with a teammate. When they access the link, GitLens will help them clone the repository, open it in GitLens, add the remote you linked, and open the Commit Graph to the tag, step-by-step. + +### Comparison + +#### Description + +Used to open a comparison between two references in the _Search & Compare_ view. + +#### Format + +_{prefix}/r/{repoId}/compare/{ref1}[..|...]{ref2}?{baseQuery}(&prRepoUrl={prRepoUrl})_ + +#### References + +- _{ref1}_ and _{ref2}_ are the two refs to compare, in reverse order i.e. GitLens will compare _{ref2}_ to _{ref1}_ in the _Search & Compare_ view. These refs can be a branch name, tag name, or commit SHA. A blank ref means “working tree”. Both refs cannot be blank. + +- _{prRepoUrl}_ is an optional parameter, generally used for Pull Request comparisons, representing the pull URL of the git remote that represents the head commit of the Pull Request. It is formatted similar to _{remoteUrl}_, so see Common References section above to learn how to format it. + +#### Example Usage + +Share the changes of a pull request by right clicking the pull request's branch in the Commit Graph and choose "Compare with Common Base". The changes should be opened in the _Search & Compare_ view. Right click the Comparison item in the _Search & Compare_ view and choose "Share -> Copy Link to Comparison", and choose a remote to copy the comparison for. Share the link with a teammate. When they access the link, GitLens will help them clone the repository, open it in GitLens, add the remote, and open the _Search & Compare_ view to the pull request's changes, step-by-step. + +### File/Lines + +#### Description + +Used to open a repository to a file, or specific lines of code within a file. Can open a file at a particular reference point or target the working copy of the file. Will highlight the specified lines of code if included. + +#### Format + +_{prefix}/r/{repoId}/f/{filePath}?{baseQuery}(&lines={lines})(&ref={ref})_ + +#### References + +- _{filePath}_ is the path to the file relative to the root of the repo (such as _src/stuff.md_). + +- _{lines}_ is an optional parameter representing the lines of code, and can be a single number or two separated by a dash. + +- _{ref}_ is an optional parameter representing the ref at which the file is referenced. Can be a branch name or tag name, fully qualified ref like _refs/tags/â€Ļ_ or a commit SHA/revision. If this is not supplied, the link points to the working version of the file. + +#### Example Usage + +To guide a teammate to a specific line of code, right click the line in the editor and choose "Share -> Copy Link to Code". Then choose which remote to target. If you prefer to target a block of code or multiple lines, highlight the lines and then right click the highlighted code and choose "Copy As -> Copy Link to Code". Then share the link with a teammate. When they access the link, GitLens will help them clone the repository, open it in GitLens, add the remote, and open the file to the specified lines of code, step-by-step. + +## GitKraken Cloud Item Deep Links + +### Common References + +- _{prefix}_ = _vscode://eamodio.gitlens/link_ + +### Notes + +- Accessing these deep links requires a GitKraken account. + +### Cloud Patch/Code Suggestion + +#### Description + +Used to open a cloud patch or code suggestion in GitLens. + +#### Format + +_{prefix}/drafts/{draftId}(?patch={patchId})(&type=suggested_pr_change&prEntityId={prEntityId})_ + +#### References + +- _{draftId}_ is the ID of the cloud patch. + +- _{patchId}_ is an optional query parameter used to access a specific revision/patch within the cloud patch. If not set, the most recent is used. + +- _type=suggested_pr_change&prEntityId={prEntityId}_ should be included in the query for deep links to code suggestions. These parameters should not be included for standard cloud patch links. + + - _{prEntityId}_ refers to the GK entity identifier for the Pull Request related to the code suggestion. + +#### Example Usage + +When you create a cloud patch from the _Cloud Patches_ view, you will receive a notification that the cloud patch has been successfully created. On that notification is a "Copy Link" button. Click it to copy a link to the cloud patch to your clipboard. Share the link with a teammate (ensure that the teammate has access based on the permissions/visibility you set for the cloud patch). When they access the link, GitLens will help them open the cloud patch in GitLens and view the patch changes, and even apply the changes to their local repository. + +### Cloud Workspace + +#### Description + +Used to open a cloud workspace in GitLens. + +#### Format + +_{prefix}/workspace/{workspaceId}_ + +#### References + +- _{workspaceId}_ is the ID of the cloud workspace. + +#### Example Usage + +Right click a cloud workspace in the _GK Workspaces_ view and choose "Share -> Copy Link to Workspace". Use this link to open the view to the chosen workspace in GitLens. + +## GitKraken Account Links + +### Login + +#### Description + +Used to log in to a GitKraken account from GitLens. + +#### Format + +_vscode://eamodio.gitlens/login?code={code}(&state={state})(&context={context})_ + +#### References + +- _{code}_ is an exchange code used to authenticate the user with GitKraken’s API. + +- _{state}_ is an optional parameter representing the state used to retrieve the code, if applicable. If a state was used to retrieve the code, it must be included in the link or the login will fail. + +- _{context}_ is an optional parameter representing the context of the login. Currently supported values include: + + - _start_trial_ - Log in to start a Pro trial. + +#### Example Usage + +External sources, such as GitKraken web pages, can use these links internally to get you into GitLens and logged in to your GitKraken account. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000000..00e93c1c9c817 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,463 @@ +// @ts-check +import globals from 'globals'; +import js from '@eslint/js'; +import ts from 'typescript-eslint'; +import antiTrojanSource from 'eslint-plugin-anti-trojan-source'; +import importX from 'eslint-plugin-import-x'; +import { configs as litConfigs } from 'eslint-plugin-lit'; +import { configs as wcConfigs } from 'eslint-plugin-wc'; + +export default ts.config( + js.configs.recommended, + ...ts.configs.strictTypeChecked, + { + ignores: ['*', '*/', '!src/', '!tests/', 'src/@types/', 'src/test/**/*'], + }, + { + linterOptions: { + reportUnusedDisableDirectives: true, + }, + plugins: { + 'import-x': importX, + 'anti-trojan-source': antiTrojanSource, + }, + rules: { + 'anti-trojan-source/no-bidi': 'error', + 'no-constant-condition': ['warn', { checkLoops: false }], + 'no-constant-binary-expression': 'error', + 'no-caller': 'error', + 'no-debugger': 'off', + 'no-else-return': 'warn', + 'no-empty': ['warn', { allowEmptyCatch: true }], + 'no-eval': 'error', + 'no-ex-assign': 'warn', + 'no-extend-native': 'error', + 'no-extra-bind': 'error', + 'no-extra-semi': 'off', + 'no-floating-decimal': 'error', + 'no-implicit-coercion': 'error', + 'no-implied-eval': 'error', + 'no-inner-declarations': 'off', + 'no-lone-blocks': 'error', + 'no-lonely-if': 'error', + 'no-loop-func': 'error', + 'no-mixed-spaces-and-tabs': 'off', + 'no-restricted-globals': ['error', 'process'], + 'no-restricted-imports': 'off', + 'no-return-assign': 'error', + 'no-return-await': 'warn', + 'no-self-compare': 'error', + 'no-sequences': 'error', + 'no-template-curly-in-string': 'warn', + 'no-throw-literal': 'error', + 'no-unmodified-loop-condition': 'warn', + 'no-unneeded-ternary': 'error', + 'no-unused-expressions': 'error', + 'no-use-before-define': 'off', + 'no-useless-call': 'error', + 'no-useless-catch': 'error', + 'no-useless-computed-key': 'error', + 'no-useless-concat': 'error', + 'no-useless-rename': 'error', + 'no-useless-return': 'error', + 'no-var': 'error', + 'no-with': 'error', + 'object-shorthand': ['error', 'never'], + 'one-var': ['error', 'never'], + 'prefer-arrow-callback': 'error', + 'prefer-const': ['error', { destructuring: 'all', ignoreReadBeforeAssign: true }], + 'prefer-numeric-literals': 'error', + 'prefer-object-spread': 'error', + 'prefer-promise-reject-errors': 'off', + 'prefer-rest-params': 'error', + 'prefer-spread': 'error', + 'prefer-template': 'error', + 'require-atomic-updates': 'off', + 'sort-imports': [ + 'error', + { + ignoreCase: true, + ignoreDeclarationSort: true, + ignoreMemberSort: false, + memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], + }, + ], + yoda: 'error', + 'import-x/consistent-type-specifier-style': ['error', 'prefer-top-level'], + 'import-x/default': 'off', + 'import-x/extensions': 'off', + 'import-x/named': 'off', + 'import-x/namespace': 'off', + 'import-x/newline-after-import': 'warn', + 'import-x/no-absolute-path': 'error', + 'import-x/no-cycle': 'off', + 'import-x/no-deprecated': 'off', + 'import-x/no-default-export': 'error', + 'import-x/no-duplicates': ['error', { 'prefer-inline': false }], + 'import-x/no-dynamic-require': 'error', + 'import-x/no-named-as-default': 'off', + 'import-x/no-named-as-default-member': 'off', + 'import-x/no-self-import': 'error', + 'import-x/no-unused-modules': 'off', + 'import-x/no-unresolved': 'off', + 'import-x/no-useless-path-segments': 'error', + 'import-x/order': [ + 'warn', + { + alphabetize: { + order: 'asc', + orderImportKind: 'asc', + caseInsensitive: true, + }, + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + 'newlines-between': 'never', + }, + ], + '@typescript-eslint/consistent-type-assertions': [ + 'error', + { + assertionStyle: 'as', + objectLiteralTypeAssertions: 'allow-as-parameter', + }, + ], + '@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }], + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'variable', + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + filter: { + regex: '^_$', + match: false, + }, + }, + { + selector: 'variableLike', + format: ['camelCase'], + leadingUnderscore: 'allow', + filter: { + regex: '^_$', + match: false, + }, + }, + { + selector: 'memberLike', + modifiers: ['private'], + format: ['camelCase'], + leadingUnderscore: 'allow', + }, + { + selector: 'memberLike', + modifiers: ['private', 'readonly'], + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + }, + { + selector: 'memberLike', + modifiers: ['static', 'readonly'], + format: ['camelCase', 'PascalCase'], + }, + { + selector: 'interface', + format: ['PascalCase'], + custom: { + regex: '^I[A-Z]', + match: false, + }, + }, + ], + '@typescript-eslint/no-confusing-void-expression': [ + 'error', + { ignoreArrowShorthand: true, ignoreVoidOperator: true }, + ], + '@typescript-eslint/no-duplicate-type-constituents': 'off', + '@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-inferrable-types': ['warn', { ignoreParameters: true, ignoreProperties: true }], + '@typescript-eslint/no-invalid-void-type': 'off', + '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }], + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-redundant-type-constituents': 'off', + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + paths: [ + 'assert', + 'buffer', + 'child_process', + 'cluster', + 'crypto', + 'dgram', + 'dns', + 'domain', + 'events', + 'freelist', + 'fs', + 'http', + 'https', + 'module', + 'net', + 'os', + 'path', + 'process', + 'punycode', + 'querystring', + 'readline', + 'repl', + 'smalloc', + 'stream', + 'string_decoder', + 'sys', + 'timers', + 'tls', + 'tracing', + 'tty', + 'url', + 'util', + 'vm', + 'zlib', + ], + patterns: [ + { + group: ['**/env/**/*'], + message: 'Use @env/ instead', + }, + { + group: ['src/*'], + message: 'Use relative paths instead', + }, + { + group: ['react-dom'], + importNames: ['Container'], + message: 'Use our Container instead', + }, + { + group: ['vscode'], + importNames: ['CancellationError'], + message: 'Use our CancellationError instead', + }, + ], + }, + ], + '@typescript-eslint/no-unnecessary-condition': 'off', + '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off', + '@typescript-eslint/no-unnecessary-type-parameters': 'off', // https://github.com/typescript-eslint/typescript-eslint/issues/9705 + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-enum-comparison': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unused-expressions': ['warn', { allowShortCircuit: true }], + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + args: 'all', + argsIgnorePattern: '^_', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + '@typescript-eslint/no-use-before-define': ['error', { functions: false, classes: false }], + '@typescript-eslint/prefer-for-of': 'warn', + '@typescript-eslint/prefer-includes': 'warn', + '@typescript-eslint/prefer-literal-enum-member': ['warn', { allowBitwiseExpressions: true }], + '@typescript-eslint/prefer-optional-chain': 'warn', + '@typescript-eslint/prefer-promise-reject-errors': ['error', { allowEmptyReject: true }], + '@typescript-eslint/prefer-reduce-type-parameter': 'warn', + '@typescript-eslint/restrict-template-expressions': [ + 'error', + { allowAny: true, allowBoolean: true, allowNumber: true, allowNullish: true }, + ], + '@typescript-eslint/unbound-method': 'off', + '@typescript-eslint/unified-signatures': ['error', { ignoreDifferentlyNamedParameters: true }], + }, + settings: { + 'import-x/extensions': ['.ts', '.tsx'], + 'import-x/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + 'import-x/resolver': { + typescript: { + alwaysTryTypes: true, + }, + }, + }, + }, + { + name: 'extension:node', + files: ['src/**/*'], + ignores: ['src/webviews/apps/**/*', 'src/env/browser/**/*'], + languageOptions: { + globals: { + ...globals.node, + }, + parser: ts.parser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + ecmaFeatures: { + impliedStrict: true, + }, + projectService: true, + }, + }, + }, + { + files: ['src/env/node/**/*'], + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['src/*'], + message: 'Use relative paths instead', + }, + { + group: ['react-dom'], + importNames: ['Container'], + message: 'Use our Container instead', + }, + { + group: ['vscode'], + importNames: ['CancellationError'], + message: 'Use our CancellationError instead', + }, + ], + }, + ], + }, + }, + { + name: 'extension:browser', + files: ['src/**/*'], + ignores: ['src/webviews/apps/**/*', 'src/env/node/**/*'], + languageOptions: { + globals: { + ...globals.worker, + }, + parser: ts.parser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + ecmaFeatures: { + impliedStrict: true, + }, + projectService: true, + }, + }, + }, + { + name: 'webviews', + ...litConfigs['flat/recommended'], + ...wcConfigs['flat/recommended'], + // Keep in sync with `src/webviews/apps/tsconfig.json` + files: [ + 'src/webviews/apps/**/*', + 'src/@types/**/*', + 'src/env/browser/**/*', + 'src/plus/gk/account/promos.ts', + 'src/plus/gk/account/subscription.ts', + 'src/plus/webviews/**/protocol.ts', + 'src/webviews/protocol.ts', + 'src/webviews/**/protocol.ts', + 'src/config.ts', + 'src/constants.ts', + 'src/constants.*.ts', + 'src/features.ts', + 'src/subscription.ts', + 'src/system/*.ts', + 'src/system/decorators/log.ts', + ], + languageOptions: { + globals: { + ...globals.browser, + }, + parser: ts.parser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + ecmaFeatures: { + impliedStrict: true, + }, + projectService: true, + }, + }, + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['src/*'], + message: 'Use relative paths instead', + }, + { + group: ['react-dom'], + importNames: ['Container'], + message: 'Use our Container instead', + }, + { + group: ['vscode'], + message: "Can't use vscode in webviews", + allowTypeImports: true, + }, + ], + }, + ], + }, + settings: { + wc: { + elementBaseClasses: [ + 'LitElement', // Recognize `LitElement` as a Custom Element base class + 'GlElement', + ], + }, + }, + }, + { + name: 'tests:e2e', + files: ['tests/**/*'], + languageOptions: { + globals: { + ...globals.node, + }, + parser: ts.parser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + ecmaFeatures: { + impliedStrict: true, + }, + projectService: true, + }, + }, + rules: { + '@typescript-eslint/no-restricted-imports': 'off', + }, + }, + { + name: 'tests:unit', + files: ['**/__tests__/**', 'src/test/suite/**'], + rules: { + 'no-restricted-imports': 'off', + '@typescript-eslint/no-restricted-imports': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'no-restricted-syntax': [ + 'error', + { + message: "Don't forget to remove .only from test suites", + selector: 'CallExpression MemberExpression[object.name="suite"][property.name="only"]', + }, + { + message: "Don't forget to remove .only from tests", + selector: 'CallExpression MemberExpression[object.name="test"][property.name="only"]', + }, + ], + }, + }, +); diff --git a/gitkraken b/gitkraken new file mode 100644 index 0000000000000..a6707bf01730b --- /dev/null +++ b/gitkraken @@ -0,0 +1 @@ +git kraken \ No newline at end of file diff --git a/images/dark/icon-branch-green.svg b/images/dark/icon-branch-ahead.svg similarity index 100% rename from images/dark/icon-branch-green.svg rename to images/dark/icon-branch-ahead.svg diff --git a/images/dark/icon-branch-red.svg b/images/dark/icon-branch-behind.svg similarity index 100% rename from images/dark/icon-branch-red.svg rename to images/dark/icon-branch-behind.svg diff --git a/images/dark/icon-branch-yellow.svg b/images/dark/icon-branch-diverged.svg similarity index 100% rename from images/dark/icon-branch-yellow.svg rename to images/dark/icon-branch-diverged.svg diff --git a/images/dark/icon-branch-synced.svg b/images/dark/icon-branch-synced.svg new file mode 100644 index 0000000000000..aeb91f2c937b0 --- /dev/null +++ b/images/dark/icon-branch-synced.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/dark/icon-git-orange.svg b/images/dark/icon-git-orange.svg deleted file mode 100644 index 0c9af12a52e1c..0000000000000 --- a/images/dark/icon-git-orange.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/images/dark/icon-git-progress.svg b/images/dark/icon-git-progress.svg deleted file mode 100644 index 90c3d1aa4630e..0000000000000 --- a/images/dark/icon-git-progress.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/images/dark/icon-git.svg b/images/dark/icon-git.svg deleted file mode 100644 index ded805271fd90..0000000000000 --- a/images/dark/icon-git.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/images/dark/icon-gitlens-filled-orange.svg b/images/dark/icon-gitlens-filled-orange.svg new file mode 100644 index 0000000000000..4471b752be14c --- /dev/null +++ b/images/dark/icon-gitlens-filled-orange.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/dark/icon-gitlens-filled-progress.svg b/images/dark/icon-gitlens-filled-progress.svg new file mode 100644 index 0000000000000..599a20db73515 --- /dev/null +++ b/images/dark/icon-gitlens-filled-progress.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/dark/icon-repo-ahead-cloud.svg b/images/dark/icon-repo-ahead-cloud.svg new file mode 100644 index 0000000000000..a94d73ef618d4 --- /dev/null +++ b/images/dark/icon-repo-ahead-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/dark/icon-repo-ahead-solid.svg b/images/dark/icon-repo-ahead-solid.svg new file mode 100644 index 0000000000000..a5a55f70bbeaa --- /dev/null +++ b/images/dark/icon-repo-ahead-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/dark/icon-repo-green.svg b/images/dark/icon-repo-ahead.svg similarity index 100% rename from images/dark/icon-repo-green.svg rename to images/dark/icon-repo-ahead.svg diff --git a/images/dark/icon-repo-behind-cloud.svg b/images/dark/icon-repo-behind-cloud.svg new file mode 100644 index 0000000000000..daea637413847 --- /dev/null +++ b/images/dark/icon-repo-behind-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/dark/icon-repo-behind-solid.svg b/images/dark/icon-repo-behind-solid.svg new file mode 100644 index 0000000000000..d150422eefffe --- /dev/null +++ b/images/dark/icon-repo-behind-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/dark/icon-repo-red.svg b/images/dark/icon-repo-behind.svg similarity index 100% rename from images/dark/icon-repo-red.svg rename to images/dark/icon-repo-behind.svg diff --git a/images/dark/icon-repo-changes-cloud.svg b/images/dark/icon-repo-changes-cloud.svg new file mode 100644 index 0000000000000..bfefa95bd208f --- /dev/null +++ b/images/dark/icon-repo-changes-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/dark/icon-repo-changes-solid.svg b/images/dark/icon-repo-changes-solid.svg new file mode 100644 index 0000000000000..32f9378851d9d --- /dev/null +++ b/images/dark/icon-repo-changes-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/dark/icon-repo-blue.svg b/images/dark/icon-repo-changes.svg similarity index 100% rename from images/dark/icon-repo-blue.svg rename to images/dark/icon-repo-changes.svg diff --git a/images/dark/icon-repo-cloud.svg b/images/dark/icon-repo-cloud.svg new file mode 100644 index 0000000000000..0bc37e3b9baaf --- /dev/null +++ b/images/dark/icon-repo-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/dark/icon-repo-diverged-cloud.svg b/images/dark/icon-repo-diverged-cloud.svg new file mode 100644 index 0000000000000..bd86ba91ae40c --- /dev/null +++ b/images/dark/icon-repo-diverged-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/dark/icon-repo-diverged-solid.svg b/images/dark/icon-repo-diverged-solid.svg new file mode 100644 index 0000000000000..b4aa92f2383a5 --- /dev/null +++ b/images/dark/icon-repo-diverged-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/dark/icon-repo-yellow.svg b/images/dark/icon-repo-diverged.svg similarity index 100% rename from images/dark/icon-repo-yellow.svg rename to images/dark/icon-repo-diverged.svg diff --git a/images/dark/icon-repo-solid.svg b/images/dark/icon-repo-solid.svg new file mode 100644 index 0000000000000..ace38185ca5bc --- /dev/null +++ b/images/dark/icon-repo-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/dark/icon-repo-synced-cloud.svg b/images/dark/icon-repo-synced-cloud.svg new file mode 100644 index 0000000000000..86dd95057d438 --- /dev/null +++ b/images/dark/icon-repo-synced-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/dark/icon-repo-synced-solid.svg b/images/dark/icon-repo-synced-solid.svg new file mode 100644 index 0000000000000..4c28fb08eb847 --- /dev/null +++ b/images/dark/icon-repo-synced-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/dark/icon-repo-synced.svg b/images/dark/icon-repo-synced.svg new file mode 100644 index 0000000000000..bbf68ae9b502a --- /dev/null +++ b/images/dark/icon-repo-synced.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/docs/code-suggest.png b/images/docs/code-suggest.png new file mode 100644 index 0000000000000..820720d1051f3 Binary files /dev/null and b/images/docs/code-suggest.png differ diff --git a/images/docs/commit-graph.png b/images/docs/commit-graph.png new file mode 100644 index 0000000000000..1105448cc736a Binary files /dev/null and b/images/docs/commit-graph.png differ diff --git a/images/docs/focus-view.png b/images/docs/focus-view.png new file mode 100644 index 0000000000000..93047ad1808ca Binary files /dev/null and b/images/docs/focus-view.png differ diff --git a/images/docs/get-started-video.png b/images/docs/get-started-video.png new file mode 100644 index 0000000000000..c6ec88d905635 Binary files /dev/null and b/images/docs/get-started-video.png differ diff --git a/images/docs/gitlens-tutorial.png b/images/docs/gitlens-tutorial.png deleted file mode 100644 index 93e6985947c0d..0000000000000 Binary files a/images/docs/gitlens-tutorial.png and /dev/null differ diff --git a/images/docs/hosting-integrations.png b/images/docs/hosting-integrations.png index c098db88d89c9..fc8e1b1b3a82b 100644 Binary files a/images/docs/hosting-integrations.png and b/images/docs/hosting-integrations.png differ diff --git a/images/docs/hovers-github-integration.png b/images/docs/hovers-github-integration.png deleted file mode 100644 index 7404715018630..0000000000000 Binary files a/images/docs/hovers-github-integration.png and /dev/null differ diff --git a/images/docs/hovers-gitlab-integration.png b/images/docs/hovers-gitlab-integration.png deleted file mode 100644 index 402e0b508b128..0000000000000 Binary files a/images/docs/hovers-gitlab-integration.png and /dev/null differ diff --git a/images/docs/launchpad.png b/images/docs/launchpad.png new file mode 100644 index 0000000000000..501411ed9d91a Binary files /dev/null and b/images/docs/launchpad.png differ diff --git a/images/docs/quick-setup.png b/images/docs/quick-setup.png deleted file mode 100644 index 1f753345cf7e6..0000000000000 Binary files a/images/docs/quick-setup.png and /dev/null differ diff --git a/images/docs/rebase.gif b/images/docs/rebase.gif index b4230620cc86d..ca288e3eae1b3 100644 Binary files a/images/docs/rebase.gif and b/images/docs/rebase.gif differ diff --git a/images/docs/revision-navigation.gif b/images/docs/revision-navigation.gif index 4dde065afc7e1..be9c496bb61a9 100644 Binary files a/images/docs/revision-navigation.gif and b/images/docs/revision-navigation.gif differ diff --git a/images/docs/side-bar-views.png b/images/docs/side-bar-views.png new file mode 100644 index 0000000000000..53acd8b2711a4 Binary files /dev/null and b/images/docs/side-bar-views.png differ diff --git a/images/docs/views-layout-gitlens.png b/images/docs/views-layout-gitlens.png deleted file mode 100644 index 83c221513d142..0000000000000 Binary files a/images/docs/views-layout-gitlens.png and /dev/null differ diff --git a/images/docs/views-layout-scm.png b/images/docs/views-layout-scm.png deleted file mode 100644 index 56f793cd7f3c1..0000000000000 Binary files a/images/docs/views-layout-scm.png and /dev/null differ diff --git a/images/docs/worktrees.png b/images/docs/worktrees.png new file mode 100644 index 0000000000000..4693417912d12 Binary files /dev/null and b/images/docs/worktrees.png differ diff --git a/images/gitlens-icon.png b/images/gitlens-icon.png index 55117a4961e05..feae8450d10bf 100644 Binary files a/images/gitlens-icon.png and b/images/gitlens-icon.png differ diff --git a/images/icons/arrow-up-force.svg b/images/icons/arrow-up-force.svg deleted file mode 100644 index 331e8dca2bc0e..0000000000000 --- a/images/icons/arrow-up-force.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/images/icons/clock.svg b/images/icons/clock.svg index 94e68aecc1c63..f096f4e0e3ffc 100644 --- a/images/icons/clock.svg +++ b/images/icons/clock.svg @@ -1 +1 @@ - + diff --git a/images/icons/cloud-patch-share.svg b/images/icons/cloud-patch-share.svg new file mode 100644 index 0000000000000..46dbb3f551f25 --- /dev/null +++ b/images/icons/cloud-patch-share.svg @@ -0,0 +1 @@ + diff --git a/images/icons/cloud-patch.svg b/images/icons/cloud-patch.svg new file mode 100644 index 0000000000000..f2dfe4c072f98 --- /dev/null +++ b/images/icons/cloud-patch.svg @@ -0,0 +1 @@ + diff --git a/images/icons/code-suggestion.svg b/images/icons/code-suggestion.svg new file mode 100644 index 0000000000000..08d83c3881b57 --- /dev/null +++ b/images/icons/code-suggestion.svg @@ -0,0 +1 @@ + diff --git a/images/icons/confirm-checked.svg b/images/icons/confirm-checked.svg new file mode 100644 index 0000000000000..2cfa5a21aa771 --- /dev/null +++ b/images/icons/confirm-checked.svg @@ -0,0 +1 @@ + diff --git a/images/icons/confirm-unchecked.svg b/images/icons/confirm-unchecked.svg new file mode 100644 index 0000000000000..c8b0e11af5c66 --- /dev/null +++ b/images/icons/confirm-unchecked.svg @@ -0,0 +1 @@ + diff --git a/images/icons/diff-multiple.svg b/images/icons/diff-multiple.svg new file mode 100644 index 0000000000000..b2f793b170800 --- /dev/null +++ b/images/icons/diff-multiple.svg @@ -0,0 +1 @@ + diff --git a/images/icons/diff-single.svg b/images/icons/diff-single.svg new file mode 100644 index 0000000000000..e5e1c9bacb0f5 --- /dev/null +++ b/images/icons/diff-single.svg @@ -0,0 +1 @@ + diff --git a/images/icons/gitlens-filled.svg b/images/icons/gitlens-filled.svg new file mode 100644 index 0000000000000..53da0f1b862cc --- /dev/null +++ b/images/icons/gitlens-filled.svg @@ -0,0 +1 @@ + diff --git a/images/icons/gitlens-inspect.svg b/images/icons/gitlens-inspect.svg new file mode 100644 index 0000000000000..bcc361c0c97ce --- /dev/null +++ b/images/icons/gitlens-inspect.svg @@ -0,0 +1 @@ + diff --git a/images/icons/gitlens.svg b/images/icons/gitlens.svg index 73d6eca98f6e0..edb9b2b45c427 100644 --- a/images/icons/gitlens.svg +++ b/images/icons/gitlens.svg @@ -1 +1 @@ - + diff --git a/images/icons/inspect.svg b/images/icons/inspect.svg new file mode 100644 index 0000000000000..1cf2117992f8b --- /dev/null +++ b/images/icons/inspect.svg @@ -0,0 +1 @@ + diff --git a/images/icons/provider-azdo.svg b/images/icons/provider-azdo.svg new file mode 100644 index 0000000000000..b21bb70ff8a2f --- /dev/null +++ b/images/icons/provider-azdo.svg @@ -0,0 +1 @@ + diff --git a/images/icons/provider-bitbucket.svg b/images/icons/provider-bitbucket.svg new file mode 100644 index 0000000000000..54ddd78d10b7f --- /dev/null +++ b/images/icons/provider-bitbucket.svg @@ -0,0 +1 @@ + diff --git a/images/icons/provider-gerrit.svg b/images/icons/provider-gerrit.svg new file mode 100644 index 0000000000000..73c63971b6381 --- /dev/null +++ b/images/icons/provider-gerrit.svg @@ -0,0 +1 @@ + diff --git a/images/icons/provider-gitea.svg b/images/icons/provider-gitea.svg new file mode 100644 index 0000000000000..4e8b522bd3fbc --- /dev/null +++ b/images/icons/provider-gitea.svg @@ -0,0 +1 @@ + diff --git a/images/icons/provider-github.svg b/images/icons/provider-github.svg new file mode 100644 index 0000000000000..d146e13976231 --- /dev/null +++ b/images/icons/provider-github.svg @@ -0,0 +1 @@ + diff --git a/images/icons/provider-gitlab.svg b/images/icons/provider-gitlab.svg new file mode 100644 index 0000000000000..66f94a08b5234 --- /dev/null +++ b/images/icons/provider-gitlab.svg @@ -0,0 +1 @@ + diff --git a/images/icons/provider-jira.svg b/images/icons/provider-jira.svg new file mode 100644 index 0000000000000..b8a4f11270a4a --- /dev/null +++ b/images/icons/provider-jira.svg @@ -0,0 +1 @@ + diff --git a/images/icons/repo-fetch.svg b/images/icons/repo-fetch.svg new file mode 100644 index 0000000000000..1fd0ea8dd589e --- /dev/null +++ b/images/icons/repo-fetch.svg @@ -0,0 +1 @@ + diff --git a/images/icons/repo-force-push.svg b/images/icons/repo-force-push.svg new file mode 100644 index 0000000000000..15878f3d3d21e --- /dev/null +++ b/images/icons/repo-force-push.svg @@ -0,0 +1 @@ + diff --git a/images/icons/repo-pull.svg b/images/icons/repo-pull.svg new file mode 100644 index 0000000000000..e28c1240cda95 --- /dev/null +++ b/images/icons/repo-pull.svg @@ -0,0 +1 @@ + diff --git a/images/icons/repo-push.svg b/images/icons/repo-push.svg new file mode 100644 index 0000000000000..878d60ae0fac3 --- /dev/null +++ b/images/icons/repo-push.svg @@ -0,0 +1 @@ + diff --git a/images/icons/repository-filled.svg b/images/icons/repository-filled.svg new file mode 100644 index 0000000000000..f0861f8dea9e0 --- /dev/null +++ b/images/icons/repository-filled.svg @@ -0,0 +1 @@ + diff --git a/images/icons/template/mapping.json b/images/icons/template/mapping.json index b74f5a8486ff1..f5a723fa67ebd 100644 --- a/images/icons/template/mapping.json +++ b/images/icons/template/mapping.json @@ -27,7 +27,29 @@ "switch": 61720, "expand": 61721, "list-auto": 61722, - "arrow-up-force": 61723, + "repo-force-push": 61723, "pinned-filled": 61724, - "clock": 61725 + "clock": 61725, + "provider-azdo": 61726, + "provider-bitbucket": 61727, + "provider-gerrit": 61728, + "provider-gitea": 61729, + "provider-github": 61730, + "provider-gitlab": 61731, + "gitlens-inspect": 61732, + "workspaces-view": 61733, + "confirm-checked": 61734, + "confirm-unchecked": 61735, + "cloud-patch": 61736, + "cloud-patch-share": 61737, + "inspect": 61738, + "repository-filled": 61739, + "gitlens-filled": 61740, + "code-suggestion": 61741, + "diff-multiple": 61742, + "diff-single": 61743, + "repo-fetch": 61744, + "repo-pull": 61745, + "repo-push": 61746, + "provider-jira": 61747 } \ No newline at end of file diff --git a/images/icons/workspaces-view.svg b/images/icons/workspaces-view.svg new file mode 100644 index 0000000000000..15613ab345383 --- /dev/null +++ b/images/icons/workspaces-view.svg @@ -0,0 +1 @@ + diff --git a/images/light/icon-branch-green.svg b/images/light/icon-branch-ahead.svg similarity index 100% rename from images/light/icon-branch-green.svg rename to images/light/icon-branch-ahead.svg diff --git a/images/light/icon-branch-red.svg b/images/light/icon-branch-behind.svg similarity index 100% rename from images/light/icon-branch-red.svg rename to images/light/icon-branch-behind.svg diff --git a/images/light/icon-branch-yellow.svg b/images/light/icon-branch-diverged.svg similarity index 100% rename from images/light/icon-branch-yellow.svg rename to images/light/icon-branch-diverged.svg diff --git a/images/light/icon-branch-synced.svg b/images/light/icon-branch-synced.svg new file mode 100644 index 0000000000000..dad5bd548b2ba --- /dev/null +++ b/images/light/icon-branch-synced.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/light/icon-git-orange.svg b/images/light/icon-git-orange.svg deleted file mode 100644 index d11d99d1e2d79..0000000000000 --- a/images/light/icon-git-orange.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/images/light/icon-git-progress.svg b/images/light/icon-git-progress.svg deleted file mode 100644 index 0f55ae7b85a07..0000000000000 --- a/images/light/icon-git-progress.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/images/light/icon-git.svg b/images/light/icon-git.svg deleted file mode 100644 index 2a49cbe32f6be..0000000000000 --- a/images/light/icon-git.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/images/light/icon-gitlens-filled-orange.svg b/images/light/icon-gitlens-filled-orange.svg new file mode 100644 index 0000000000000..c43e1f2b460be --- /dev/null +++ b/images/light/icon-gitlens-filled-orange.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/light/icon-gitlens-filled-progress.svg b/images/light/icon-gitlens-filled-progress.svg new file mode 100644 index 0000000000000..ea67e6f3fcb6a --- /dev/null +++ b/images/light/icon-gitlens-filled-progress.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/images/light/icon-repo-ahead-cloud.svg b/images/light/icon-repo-ahead-cloud.svg new file mode 100644 index 0000000000000..4551e6a8ef406 --- /dev/null +++ b/images/light/icon-repo-ahead-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/light/icon-repo-ahead-solid.svg b/images/light/icon-repo-ahead-solid.svg new file mode 100644 index 0000000000000..9f120a4d66208 --- /dev/null +++ b/images/light/icon-repo-ahead-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/light/icon-repo-green.svg b/images/light/icon-repo-ahead.svg similarity index 100% rename from images/light/icon-repo-green.svg rename to images/light/icon-repo-ahead.svg diff --git a/images/light/icon-repo-behind-cloud.svg b/images/light/icon-repo-behind-cloud.svg new file mode 100644 index 0000000000000..21a84c934d9a8 --- /dev/null +++ b/images/light/icon-repo-behind-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/light/icon-repo-behind-solid.svg b/images/light/icon-repo-behind-solid.svg new file mode 100644 index 0000000000000..da150a6f47046 --- /dev/null +++ b/images/light/icon-repo-behind-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/light/icon-repo-red.svg b/images/light/icon-repo-behind.svg similarity index 100% rename from images/light/icon-repo-red.svg rename to images/light/icon-repo-behind.svg diff --git a/images/light/icon-repo-changes-cloud.svg b/images/light/icon-repo-changes-cloud.svg new file mode 100644 index 0000000000000..8171e6cf0c36b --- /dev/null +++ b/images/light/icon-repo-changes-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/light/icon-repo-changes-solid.svg b/images/light/icon-repo-changes-solid.svg new file mode 100644 index 0000000000000..b0496f9b37708 --- /dev/null +++ b/images/light/icon-repo-changes-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/light/icon-repo-blue.svg b/images/light/icon-repo-changes.svg similarity index 100% rename from images/light/icon-repo-blue.svg rename to images/light/icon-repo-changes.svg diff --git a/images/light/icon-repo-cloud.svg b/images/light/icon-repo-cloud.svg new file mode 100644 index 0000000000000..16451d8d12dae --- /dev/null +++ b/images/light/icon-repo-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/light/icon-repo-diverged-cloud.svg b/images/light/icon-repo-diverged-cloud.svg new file mode 100644 index 0000000000000..752c6aaaaa71e --- /dev/null +++ b/images/light/icon-repo-diverged-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/light/icon-repo-diverged-solid.svg b/images/light/icon-repo-diverged-solid.svg new file mode 100644 index 0000000000000..082d65969ee34 --- /dev/null +++ b/images/light/icon-repo-diverged-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/light/icon-repo-yellow.svg b/images/light/icon-repo-diverged.svg similarity index 100% rename from images/light/icon-repo-yellow.svg rename to images/light/icon-repo-diverged.svg diff --git a/images/light/icon-repo-solid.svg b/images/light/icon-repo-solid.svg new file mode 100644 index 0000000000000..59f72f172a16b --- /dev/null +++ b/images/light/icon-repo-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/light/icon-repo-synced-cloud.svg b/images/light/icon-repo-synced-cloud.svg new file mode 100644 index 0000000000000..0f837b7741e48 --- /dev/null +++ b/images/light/icon-repo-synced-cloud.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/light/icon-repo-synced-solid.svg b/images/light/icon-repo-synced-solid.svg new file mode 100644 index 0000000000000..093ff25fe51cd --- /dev/null +++ b/images/light/icon-repo-synced-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/light/icon-repo-synced.svg b/images/light/icon-repo-synced.svg new file mode 100644 index 0000000000000..8408401015a20 --- /dev/null +++ b/images/light/icon-repo-synced.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/package.json b/package.json index dfb1886b30ca2..1c4f1eb25e070 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,9 @@ "name": "gitlens", "displayName": "GitLens — Git supercharged", "description": "Supercharge Git within VS Code — Visualize code authorship at a glance via Git blame annotations and CodeLens, seamlessly navigate and explore Git repositories, gain valuable insights via rich visualizations and powerful comparison commands, and so much more", - "version": "13.3.1", + "version": "15.5.0", "engines": { - "vscode": "^1.72.0" + "vscode": "^1.82.0" }, "license": "SEE LICENSE IN LICENSE", "publisher": "eamodio", @@ -49,177 +49,12 @@ ], "activationEvents": [ "onAuthenticationRequest:gitlens-gitkraken", - "onCustomEditor:gitlens.rebase", "onFileSystem:gitlens", - "onView:gitlens.views.home", - "onView:gitlens.views.repositories", - "onView:gitlens.views.commits", - "onView:gitlens.views.fileHistory", - "onView:gitlens.views.lineHistory", - "onView:gitlens.views.branches", - "onView:gitlens.views.remotes", - "onView:gitlens.views.stashes", - "onView:gitlens.views.tags", - "onView:gitlens.views.contributors", - "onView:gitlens.views.searchAndCompare", - "onView:gitlens.views.worktrees", - "onView:gitlens.views.commitDetails", - "onWebviewPanel:gitlens.welcome", - "onWebviewPanel:gitlens.settings", "onWebviewPanel:gitlens.graph", - "onWebviewPanel:gitlens.focus", - "onCommand:gitlens.plus.learn", - "onCommand:gitlens.plus.loginOrSignUp", - "onCommand:gitlens.plus.logout", - "onCommand:gitlens.plus.startPreviewTrial", - "onCommand:gitlens.plus.manage", - "onCommand:gitlens.plus.purchase", - "onCommand:gitlens.getStarted", - "onCommand:gitlens.showGraphPage", - "onCommand:gitlens.showFocusPage", - "onCommand:gitlens.showSettingsPage", - "onCommand:gitlens.showSettingsPage#views", - "onCommand:gitlens.showSettingsPage#autolinks", - "onCommand:gitlens.showSettingsPage#branches-view", - "onCommand:gitlens.showSettingsPage#commits-view", - "onCommand:gitlens.showSettingsPage#contributors-view", - "onCommand:gitlens.showSettingsPage#file-history-view", - "onCommand:gitlens.showSettingsPage#line-history-view", - "onCommand:gitlens.showSettingsPage#remotes-view", - "onCommand:gitlens.showSettingsPage#repositories-view", - "onCommand:gitlens.showSettingsPage#search-compare-view", - "onCommand:gitlens.showSettingsPage#stashes-view", - "onCommand:gitlens.showSettingsPage#tags-view", - "onCommand:gitlens.showSettingsPage#worktrees-view", - "onCommand:gitlens.showSettingsPage#commit-graph", - "onCommand:gitlens.showWelcomePage", - "onCommand:gitlens.showBranchesView", - "onCommand:gitlens.showCommitDetailsView", - "onCommand:gitlens.showCommitsView", - "onCommand:gitlens.showContributorsView", - "onCommand:gitlens.showFileHistoryView", - "onCommand:gitlens.showHomeView", - "onCommand:gitlens.showLineHistoryView", - "onCommand:gitlens.showRemotesView", - "onCommand:gitlens.showRepositoriesView", - "onCommand:gitlens.showSearchAndCompareView", - "onCommand:gitlens.showStashesView", - "onCommand:gitlens.showTagsView", - "onCommand:gitlens.showTimelineView", - "onCommand:gitlens.showWorktreesView", - "onCommand:gitlens.compareWith", - "onCommand:gitlens.compareHeadWith", - "onCommand:gitlens.compareWorkingWith", - "onCommand:gitlens.diffDirectory", - "onCommand:gitlens.diffDirectoryWithHead", - "onCommand:gitlens.diffWithNext", - "onCommand:gitlens.diffWithNextInDiffLeft", - "onCommand:gitlens.diffWithNextInDiffRight", - "onCommand:gitlens.diffWithPrevious", - "onCommand:gitlens.diffWithPreviousInDiffLeft", - "onCommand:gitlens.diffWithPreviousInDiffRight", - "onCommand:gitlens.diffLineWithPrevious", - "onCommand:gitlens.diffWithRevision", - "onCommand:gitlens.diffWithRevisionFrom", - "onCommand:gitlens.diffWithWorking", - "onCommand:gitlens.diffWithWorkingInDiffLeft", - "onCommand:gitlens.diffWithWorkingInDiffRight", - "onCommand:gitlens.diffLineWithWorking", - "onCommand:gitlens.disableRebaseEditor", - "onCommand:gitlens.enableRebaseEditor", - "onCommand:gitlens.toggleFileBlame", - "onCommand:gitlens.toggleFileBlameInDiffLeft", - "onCommand:gitlens.toggleFileBlameInDiffRight", - "onCommand:gitlens.clearFileAnnotations", - "onCommand:gitlens.computingFileAnnotations", - "onCommand:gitlens.toggleFileHeatmap", - "onCommand:gitlens.toggleFileHeatmapInDiffLeft", - "onCommand:gitlens.toggleFileHeatmapInDiffRight", - "onCommand:gitlens.toggleFileChanges", - "onCommand:gitlens.toggleFileChangesOnly", - "onCommand:gitlens.toggleLineBlame", - "onCommand:gitlens.toggleCodeLens", - "onCommand:gitlens.gitCommands", - "onCommand:gitlens.switchMode", - "onCommand:gitlens.toggleReviewMode", - "onCommand:gitlens.toggleZenMode", - "onCommand:gitlens.setViewsLayout", - "onCommand:gitlens.showCommitSearch", - "onCommand:gitlens.revealCommitInView", - "onCommand:gitlens.showCommitInView", - "onCommand:gitlens:showInDetailsView", - "onCommand:gitlens.showCommitsInView", - "onCommand:gitlens.showFileHistoryInView", - "onCommand:gitlens.openFileHistory", - "onCommand:gitlens.openFolderHistory", - "onCommand:gitlens.showQuickCommitDetails", - "onCommand:gitlens.showQuickCommitFileDetails", - "onCommand:gitlens.showQuickRevisionDetails", - "onCommand:gitlens.showQuickRevisionDetailsInDiffLeft", - "onCommand:gitlens.showQuickRevisionDetailsInDiffRight", - "onCommand:gitlens.showQuickFileHistory", - "onCommand:gitlens.quickOpenFileHistory", - "onCommand:gitlens.showQuickBranchHistory", - "onCommand:gitlens.showQuickRepoHistory", - "onCommand:gitlens.showQuickRepoStatus", - "onCommand:gitlens.showQuickStashList", - "onCommand:gitlens.addAuthors", - "onCommand:gitlens.connectRemoteProvider", - "onCommand:gitlens.disconnectRemoteProvider", - "onCommand:gitlens.copyCurrentBranch", - "onCommand:gitlens.copyMessageToClipboard", - "onCommand:gitlens.copyShaToClipboard", - "onCommand:gitlens.closeUnchangedFiles", - "onCommand:gitlens.openChangedFiles", - "onCommand:gitlens.openBranchesOnRemote", - "onCommand:gitlens.copyRemoteBranchesUrl", - "onCommand:gitlens.openBranchOnRemote", - "onCommand:gitlens.openCurrentBranchOnRemote", - "onCommand:gitlens.copyRemoteBranchUrl", - "onCommand:gitlens.openCommitOnRemote", - "onCommand:gitlens.copyRemoteCommitUrl", - "onCommand:gitlens.openComparisonOnRemote", - "onCommand:gitlens.copyRemoteComparisonUrl", - "onCommand:gitlens.openFileFromRemote", - "onCommand:gitlens.openFileOnRemote", - "onCommand:gitlens.copyRemoteFileUrlToClipboard", - "onCommand:gitlens.copyRemoteFileUrlWithoutRange", - "onCommand:gitlens.openFileOnRemoteFrom", - "onCommand:gitlens.copyRemoteFileUrlFrom", - "onCommand:gitlens.openBlamePriorToChange", - "onCommand:gitlens.openFileRevision", - "onCommand:gitlens.openFileRevisionFrom", - "onCommand:gitlens.openAutolinkUrl", - "onCommand:gitlens.copyAutolinkUrl", - "onCommand:gitlens.openIssueOnRemote", - "onCommand:gitlens.copyRemoteIssueUrl", - "onCommand:gitlens.openPullRequestOnRemote", - "onCommand:gitlens.copyRemotePullRequestUrl", - "onCommand:gitlens.openAssociatedPullRequestOnRemote", - "onCommand:gitlens.openRepoOnRemote", - "onCommand:gitlens.copyRemoteRepositoryUrl", - "onCommand:gitlens.openRevisionFile", - "onCommand:gitlens.openRevisionFileInDiffLeft", - "onCommand:gitlens.openRevisionFileInDiffRight", - "onCommand:gitlens.openWorkingFile", - "onCommand:gitlens.openWorkingFileInDiffLeft", - "onCommand:gitlens.openWorkingFileInDiffRight", - "onCommand:gitlens.stashApply", - "onCommand:gitlens.stashSave", - "onCommand:gitlens.stashSaveFiles", - "onCommand:gitlens.externalDiff", - "onCommand:gitlens.externalDiffAll", - "onCommand:gitlens.resetAvatarCache", - "onCommand:gitlens.resetSuppressedWarnings", - "onCommand:gitlens.resetTrackedUsage", - "onCommand:gitlens.inviteToLiveShare", - "onCommand:gitlens.browseRepoAtRevision", - "onCommand:gitlens.browseRepoAtRevisionInNewWindow", - "onCommand:gitlens.browseRepoBeforeRevision", - "onCommand:gitlens.browseRepoBeforeRevisionInNewWindow", - "onCommand:gitlens.fetchRepositories", - "onCommand:gitlens.pullRepositories", - "onCommand:gitlens.pushRepositories", + "onWebviewPanel:gitlens.patchDetails", + "onWebviewPanel:gitlens.settings", + "onWebviewPanel:gitlens.timeline", + "onWebviewPanel:gitlens.welcome", "onStartupFinished" ], "capabilities": { @@ -232,41 +67,69 @@ "configuration": [ { "id": "current-line-blame", - "title": "Current Line Blame", + "title": "Inline Blame", "order": 10, "properties": { "gitlens.currentLine.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to provide a blame annotation for the current line, by default. Use the `Toggle Line Blame Annotations` command (`gitlens.toggleLineBlame`) to toggle the annotations on and off for the current window", + "markdownDescription": "Specifies whether to provide an inline blame annotation for the current line, by default. Use the `Toggle Line Blame Annotations` command (`gitlens.toggleLineBlame`) to toggle the annotations on and off for the current window", "scope": "window", "order": 10 }, "gitlens.currentLine.pullRequests.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to provide information about the Pull Request (if any) that introduced the commit in the current line blame annotation. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to provide information about the Pull Request (if any) that introduced the commit in the inline blame annotation. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", "order": 20 }, "gitlens.currentLine.format": { "type": "string", - "default": "${author, }${agoOrDate}${' via 'pullRequest}${ â€ĸ message|50?}", - "markdownDescription": "Specifies the format of the current line blame annotation. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `#gitlens.currentLine.dateFormat#` setting", + "default": "${author, }${agoOrDate}${' via 'pullRequest}${ â€ĸ message|50?}", + "markdownDescription": "Specifies the format of the inline blame annotation. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `#gitlens.currentLine.dateFormat#` setting", "scope": "window", "order": 30 }, "gitlens.currentLine.uncommittedChangesFormat": { "type": "string", "default": null, - "markdownDescription": "Specifies the uncommitted changes format of the current line blame annotation. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `#gitlens.currentLine.dateFormat#` setting.\n\n**NOTE**: Setting this to an empty string will disable current line blame annotations for uncommitted changes.", + "markdownDescription": "Specifies the uncommitted changes format of the inline blame annotation. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `#gitlens.currentLine.dateFormat#` setting.\n\n**NOTE**: Setting this to an empty string will disable inline blame annotations for uncommitted changes.", "scope": "window", "order": 31 }, + "gitlens.currentLine.fontFamily": { + "type": "string", + "default": "", + "markdownDescription": "Specifies the font family of the inline blame annotation", + "scope": "window", + "order": 33 + }, + "gitlens.currentLine.fontSize": { + "type": "number", + "default": 0, + "markdownDescription": "Specifies the font size of the inline blame annotation", + "scope": "window", + "order": 34 + }, + "gitlens.currentLine.fontStyle": { + "type": "string", + "default": "normal", + "markdownDescription": "Specifies the font style of the inline blame annotation", + "scope": "window", + "order": 35 + }, + "gitlens.currentLine.fontWeight": { + "type": "string", + "default": "normal", + "markdownDescription": "Specifies the font weight of the inline blame annotation", + "scope": "window", + "order": 36 + }, "gitlens.currentLine.scrollable": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether the current line blame annotation can be scrolled into view when it is outside the viewport. **NOTE**: Setting this to `false` will inhibit the hovers from showing over the annotation; Set `#gitlens.hovers.currentLine.over#` to `line` to enable the hovers to show anywhere over the line.", + "markdownDescription": "Specifies whether the inline blame annotation can be scrolled into view when it is outside the viewport. **NOTE**: Setting this to `false` will inhibit the hovers from showing over the annotation; Set `#gitlens.hovers.currentLine.over#` to `line` to enable the hovers to show anywhere over the line.", "scope": "window", "order": 40 }, @@ -276,7 +139,7 @@ "null" ], "default": null, - "markdownDescription": "Specifies how to format absolute dates (e.g. using the `${date}` token) for the current line blame annotation. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", + "markdownDescription": "Specifies how to format absolute dates (e.g. using the `${date}` token) for the inline blame annotation. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", "scope": "window", "order": 50 } @@ -333,7 +196,7 @@ "Compares the current committed file with the previous commit", "Reveals the commit in the Side Bar", "Searches for commits within the range", - "Shows a commit details quick pick menu", + "Shows an Inspect quick pick menu", "Shows a commit file details quick pick menu", "Shows a file history quick pick menu", "Shows a branch history quick pick menu", @@ -385,8 +248,8 @@ "Toggles file changes from the commit", "Compares the current committed file with the previous commit", "Reveals the commit in the Side Bar", - "Shows the commit details", - "Shows a commit details quick pick menu", + "Shows the Inspect", + "Shows an Inspect quick pick menu", "Shows a commit file details quick pick menu", "Shows a file history quick pick menu", "Shows a branch history quick pick menu", @@ -501,7 +364,7 @@ }, "gitlens.statusBar.format": { "type": "string", - "default": "${author}, ${agoOrDate}${' via 'pullRequest}", + "default": "${author}, ${agoOrDate}${' via 'pullRequest}", "markdownDescription": "Specifies the format of the blame information in the status bar. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `#gitlens.statusBar.dateFormat#` setting", "scope": "window", "order": 30 @@ -552,8 +415,8 @@ "Compares the current line commit with the previous", "Compares the current line commit with the working tree", "Reveals the commit in the Side Bar", - "Shows the commit details", - "Shows a commit details quick pick menu", + "Shows the Inspect", + "Shows an Inspect quick pick menu", "Shows a commit file details quick pick menu", "Shows a file history quick pick menu", "Shows a branch history quick pick menu", @@ -568,7 +431,7 @@ }, "gitlens.statusBar.tooltipFormat": { "type": "string", - "default": "${avatar}  __${author}__, ${ago}${' via 'pullRequest}   _(${date})_ \n\n${message}${\n\n---\n\nfootnotes}\n\n${commands}", + "default": "${avatar}  __${author}__  $(history) ${ago} _(${date})_${' via 'pullRequest} ${message}${\n\n---\n\nfootnotes}\n\n${commands}", "editPresentation": "multilineText", "markdownDescription": "Specifies the format (in markdown) of hover shown over the blame information in the status bar. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", "scope": "window", @@ -714,7 +577,7 @@ }, "gitlens.hovers.detailsMarkdownFormat": { "type": "string", - "default": "${avatar}  __${author}__, ${ago}${' via 'pullRequest}   _(${date})_ \n\n${message}${\n\n---\n\nfootnotes}\n\n${commands}", + "default": "${avatar}  __${author}__  $(history) ${ago} _(${date})_${' via 'pullRequest} ${message}${\n\n---\n\nfootnotes}\n\n${commands}", "editPresentation": "multilineText", "markdownDescription": "Specifies the format (in markdown) of the _commit details_ hover. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", "scope": "window", @@ -738,884 +601,950 @@ } }, { - "id": "views", - "title": "Views", - "order": 20, + "id": "file-annotations", + "title": "File Annotations", + "order": 14, "properties": { - "gitlens.views.defaultItemLimit": { - "type": "number", - "default": 10, - "markdownDescription": "Specifies the default number of items to show in a view list. Use 0 to specify no limit", + "gitlens.fileAnnotations.dismissOnEscape": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether pressing the `ESC` key dismisses the active file annotations", "scope": "window", "order": 10 }, - "gitlens.views.pageItemLimit": { - "type": "number", - "default": 40, - "markdownDescription": "Specifies the number of items to show in a each page when paginating a view list. Use 0 to specify no limit", + "gitlens.fileAnnotations.command": { + "type": [ + "string", + "null" + ], + "default": null, + "enum": [ + null, + "blame", + "heatmap", + "changes" + ], + "enumDescriptions": [ + "Shows a menu to choose which file annotations to toggle", + "Toggles file blame annotations", + "Toggles file heatmap annotations", + "Toggles file changes annotations" + ], + "markdownDescription": "Specifies whether the file annotations button in the editor title shows a menu or immediately toggles the specified file annotations", "scope": "window", - "order": 11 + "order": 20 }, - "gitlens.views.showRelativeDateMarkers": { + "gitlens.fileAnnotations.preserveWhileEditing": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show relative date markers (_Less than a week ago_, _Over a week ago_, _Over a month ago_, etc) on revision (commit) histories in the views", - "scope": "window", - "order": 20 - }, - "gitlens.views.formats.commits.label": { - "type": "string", - "default": "${❰ tips ❱➤ }${message}", - "markdownDescription": "Specifies the format of commits in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", + "markdownDescription": "Specifies whether file annotations will be preserved while editing. Use `#gitlens.advanced.blame.delayAfterEdit#` to control how long to wait before the annotation will update while the file is still dirty", "scope": "window", "order": 30 }, - "gitlens.views.formats.commits.description": { - "type": "string", - "default": "${author, }${agoOrDate}", - "markdownDescription": "Specifies the description format of commits in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", + "gitlens.advanced.blame.delayAfterEdit": { + "type": "number", + "default": 5000, + "markdownDescription": "Specifies the time (in milliseconds) to wait before re-blaming an unsaved document after an edit but before it is saved. Use 0 to specify an infinite wait. Only applies if the file is under the `#gitlens.advanced.sizeThresholdAfterEdit#`", "scope": "window", - "order": 31 + "order": 90 }, - "gitlens.views.formats.commits.tooltip": { + "gitlens.advanced.blame.sizeThresholdAfterEdit": { + "type": "number", + "default": 5000, + "markdownDescription": "Specifies the maximum document size (in lines) allowed to be re-blamed after an edit while still unsaved. Use 0 to specify no maximum", + "scope": "window", + "order": 91 + } + } + }, + { + "id": "file-blame", + "title": "File Blame", + "order": 15, + "properties": { + "gitlens.blame.toggleMode": { "type": "string", - "default": "${link}${' via 'pullRequest}${'  â€ĸ  'changesDetail}${'    'tips}\n\n${avatar}  __${author}__, ${ago}   _(${date})_ \n\n${message}${\n\n---\n\nfootnotes}", - "markdownDescription": "Specifies the tooltip format (in markdown) of commits in the views. See [_Commit Tokens_](https://github.com/eamodio/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", + "default": "file", + "enum": [ + "file", + "window" + ], + "enumDescriptions": [ + "Toggles each file individually", + "Toggles the window, i.e. all files at once" + ], + "markdownDescription": "Specifies how the file blame annotations will be toggled", "scope": "window", - "order": 32 + "order": 10 }, - "gitlens.views.formats.commits.tooltipWithStatus": { + "gitlens.blame.format": { "type": "string", - "default": "${link}${' via 'pullRequest}  â€ĸ  {{slot-status}}${'  â€ĸ  'changesDetail}${'    'tips}\n\n${avatar}  __${author}__, ${ago}   _(${date})_ \n\n${message}${\n\n---\n\nfootnotes}", - "markdownDescription": "Specifies the tooltip format (in markdown) of \"file\" commits in the views. See [_Commit Tokens_](https://github.com/eamodio/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", + "default": "${message|50?} ${agoOrDate|14-}", + "markdownDescription": "Specifies the format of the file blame annotations. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `#gitlens.blame.dateFormat#` setting", "scope": "window", - "order": 32 + "order": 20 }, - "gitlens.views.formats.files.label": { + "gitlens.blame.fontFamily": { "type": "string", - "default": "${working }${file}", - "markdownDescription": "Specifies the format of a file in the views. See [_File Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#file-tokens) in the GitLens docs", + "default": "", + "markdownDescription": "Specifies the font family of the file blame annotations", "scope": "window", - "order": 40 + "order": 22 }, - "gitlens.views.formats.files.description": { - "type": "string", - "default": "${directory}${ ← originalPath}", - "markdownDescription": "Specifies the description format of a file in the views. See [_File Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#file-tokens) in the GitLens docs", + "gitlens.blame.fontSize": { + "type": "number", + "default": 0, + "markdownDescription": "Specifies the font size of the file blame annotations", "scope": "window", - "order": 41 + "order": 23 }, - "gitlens.views.formats.stashes.label": { + "gitlens.blame.fontStyle": { "type": "string", - "default": "${message}", - "markdownDescription": "Specifies the format of stashes in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", + "default": "normal", + "markdownDescription": "Specifies the font style of the file blame annotations", "scope": "window", - "order": 50 + "order": 24 }, - "gitlens.views.formats.stashes.description": { + "gitlens.blame.fontWeight": { "type": "string", - "default": "${stashOnRef, }${agoOrDate}", - "markdownDescription": "Specifies the description format of stashes in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", + "default": "normal", + "markdownDescription": "Specifies the font weight of the file blame annotations", "scope": "window", - "order": 51 + "order": 25 }, - "gitlens.views.experimental.multiSelect.enabled": { + "gitlens.blame.heatmap.enabled": { "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to enable experimental multi-select support in the views.\n**NOTE**: Requires a restart to take effect.", + "default": true, + "markdownDescription": "Specifies whether to provide a heatmap indicator in the file blame annotations", "scope": "window", - "order": 60 - }, - "gitlens.views.commitFileFormat": { - "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.label` instead", - "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.label#` instead" - }, - "gitlens.views.commitFileDescriptionFormat": { - "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.description` instead", - "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.description#` instead" - }, - "gitlens.views.commitFormat": { - "deprecationMessage": "Deprecated. Use `gitlens.views.formats.commits.label` instead", - "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.commits.files.label#` instead" - }, - "gitlens.views.commitDescriptionFormat": { - "deprecationMessage": "Deprecated. Use `gitlens.views.formats.commits.description` instead", - "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.commits.description#` instead" - }, - "gitlens.views.stashFileFormat": { - "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.label` instead", - "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.label#` instead" - }, - "gitlens.views.stashFileDescriptionFormat": { - "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.description` instead", - "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.description#` instead" - }, - "gitlens.views.stashFormat": { - "deprecationMessage": "Deprecated. Use `gitlens.views.formats.stashes.label` instead", - "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.stashes.files.label#` instead" - }, - "gitlens.views.stashDescriptionFormat": { - "deprecationMessage": "Deprecated. Use `gitlens.views.formats.stashes.description` instead", - "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.stashes.description#` instead" - }, - "gitlens.views.statusFileFormat": { - "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.label` instead", - "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.label#` instead" + "order": 30 }, - "gitlens.views.statusFileDescriptionFormat": { - "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.description` instead", - "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.description#` instead" - } - } - }, - { - "id": "commits-view", - "title": "Commits View", - "order": 21, - "properties": { - "gitlens.views.commits.showBranchComparison": { - "type": [ - "boolean", - "string" - ], + "gitlens.blame.heatmap.location": { + "type": "string", + "default": "right", "enum": [ - false, - "branch", - "working" + "left", + "right" ], "enumDescriptions": [ - "Hides the branch comparison", - "Compares the current branch with a user-selected reference", - "Compares the working tree with a user-selected reference" + "Adds a heatmap indicator on the left edge of the file blame annotations", + "Adds a heatmap indicator on the right edge of the file blame annotations" ], - "default": "working", - "markdownDescription": "Specifies whether to show a comparison of the current branch or the working tree with a user-selected reference (branch, tag. etc) in the _Commits_ view", + "markdownDescription": "Specifies where the heatmap indicators will be shown in the file blame annotations", "scope": "window", - "order": 10 + "order": 31 }, - "gitlens.views.commits.pullRequests.enabled": { + "gitlens.blame.avatars": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to query for pull requests associated with the current branch and commits in the _Commits_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to show avatar images in the file blame annotations", "scope": "window", - "order": 21 + "order": 40 }, - "gitlens.views.commits.pullRequests.showForBranches": { + "gitlens.blame.compact": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with the current branch in the _Commits_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to compact (deduplicate) matching adjacent file blame annotations", "scope": "window", - "order": 21 + "order": 50 }, - "gitlens.views.commits.pullRequests.showForCommits": { + "gitlens.blame.highlight.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Commits_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to highlight lines associated with the current line", "scope": "window", - "order": 22 + "order": 60 }, - "gitlens.views.commits.files.layout": { - "type": "string", - "default": "auto", - "enum": [ - "auto", - "list", - "tree" - ], - "enumDescriptions": [ - "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.commits.files.threshold#` value and the number of files at each nesting level", - "Displays files as a list", - "Displays files as a tree" + "gitlens.blame.highlight.locations": { + "type": "array", + "default": [ + "gutter", + "line", + "overview" ], - "markdownDescription": "Specifies how the _Commits_ view will display files", + "items": { + "type": "string", + "enum": [ + "gutter", + "line", + "overview" + ], + "enumDescriptions": [ + "Adds an indicator to the gutter", + "Adds a full-line highlight background color", + "Adds an indicator to the scroll bar" + ] + }, + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "markdownDescription": "Specifies where the associated line highlights will be shown", "scope": "window", - "order": 30 - }, - "gitlens.views.commits.files.threshold": { - "type": "number", - "default": 5, - "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Commits_ view. Only applies when `#gitlens.views.commits.files.layout#` is set to `auto`", - "scope": "window", - "order": 31 - }, - "gitlens.views.commits.files.compact": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Commits_ view. Only applies when `#gitlens.views.commits.files.layout#` is set to `tree` or `auto`", - "scope": "window", - "order": 32 + "order": 61 }, - "gitlens.views.commits.avatars": { + "gitlens.blame.separateLines": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Commits_ view", + "markdownDescription": "Specifies whether file blame annotations will be separated by a small gap", "scope": "window", - "order": 40 + "order": 70 }, - "gitlens.views.commits.reveal": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to reveal commits in the _Commits_ view, otherwise they revealed in the _Repositories_ view", + "gitlens.blame.dateFormat": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "Specifies how to format absolute dates (e.g. using the `${date}` token) in file blame annotations. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", "scope": "window", - "order": 50 + "order": 80 } } }, { - "id": "commit-details-view", - "title": "Commit Details View", - "order": 22, + "id": "file-changes", + "title": "File Changes", + "order": 16, "properties": { - "gitlens.views.commitDetails.autolinks.enabled": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to automatically link external resources in commit messages", - "scope": "window", - "order": 31 - }, - "gitlens.views.commitDetails.autolinks.enhanced": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to lookup additional details about automatically link external resources in commit messages. Requires a connection to a supported remote service (e.g. GitHub)", - "scope": "window", - "order": 32 - }, - "gitlens.views.commitDetails.pullRequests.enabled": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to query for associated pull requests. Requires a connection to a supported remote service (e.g. GitHub)", - "scope": "window", - "order": 21 - }, - "gitlens.views.commitDetails.files.layout": { + "gitlens.changes.toggleMode": { "type": "string", - "default": "auto", + "default": "file", "enum": [ - "auto", - "list", - "tree" + "file", + "window" ], "enumDescriptions": [ - "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.commitDetails.files.threshold#` value and the number of files at each nesting level", - "Displays files as a list", - "Displays files as a tree" + "Toggles each file individually", + "Toggles the window, i.e. all files at once" ], - "markdownDescription": "Specifies how the _Commit Details_ view will display files", - "scope": "window", - "order": 30 - }, - "gitlens.views.commitDetails.files.threshold": { - "type": "number", - "default": 5, - "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Commit Details_ view. Only applies when `#gitlens.views.commitDetails.files.layout#` is set to `auto`", - "scope": "window", - "order": 31 - }, - "gitlens.views.commitDetails.files.compact": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Commit Details_ view. Only applies when `#gitlens.views.commitDetails.files.layout#` is set to `tree` or `auto`", + "markdownDescription": "Specifies how the file changes annotations will be toggled", "scope": "window", - "order": 32 + "order": 10 }, - "gitlens.views.commitDetails.avatars": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Commit Details_ view", + "gitlens.changes.locations": { + "type": "array", + "default": [ + "gutter", + "line", + "overview" + ], + "items": { + "type": "string", + "enum": [ + "gutter", + "line", + "overview" + ], + "enumDescriptions": [ + "Adds an indicator to the gutter", + "Adds a full-line highlight background color", + "Adds an indicator to the scroll bar" + ] + }, + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "markdownDescription": "Specifies where the indicators of the file changes annotations will be shown", "scope": "window", - "order": 40 + "order": 20 } } }, { - "id": "repositories-view", - "title": "Repositories View", - "order": 23, + "id": "file-heatmap", + "title": "File Heatmap", + "order": 17, "properties": { - "gitlens.views.repositories.showBranchComparison": { - "type": [ - "boolean", - "string" - ], + "gitlens.heatmap.toggleMode": { + "type": "string", + "default": "file", "enum": [ - false, - "branch", - "working" + "file", + "window" ], "enumDescriptions": [ - "Hides the branch comparison", - "Compares the current branch with a user-selected reference", - "Compares the working tree with a user-selected reference" + "Toggles each file individually", + "Toggles the window, i.e. all files at once" ], - "default": "working", - "markdownDescription": "Specifies whether to show a comparison of the current branch or the working tree with a user-selected reference (branch, tag. etc) in the _Repositories_ view", + "markdownDescription": "Specifies how the file heatmap annotations will be toggled", "scope": "window", "order": 10 }, - "gitlens.views.repositories.showUpstreamStatus": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show the upstream status of the current branch for each repository in the _Repositories_ view", - "scope": "window", - "order": 11 - }, - "gitlens.views.repositories.includeWorkingTree": { - "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to include working tree file status for each repository in the _Repositories_ view", - "scope": "window", - "order": 12 - }, - "gitlens.views.repositories.pullRequests.enabled": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to query for pull requests associated with branches and commits in the _Repositories_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "gitlens.heatmap.locations": { + "type": "array", + "default": [ + "gutter", + "overview" + ], + "items": { + "type": "string", + "enum": [ + "gutter", + "line", + "overview" + ], + "enumDescriptions": [ + "Adds an indicator to the gutter", + "Adds a full-line highlight background color", + "Adds an indicator to the scroll bar" + ] + }, + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "markdownDescription": "Specifies where the indicators of the file heatmap annotations will be shown", "scope": "window", "order": 20 }, - "gitlens.views.repositories.pullRequests.showForBranches": { + "gitlens.heatmap.fadeLines": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with branches in the _Repositories_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies the whether to fade out older lines", "scope": "window", "order": 21 }, - "gitlens.views.repositories.pullRequests.showForCommits": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Repositories_ view. Requires a connection to a supported remote service (e.g. GitHub)", - "scope": "window", - "order": 22 - }, - "gitlens.views.repositories.showCommits": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show the commits on the current branch for each repository in the _Repositories_ view", + "gitlens.heatmap.ageThreshold": { + "type": "number", + "default": 90, + "markdownDescription": "Specifies the age of the most recent change (in days) after which the file heatmap annotations will be cold rather than hot (i.e. will use `#gitlens.heatmap.coldColor#` instead of `#gitlens.heatmap.hotColor#`)", "scope": "window", "order": 30 }, - "gitlens.views.repositories.showBranches": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show the branches for each repository in the _Repositories_ view", + "gitlens.heatmap.coldColor": { + "type": "string", + "default": "#0a60f6", + "markdownDescription": "Specifies the base color of the file heatmap annotations when the most recent change is older (cold) than the `#gitlens.heatmap.ageThreshold#` value", "scope": "window", - "order": 31 + "order": 40 }, - "gitlens.views.repositories.showRemotes": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show the remotes for each repository in the _Repositories_ view", + "gitlens.heatmap.hotColor": { + "type": "string", + "default": "#f66a0a", + "markdownDescription": "Specifies the base color of the file heatmap annotations when the most recent change is newer (hot) than the `#gitlens.heatmap.ageThreshold#` value", "scope": "window", - "order": 32 + "order": 50 + } + } + }, + { + "id": "graph", + "title": "Commit Graph (ᴘʀᴏ)", + "order": 50, + "properties": { + "gitlens.graph.layout": { + "type": "string", + "default": "panel", + "enum": [ + "editor", + "panel" + ], + "enumDescriptions": [ + "Prefer showing the Commit Graph in the editor area", + "Prefer showing the Commit Graph in the bottom panel" + ], + "markdownDescription": "Specifies the preferred layout of the _Commit Graph_", + "scope": "window", + "order": 1 }, - "gitlens.views.repositories.showStashes": { + "gitlens.graph.allowMultiple": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show the stashes for each repository in the _Repositories_ view", + "markdownDescription": "Specifies whether to allow opening multiple instances of the _Commit Graph_ in the editor area", "scope": "window", - "order": 33 + "order": 2 }, - "gitlens.views.repositories.showTags": { + "gitlens.graph.minimap.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show the tags for each repository in the _Repositories_ view", + "markdownDescription": "Specifies whether to show a minimap of commit activity above the _Commit Graph_", "scope": "window", - "order": 34 + "order": 100 }, - "gitlens.views.repositories.showContributors": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show the contributors for each repository in the _Repositories_ view", + "gitlens.graph.minimap.dataType": { + "type": "string", + "default": "commits", + "enum": [ + "commits", + "lines" + ], + "enumDescriptions": [ + "Shows the number of commits per day in the minimap", + "Shows the number of lines changed per day in the minimap" + ], + "markdownDescription": "Specifies the data to show on the minimap in the _Commit Graph_", "scope": "window", - "order": 35 + "order": 101 }, - "gitlens.views.repositories.showWorktrees": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show the worktrees for each repository in the _Repositories_ view", - "scope": "window", - "order": 36 - }, - "gitlens.views.repositories.showIncomingActivity": { - "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to show the experimental incoming activity for each repository in the _Repositories_ view", + "gitlens.graph.minimap.additionalTypes": { + "type": "array", + "default": [ + "localBranches", + "stashes" + ], + "items": { + "type": "string", + "enum": [ + "localBranches", + "remoteBranches", + "pullRequests", + "stashes", + "tags" + ], + "enumDescriptions": [ + "Marks the location of local branches", + "Marks the location of remote branches", + "Marks the location of pull requests", + "Marks the location of stashes", + "Marks the location of tags" + ] + }, + "minItems": 0, + "maxItems": 5, + "uniqueItems": true, + "markdownDescription": "Specifies additional markers to show on the minimap in the _Commit Graph_", "scope": "window", - "order": 37 + "order": 102 }, - "gitlens.views.repositories.autoRefresh": { + "gitlens.graph.scrollMarkers.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to automatically refresh the _Repositories_ view when the repository or the file system changes", + "markdownDescription": "Specifies whether to show markers on the scrollbar in the _Commit Graph_", "scope": "window", - "order": 40 + "order": 200 }, - "gitlens.views.repositories.autoReveal": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to automatically reveal repositories in the _Repositories_ view when opening files", + "gitlens.graph.scrollMarkers.additionalTypes": { + "type": "array", + "default": [ + "localBranches", + "stashes" + ], + "items": { + "type": "string", + "enum": [ + "localBranches", + "remoteBranches", + "pullRequests", + "stashes", + "tags" + ], + "enumDescriptions": [ + "Marks the location of local branches", + "Marks the location of remote branches", + "Marks the location of pull requests", + "Marks the location of stashes", + "Marks the location of tags" + ] + }, + "minItems": 0, + "maxItems": 5, + "uniqueItems": true, + "markdownDescription": "Specifies additional markers to show on the scrollbar in the _Commit Graph_", "scope": "window", - "order": 50 + "order": 201 }, - "gitlens.views.repositories.avatars": { + "gitlens.graph.sidebar.enabled": { "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Repositories_ view", + "default": false, + "markdownDescription": "Specifies whether to show a sidebar on the _Commit Graph_", "scope": "window", - "order": 60 + "order": 300 }, - "gitlens.views.repositories.branches.layout": { + "gitlens.graph.branchesVisibility": { "type": "string", - "default": "tree", "enum": [ - "list", - "tree" + "all", + "smart", + "current" ], "enumDescriptions": [ - "Displays branches as a list", - "Displays branches as a tree when branch names contain slashes `/`" + "Shows all branches", + "Shows only relevant branches", + "Shows only the current branch" ], - "markdownDescription": "Specifies how the _Repositories_ view will display branches", + "default": "all", + "markdownDescription": "Specifies the visibility of branches on the _Commit Graph_", "scope": "window", - "order": 70 + "order": 400 }, - "gitlens.views.repositories.files.layout": { - "type": "string", - "default": "auto", + "gitlens.graph.showDetailsView": { + "type": [ + "boolean", + "string" + ], + "default": "selection", "enum": [ - "auto", - "list", - "tree" + false, + "open", + "selection" ], "enumDescriptions": [ - "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.repositories.files.threshold#` value and the number of files at each nesting level", - "Displays files as a list", - "Displays files as a tree" + "Never shows the _Commit Details_ view automatically", + "Shows the _Commit Details_ view automatically only when opening the _Commit Graph_", + "Shows the _Commit Details_ view automatically when selection changes in the _Commit Graph_" ], - "markdownDescription": "Specifies how the _Repositories_ view will display files", - "scope": "window", - "order": 80 - }, - "gitlens.views.repositories.files.threshold": { - "type": "number", - "default": 5, - "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Repositories_ view. Only applies when `#gitlens.views.repositories.files.layout#` is set to `auto`", - "scope": "window", - "order": 81 - }, - "gitlens.views.repositories.files.compact": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Repositories_ view. Only applies when `#gitlens.views.repositories.files.layout#` is set to `tree` or `auto`", + "markdownDescription": "Specifies when to show the _Commit Details_ view for the selected row in the _Commit Graph_", "scope": "window", - "order": 82 + "order": 500 }, - "gitlens.views.repositories.compact": { - "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to show the _Repositories_ view in a compact display density", + "gitlens.graph.dateFormat": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "Specifies how absolute dates will be formatted in the _Commit Graph_. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", "scope": "window", - "order": 90 + "order": 600 }, - "gitlens.views.repositories.branches.showBranchComparison": { + "gitlens.graph.dateStyle": { "type": [ - "boolean", - "string" + "string", + "null" ], + "default": null, "enum": [ - false, - "branch" + "relative", + "absolute" ], "enumDescriptions": [ - "Hides the branch comparison", - "Compares the branch with a user-selected reference" + "e.g. 1 day ago", + "e.g. July 25th, 2018 7:18pm" ], - "default": "branch", - "markdownDescription": "Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag. etc) under each branch in the _Repositories_ view", + "markdownDescription": "Specifies how dates will be displayed in the _Commit Graph_", "scope": "window", - "order": 100 + "order": 601 }, - "gitlens.views.repositories.enabled": { - "deprecationMessage": "Deprecated. This setting is no longer used", - "markdownDeprecationMessage": "Deprecated. This setting is no longer used" - } - } - }, - { - "id": "file-history-view", - "title": "File History View", - "order": 24, - "properties": { - "gitlens.views.fileHistory.files.layout": { + "gitlens.graph.commitOrdering": { "type": "string", - "default": "auto", + "default": "date", "enum": [ - "auto", - "list", - "tree" + "date", + "author-date", + "topo" ], "enumDescriptions": [ - "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.fileHistory.files.threshold#` value and the number of files at each nesting level", - "Displays files as a list", - "Displays files as a tree" + "Shows commits in reverse chronological order of the commit timestamp", + "Shows commits in reverse chronological order of the author timestamp", + "Shows commits in reverse chronological order of the commit timestamp, but avoids intermixing multiple lines of history" ], - "markdownDescription": "Specifies how the _File History_ view will display files when showing the history of a folder", + "markdownDescription": "Specifies the order by which commits will be shown on the _Commit Graph_", "scope": "window", - "order": 10 + "order": 602 }, - "gitlens.views.fileHistory.files.threshold": { - "type": "number", - "default": 5, - "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _File History_ view. Only applies to folder history and when `#gitlens.views.fileHistory.files.layout#` is set to `auto`", - "scope": "window", - "order": 11 + "gitlens.graph.onlyFollowFirstParent": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to only follow the first parent when showing commits on the _Commit Graph_", + "order": 603 }, - "gitlens.views.fileHistory.files.compact": { + "gitlens.graph.dimMergeCommits": { "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _File History_ view. Only applies to folder history and when `#gitlens.views.fileHistory.files.layout#` is set to `tree` or `auto`", + "default": false, + "markdownDescription": "Specifies whether to dim (deemphasize) merge commit rows in the _Commit Graph_", "scope": "window", - "order": 12 + "order": 700 }, - "gitlens.views.fileHistory.avatars": { + "gitlens.graph.showUpstreamStatus": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of status icons in the _File History_ view", + "markdownDescription": "Specifies whether to show a local branch's upstream status in the _Commit Graph_", "scope": "window", - "order": 20 + "order": 701 }, - "gitlens.advanced.fileHistoryFollowsRenames": { + "gitlens.graph.showRemoteNames": { "type": "boolean", "default": false, - "markdownDescription": "Specifies whether file histories will follow renames — will affect how merge commits are shown in histories", + "markdownDescription": "Specifies whether to show remote names on remote branches in the _Commit Graph_", "scope": "window", - "order": 100 + "order": 702 }, - "gitlens.advanced.fileHistoryShowAllBranches": { - "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether file histories will show commits from all branches", - "scope": "window", - "order": 101 - } - } - }, - { - "id": "line-history-view", - "title": "Line History View", - "order": 25, - "properties": { - "gitlens.views.lineHistory.avatars": { + "gitlens.graph.pullRequests.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of status icons in the _Line History_ view", - "scope": "window", - "order": 10 - }, - "gitlens.views.lineHistory.enabled": { - "deprecationMessage": "Deprecated. This setting is no longer used", - "markdownDeprecationMessage": "Deprecated. This setting is no longer used" - } - } - }, - { - "id": "branches-view", - "title": "Branches View", - "order": 26, - "properties": { - "gitlens.views.branches.showBranchComparison": { - "type": [ - "boolean", - "string" - ], - "enum": [ - false, - "branch" - ], - "enumDescriptions": [ - "Hides the branch comparison", - "Compares the branch with a user-selected reference" - ], - "default": "branch", - "markdownDescription": "Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag. etc) in the _Branches_ view", + "markdownDescription": "Specifies whether to show associated pull requests on remote branches in the _Commit Graph_. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 10 + "order": 703 }, - "gitlens.views.branches.pullRequests.enabled": { + "gitlens.graph.avatars": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to query for pull requests associated with each branch and commits in the _Branches_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to show avatar images instead of author initials and remote icons in the _Commit Graph_", "scope": "window", - "order": 20 + "order": 704 }, - "gitlens.views.branches.pullRequests.showForBranches": { + "gitlens.graph.showGhostRefsOnRowHover": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with each branch in the _Branches_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to show a ghost branch / tag when hovering over or selecting a row in the _Commit Graph_", "scope": "window", - "order": 21 + "order": 750 }, - "gitlens.views.branches.pullRequests.showForCommits": { + "gitlens.graph.highlightRowsOnRefHover": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Branches_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to highlight rows associated with the branch / tag when hovering over it in the _Commit Graph_", "scope": "window", - "order": 22 + "order": 751 }, - "gitlens.views.branches.branches.layout": { - "type": "string", - "default": "tree", - "enum": [ - "list", - "tree" - ], - "enumDescriptions": [ - "Displays branches as a list", - "Displays branches as a tree when branch names contain slashes `/`" - ], - "markdownDescription": "Specifies how the _Branches_ view will display branches", - "scope": "window", - "order": 30 - }, - "gitlens.sortBranchesBy": { - "type": "string", - "default": "date:desc", - "enum": [ - "date:desc", - "date:asc", - "name:asc", - "name:desc" - ], - "enumDescriptions": [ - "Sorts branches by the most recent commit date in descending order", - "Sorts branches by the most recent commit date in ascending order", - "Sorts branches by name in ascending order", - "Sorts branches by name in descending order" - ], - "markdownDescription": "Specifies how branches are sorted in quick pick menus and views", - "scope": "window", - "order": 40 - }, - "gitlens.views.branches.files.layout": { - "type": "string", - "default": "auto", - "enum": [ - "auto", - "list", - "tree" - ], - "enumDescriptions": [ - "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.branches.files.threshold#` value and the number of files at each nesting level", - "Displays files as a list", - "Displays files as a tree" - ], - "markdownDescription": "Specifies how the _Branches_ view will display files", + "gitlens.graph.defaultItemLimit": { + "type": "number", + "default": 500, + "markdownDescription": "Specifies the default number of items to show in the _Commit Graph_. Use 0 to specify no limit", "scope": "window", - "order": 50 + "order": 800 }, - "gitlens.views.branches.files.threshold": { + "gitlens.graph.pageItemLimit": { "type": "number", - "default": 5, - "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Branches_ view. Only applies when `#gitlens.views.branches.files.layout#` is set to `auto`", + "default": 200, + "markdownDescription": "Specifies the number of additional items to fetch when paginating in the _Commit Graph_. Use 0 to specify no limit", "scope": "window", - "order": 51 + "order": 801 }, - "gitlens.views.branches.files.compact": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Branches_ view. Only applies when `#gitlens.views.branches.files.layout#` is set to `tree` or `auto`", + "gitlens.graph.searchItemLimit": { + "type": "number", + "default": 100, + "markdownDescription": "Specifies the number of results to gather when searching in the _Commit Graph_. Use 0 to specify no limit", "scope": "window", - "order": 52 + "order": 802 }, - "gitlens.views.branches.avatars": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Branches_ view", + "gitlens.graph.scrollRowPadding": { + "type": "number", + "default": 0, + "markdownDescription": "Specifies the number of rows from the edge at which the graph will scroll when using keyboard or search to change the selected row", "scope": "window", - "order": 60 + "order": 900 }, - "gitlens.views.branches.reveal": { + "gitlens.graph.statusBar.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to reveal branches in the _Branches_ view, otherwise they revealed in the _Repositories_ view", + "markdownDescription": "Specifies whether to show the _Commit Graph_ in the status bar", "scope": "window", - "order": 70 + "order": 1000 } } }, { - "id": "remotes-view", - "title": "Remotes View", - "order": 27, + "id": "focus", + "title": "Launchpad (ᴘʀᴏ)", + "order": 60, "properties": { - "gitlens.views.remotes.pullRequests.enabled": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to query for pull requests associated with each branch and commits in the _Remotes_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "gitlens.launchpad.ignoredRepositories": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "markdownDescription": "Specifies the repositories to ignore in the _Launchpad_", "scope": "window", "order": 10 }, - "gitlens.views.remotes.pullRequests.showForBranches": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with each branch in the _Remotes_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "gitlens.launchpad.ignoredOrganizations": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "markdownDescription": "Specifies the organizations to ignore in the _Launchpad_", "scope": "window", "order": 11 }, - "gitlens.views.remotes.pullRequests.showForCommits": { + "gitlens.launchpad.staleThreshold": { + "type": [ + "number", + "null" + ], + "default": null, + "markdownDescription": "Specifies the number of days after which a pull request is considered stale and moved to Other in the _Launchpad_", + "scope": "window", + "order": 20 + }, + "gitlens.launchpad.indicator.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Remotes_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to enable status bar indicator for _Launchpad_", "scope": "window", - "order": 12 + "order": 100 }, - "gitlens.views.remotes.branches.layout": { + "gitlens.launchpad.indicator.icon": { "type": "string", - "default": "tree", "enum": [ - "list", - "tree" + "default", + "group" ], "enumDescriptions": [ - "Displays branches as a list", - "Displays branches as a tree when branch names contain slashes `/`" + "Shows the Launchpad icon", + "Shows the icon of the highest priority group" ], - "markdownDescription": "Specifies how the _Remotes_ view will display branches", + "default": "default", + "markdownDescription": "Specifies the style of the _Launchpad_ status bar indicator icon", "scope": "window", - "order": 20 + "order": 110 }, - "gitlens.views.remotes.files.layout": { - "type": "string", - "default": "auto", + "gitlens.launchpad.indicator.label": { + "type": [ + "boolean", + "string" + ], "enum": [ - "auto", - "list", - "tree" + false, + "item", + "counts" ], "enumDescriptions": [ - "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.remotes.files.threshold#` value and the number of files at each nesting level", - "Displays files as a list", - "Displays files as a tree" + "Hides the label", + "Shows the highest priority item which needs your attention", + "Shows the status counts of items which need your attention" ], - "markdownDescription": "Specifies how the _Remotes_ view will display files", + "default": "item", + "markdownDescription": "Specifies the display of the _Launchpad_ status bar indicator label", "scope": "window", - "order": 30 + "order": 120 }, - "gitlens.views.remotes.files.threshold": { - "type": "number", - "default": 5, - "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Remotes_ view. Only applies when `#gitlens.views.remotes.files.layout#` is set to `auto`", + "gitlens.launchpad.indicator.groups": { + "type": "array", + "default": [ + "mergeable", + "blocked", + "needs-review", + "follow-up" + ], + "items": { + "type": "string", + "enum": [ + "mergeable", + "blocked", + "needs-review", + "follow-up" + ], + "enumDescriptions": [ + "Shows mergeable pull requests", + "Shows blocked pull requests", + "Shows pull requests needing your review", + "Shows pull requests needing follow-up" + ] + }, + "minItems": 1, + "uniqueItems": true, + "markdownDescription": "Specifies the groups of pull requests to show on the _Launchpad_ status bar indicator", "scope": "window", - "order": 31 + "order": 130 }, - "gitlens.views.remotes.files.compact": { + "gitlens.launchpad.indicator.useColors": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to use colors on the _Launchpad_ status bar indicator", + "scope": "window", + "order": 140 + }, + "gitlens.launchpad.indicator.polling.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Remotes_ view. Only applies when `#gitlens.views.remotes.files.layout#` is set to `tree` or `auto`", + "markdownDescription": "Specifies whether the status bar indicator will fetch and display pull request data for _Launchpad_", "scope": "window", - "order": 32 + "order": 150 }, - "gitlens.views.remotes.avatars": { + "gitlens.launchpad.indicator.polling.interval": { + "type": "number", + "default": 30, + "markdownDescription": "Specifies the rate (in minutes) at which the status bar indicator will fetch pull request data for _Launchpad_. Use 0 to disable automatic polling", + "scope": "window", + "order": 160 + }, + "gitlens.launchpad.allowMultiple": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Remotes_ view", + "markdownDescription": "Specifies whether to allow opening multiple instances of the _Launchpad_ as an editor tab", "scope": "window", - "order": 40 + "order": 1000 }, - "gitlens.views.remotes.reveal": { + "gitlens.launchpad.experimental.queryLimit": { + "type": "number", + "default": 100, + "markdownDescription": "(Experimental) Specifies a limit on the number of pull requests to be queried in the _Launchpad_", + "scope": "window", + "order": 1100 + } + } + }, + { + "id": "cloud-patches", + "title": "Cloud Patches (ᴘʀᴇᴠÉĒᴇᴡ)", + "order": 70, + "properties": { + "gitlens.cloudPatches.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to reveal remotes in the _Remotes_ view, otherwise they revealed in the _Repositories_ view", + "markdownDescription": "Specifies whether to enable the preview of _Cloud Patches_, which allow you to easily and securely share code with your teammates or other developers", "scope": "window", - "order": 50 + "order": 10 } } }, { - "id": "stashes-view", - "title": "Stashes View", - "order": 28, + "id": "views", + "title": "Views", + "order": 100, "properties": { - "gitlens.views.stashes.files.layout": { - "type": "string", - "default": "auto", - "enum": [ - "auto", - "list", - "tree" - ], - "enumDescriptions": [ - "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.stashes.files.threshold#` value and the number of files at each nesting level", - "Displays files as a list", - "Displays files as a tree" - ], - "markdownDescription": "Specifies how the _Stashes_ view will display files", + "gitlens.views.collapseWorktreesWhenPossible": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to try to collapse the opened worktrees into a single (common) repository in the views when possible", + "scope": "window", + "order": 1 + }, + "gitlens.views.showCurrentBranchOnTop": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to always show the current branch at the top of the views", + "scope": "window", + "order": 2 + }, + "gitlens.views.defaultItemLimit": { + "type": "number", + "default": 10, + "markdownDescription": "Specifies the default number of items to show in a view list. Use 0 to specify no limit", "scope": "window", "order": 10 }, - "gitlens.views.stashes.files.threshold": { + "gitlens.views.pageItemLimit": { "type": "number", - "default": 5, - "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Stashes_ view. Only applies when `#gitlens.views.stashes.files.layout#` is set to `auto`", + "default": 40, + "markdownDescription": "Specifies the number of items to show in a each page when paginating a view list. Use 0 to specify no limit", "scope": "window", "order": 11 }, - "gitlens.views.stashes.files.compact": { + "gitlens.views.showRelativeDateMarkers": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Stashes_ view. Only applies when `#gitlens.views.stashes.files.layout#` is set to `tree` or `auto`", + "markdownDescription": "Specifies whether to show relative date markers (_Less than a week ago_, _Over a week ago_, _Over a month ago_, etc) on revision (commit) histories in the views", "scope": "window", - "order": 12 + "order": 20 }, - "gitlens.views.stashes.reveal": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to reveal stashes in the _Stashes_ view, otherwise they revealed in the _Repositories_ view", + "gitlens.views.formats.commits.label": { + "type": "string", + "default": "${❰ tips|11? ❱➤ }${message}", + "markdownDescription": "Specifies the format of commits in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", "scope": "window", - "order": 20 - } - } - }, - { - "id": "tags-view", - "title": "Tags View", - "order": 29, - "properties": { - "gitlens.views.tags.branches.layout": { + "order": 30 + }, + "gitlens.views.formats.commits.description": { "type": "string", - "default": "tree", - "enum": [ - "list", - "tree" - ], - "enumDescriptions": [ - "Displays tags as a list", - "Displays tags as a tree when tags names contain slashes `/`" - ], - "markdownDescription": "Specifies how the _Tags_ view will display tags", + "default": "${author, }${agoOrDate}", + "markdownDescription": "Specifies the description format of commits in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", "scope": "window", - "order": 10 + "order": 31 }, - "gitlens.sortTagsBy": { + "gitlens.views.formats.commits.tooltip": { "type": "string", - "default": "date:desc", - "enum": [ - "date:desc", - "date:asc", - "name:asc", - "name:desc" - ], - "enumDescriptions": [ - "Sorts tags by date in descending order", - "Sorts tags by date in ascending order", - "Sorts tags by name in ascending order", - "Sorts tags by name in descending order" - ], - "markdownDescription": "Specifies how tags are sorted in quick pick menus and views", + "default": "${avatar}  __${author}__  $(history) ${ago} _(${date})_ \\\n${link}${' via 'pullRequest}${'  â€ĸ  'changesDetail} ${message}${\n\n---\n\nfootnotes}\n\n${tips}", + "markdownDescription": "Specifies the tooltip format (in markdown) of commits in the views. See [_Commit Tokens_](https://github.com/eamodio/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", "scope": "window", - "order": 20 + "order": 32 }, - "gitlens.views.tags.files.layout": { + "gitlens.views.formats.commits.tooltipWithStatus": { + "type": "string", + "default": "${avatar}  __${author}__  $(history) ${ago} _(${date})_ \\\n${link}${' via 'pullRequest}  â€ĸ  {{slot-status}}${'  â€ĸ  'changesDetail} ${message}${\n\n---\n\nfootnotes}\n\n${tips}", + "markdownDescription": "Specifies the tooltip format (in markdown) of \"file\" commits in the views. See [_Commit Tokens_](https://github.com/eamodio/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", + "scope": "window", + "order": 32 + }, + "gitlens.views.formats.files.label": { + "type": "string", + "default": "${working }${file}", + "markdownDescription": "Specifies the format of a file in the views. See [_File Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#file-tokens) in the GitLens docs", + "scope": "window", + "order": 40 + }, + "gitlens.views.formats.files.description": { + "type": "string", + "default": "${directory}${ ← originalPath}", + "markdownDescription": "Specifies the description format of a file in the views. See [_File Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#file-tokens) in the GitLens docs", + "scope": "window", + "order": 41 + }, + "gitlens.views.formats.stashes.label": { + "type": "string", + "default": "${message}", + "markdownDescription": "Specifies the format of stashes in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", + "scope": "window", + "order": 50 + }, + "gitlens.views.formats.stashes.description": { + "type": "string", + "default": "${stashOnRef, }${agoOrDate}", + "markdownDescription": "Specifies the description format of stashes in the views. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", + "scope": "window", + "order": 51 + }, + "gitlens.views.formats.stashes.tooltip": { + "type": "string", + "default": "${link}${' on `'stashOnRef`}${'  â€ĸ  'changesDetail} \\\n  $(history) ${ago} _(${date})_ ${message}${\n\n---\n\nfootnotes}", + "markdownDescription": "Specifies the tooltip format (in markdown) of stashes in the views. See [_Commit Tokens_](https://github.com/eamodio/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs", + "scope": "window", + "order": 52 + }, + "gitlens.views.openChangesInMultiDiffEditor": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to open multiple changes in the multi-diff editor (single tab) or in individual diff editors (multiple tabs)", + "scope": "window", + "order": 60 + }, + "gitlens.views.commitFileFormat": { + "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.label` instead", + "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.label#` instead" + }, + "gitlens.views.commitFileDescriptionFormat": { + "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.description` instead", + "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.description#` instead" + }, + "gitlens.views.commitFormat": { + "deprecationMessage": "Deprecated. Use `gitlens.views.formats.commits.label` instead", + "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.commits.files.label#` instead" + }, + "gitlens.views.commitDescriptionFormat": { + "deprecationMessage": "Deprecated. Use `gitlens.views.formats.commits.description` instead", + "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.commits.description#` instead" + }, + "gitlens.views.stashFileFormat": { + "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.label` instead", + "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.label#` instead" + }, + "gitlens.views.stashFileDescriptionFormat": { + "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.description` instead", + "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.description#` instead" + }, + "gitlens.views.stashFormat": { + "deprecationMessage": "Deprecated. Use `gitlens.views.formats.stashes.label` instead", + "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.stashes.files.label#` instead" + }, + "gitlens.views.stashDescriptionFormat": { + "deprecationMessage": "Deprecated. Use `gitlens.views.formats.stashes.description` instead", + "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.stashes.description#` instead" + }, + "gitlens.views.statusFileFormat": { + "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.label` instead", + "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.label#` instead" + }, + "gitlens.views.statusFileDescriptionFormat": { + "deprecationMessage": "Deprecated. Use `gitlens.views.formats.files.description` instead", + "markdownDeprecationMessage": "Deprecated. Use `#gitlens.views.formats.files.description#` instead" + } + } + }, + { + "id": "launchpad-view", + "title": "Launchpad View (ᴇxᴘᴇʀÉĒᴍᴇɴᴛᴀʟ)", + "order": 101, + "properties": { + "gitlens.views.launchpad.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "(Experimental) Specifies whether to enable an experimental _Launchpad_ view", + "scope": "window", + "order": 10 + }, + "gitlens.views.launchpad.files.layout": { "type": "string", "default": "auto", "enum": [ @@ -1624,124 +1553,99 @@ "tree" ], "enumDescriptions": [ - "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.tags.files.threshold#` value and the number of files at each nesting level", + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.launchpad.files.threshold#` value and the number of files at each nesting level", "Displays files as a list", "Displays files as a tree" ], - "markdownDescription": "Specifies how the _Tags_ view will display files", + "markdownDescription": "Specifies how the _Launchpad_ view will display files", "scope": "window", "order": 30 }, - "gitlens.views.tags.files.threshold": { + "gitlens.views.launchpad.files.threshold": { "type": "number", "default": 5, - "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Tags_ view. Only applies when `#gitlens.views.tags.files.layout#` is set to `auto`", + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Launchpad_ view. Only applies when `#gitlens.views.launchpad.files.layout#` is set to `auto`", "scope": "window", "order": 31 }, - "gitlens.views.tags.files.compact": { + "gitlens.views.launchpad.files.compact": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Tags_ view. Only applies when `#gitlens.views.tags.files.layout#` is set to `tree` or `auto`", + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Launchpad_ view. Only applies when `#gitlens.views.launchpad.files.layout#` is set to `tree` or `auto`", "scope": "window", "order": 32 }, - "gitlens.views.tags.avatars": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Tags_ view", + "gitlens.views.launchpad.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Launchpad_ view will display file icons", "scope": "window", - "order": 40 + "order": 33 }, - "gitlens.views.tags.reveal": { + "gitlens.views.launchpad.avatars": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to reveal tags in the _Tags_ view, otherwise they revealed in the _Repositories_ view", + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Launchpad_ view", "scope": "window", - "order": 50 + "order": 40 } } }, { - "id": "worktrees-view", - "title": "Worktrees View", - "order": 30, + "id": "commits-view", + "title": "Commits View", + "order": 110, "properties": { - "gitlens.worktrees.promptForLocation": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to prompt for a path when creating new worktrees", - "scope": "resource", - "order": 10 - }, - "gitlens.worktrees.defaultLocation": { - "type": "string", - "default": null, - "markdownDescription": "Specifies the default path in which new worktrees will be created", - "scope": "resource", - "order": 11 - }, - "gitlens.worktrees.openAfterCreate": { - "type": "string", - "default": "prompt", - "enum": [ - "always", - "alwaysNewWindow", - "onlyWhenEmpty", - "never", - "prompt" - ], - "enumDescriptions": [ - "Always open the new worktree in the current window", - "Always open the new worktree in a new window", - "Only open the new worktree in the current window when no folder is opened", - "Never open the new worktree", - "Always prompt to open the new worktree" - ], - "markdownDescription": "Specifies how and when to open a worktree after it is created", - "scope": "resource", - "order": 12 - }, - "gitlens.views.worktrees.showBranchComparison": { + "gitlens.views.commits.showBranchComparison": { "type": [ "boolean", "string" ], "enum": [ false, - "branch" + "branch", + "working" ], "enumDescriptions": [ "Hides the branch comparison", - "Compares the worktree branch with a user-selected reference" + "Compares the current branch with a user-selected reference", + "Compares the working tree with a user-selected reference" ], "default": "working", - "markdownDescription": "Specifies whether to show a comparison of the worktree branch with a user-selected reference (branch, tag. etc) in the _Worktrees_ view", + "markdownDescription": "Specifies whether to show a comparison of the current branch or the working tree with a user-selected reference (branch, tag, etc) in the _Commits_ view", "scope": "window", - "order": 20 + "order": 10 }, - "gitlens.views.worktrees.pullRequests.enabled": { + "gitlens.views.commits.pullRequests.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to query for pull requests associated with the worktree branch and commits in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to query for pull requests associated with the current branch and commits in the _Commits_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 30 + "order": 21 }, - "gitlens.views.worktrees.pullRequests.showForBranches": { + "gitlens.views.commits.pullRequests.showForBranches": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with the worktree branch in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to show pull requests (if any) associated with the current branch in the _Commits_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 31 + "order": 22 }, - "gitlens.views.worktrees.pullRequests.showForCommits": { + "gitlens.views.commits.pullRequests.showForCommits": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Commits_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 32 + "order": 23 }, - "gitlens.views.worktrees.files.layout": { + "gitlens.views.commits.files.layout": { "type": "string", "default": "auto", "enum": [ @@ -1750,101 +1654,86 @@ "tree" ], "enumDescriptions": [ - "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.worktrees.files.threshold#` value and the number of files at each nesting level", + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.commits.files.threshold#` value and the number of files at each nesting level", "Displays files as a list", "Displays files as a tree" ], - "markdownDescription": "Specifies how the _Worktrees_ view will display files", + "markdownDescription": "Specifies how the _Commits_ view will display files", "scope": "window", - "order": 40 + "order": 30 }, - "gitlens.views.worktrees.files.threshold": { + "gitlens.views.commits.files.threshold": { "type": "number", "default": 5, - "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Worktrees_ view. Only applies when `#gitlens.views.worktrees.files.layout#` is set to `auto`", + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Commits_ view. Only applies when `#gitlens.views.commits.files.layout#` is set to `auto`", "scope": "window", - "order": 41 + "order": 31 }, - "gitlens.views.worktrees.files.compact": { + "gitlens.views.commits.files.compact": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Worktrees_ view. Only applies when `#gitlens.views.worktrees.files.layout#` is set to `tree` or `auto`", + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Commits_ view. Only applies when `#gitlens.views.commits.files.layout#` is set to `tree` or `auto`", "scope": "window", - "order": 42 + "order": 32 }, - "gitlens.views.worktrees.avatars": { + "gitlens.views.commits.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Commits_ view will display file icons", + "scope": "window", + "order": 33 + }, + "gitlens.views.commits.avatars": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Worktrees_ view", + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Commits_ view", "scope": "window", - "order": 50 + "order": 40 }, - "gitlens.views.worktrees.reveal": { + "gitlens.views.commits.reveal": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to reveal worktrees in the _Worktrees_ view, otherwise they revealed in the _Repositories_ view", + "markdownDescription": "Specifies whether to reveal commits in the _Commits_ view, otherwise they revealed in the _Repositories_ view", "scope": "window", - "order": 60 + "order": 50 } } }, { - "id": "contributors-view", - "title": "Contributors View", - "order": 31, + "id": "commit-details-view", + "title": "Inspect View", + "order": 120, "properties": { - "gitlens.views.contributors.showAllBranches": { + "gitlens.views.commitDetails.autolinks.enabled": { "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to show commits from all branches in the _Contributors_ view", + "default": true, + "markdownDescription": "Specifies whether to automatically link external resources in commit messages", "scope": "window", - "order": 10 + "order": 31 }, - "gitlens.views.contributors.showStatistics": { + "gitlens.views.commitDetails.autolinks.enhanced": { "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to show contributor statistics in the _Contributors_ view. This can take a while to compute depending on the repository size", + "default": true, + "markdownDescription": "Specifies whether to lookup additional details about automatically link external resources in commit messages. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 20 - }, - "gitlens.views.contributors.pullRequests.enabled": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to query for pull requests associated with branches and commits in the _Contributors_ view. Requires a connection to a supported remote service (e.g. GitHub)", - "scope": "window", - "order": 30 + "order": 32 }, - "gitlens.views.contributors.pullRequests.showForCommits": { + "gitlens.views.commitDetails.pullRequests.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Contributors_ view. Requires a connection to a supported remote service (e.g. GitHub)", - "scope": "window", - "order": 31 - }, - "gitlens.sortContributorsBy": { - "type": "string", - "default": "count:desc", - "enum": [ - "count:desc", - "count:asc", - "date:desc", - "date:asc", - "name:asc", - "name:desc" - ], - "enumDescriptions": [ - "Sorts contributors by commit count in descending order", - "Sorts contributors by commit count in ascending order", - "Sorts contributors by the most recent commit date in descending order", - "Sorts contributors by the most recent commit date in ascending order", - "Sorts contributors by name in ascending order", - "Sorts contributors by name in descending order" - ], - "markdownDescription": "Specifies how contributors are sorted in quick pick menus and views", + "markdownDescription": "Specifies whether to query for associated pull requests. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 40 + "order": 21 }, - "gitlens.views.contributors.files.layout": { + "gitlens.views.commitDetails.files.layout": { "type": "string", "default": "auto", "enum": [ @@ -1853,64 +1742,58 @@ "tree" ], "enumDescriptions": [ - "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.contributors.files.threshold#` value and the number of files at each nesting level", + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.commitDetails.files.threshold#` value and the number of files at each nesting level", "Displays files as a list", "Displays files as a tree" ], - "markdownDescription": "Specifies how the _Contributors_ view will display files", + "markdownDescription": "Specifies how the _Commit Details_ view will display files", "scope": "window", - "order": 50 + "order": 30 }, - "gitlens.views.contributors.files.threshold": { + "gitlens.views.commitDetails.files.threshold": { "type": "number", "default": 5, - "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Contributors_ view. Only applies when `#gitlens.views.contributors.files.layout#` is set to `auto`", + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Commit Details_ view. Only applies when `#gitlens.views.commitDetails.files.layout#` is set to `auto`", "scope": "window", - "order": 51 + "order": 31 }, - "gitlens.views.contributors.files.compact": { + "gitlens.views.commitDetails.files.compact": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Contributors_ view. Only applies when `#gitlens.views.contributors.files.layout#` is set to `tree` or `auto`", + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Commit Details_ view. Only applies when `#gitlens.views.commitDetails.files.layout#` is set to `tree` or `auto`", "scope": "window", - "order": 52 + "order": 32 }, - "gitlens.views.contributors.avatars": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Contributors_ view", + "gitlens.views.commitDetails.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Commit Details_ view will display file icons", "scope": "window", - "order": 60 + "order": 33 }, - "gitlens.views.contributors.reveal": { + "gitlens.views.commitDetails.avatars": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to reveal contributors in the _Contributors_ view, otherwise they revealed in the _Repositories_ view", + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Commit Details_ view", "scope": "window", - "order": 20 + "order": 40 } } }, { - "id": "search-compare-view", - "title": "Search & Compare View", - "order": 32, + "id": "pull-request-view", + "title": "Pull Request View", + "order": 130, "properties": { - "gitlens.views.searchAndCompare.pullRequests.enabled": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to query for pull requests associated with commits in the _Search & Compare_ view. Requires a connection to a supported remote service (e.g. GitHub)", - "scope": "window", - "order": 10 - }, - "gitlens.views.searchAndCompare.pullRequests.showForCommits": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Search & Compare_ view. Requires a connection to a supported remote service (e.g. GitHub)", - "scope": "window", - "order": 11 - }, - "gitlens.views.searchAndCompare.files.layout": { + "gitlens.views.pullRequest.files.layout": { "type": "string", "default": "auto", "enum": [ @@ -1919,2040 +1802,3192 @@ "tree" ], "enumDescriptions": [ - "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.searchAndCompare.files.threshold#` value and the number of files at each nesting level", + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.pullRequest.files.threshold#` value and the number of files at each nesting level", "Displays files as a list", "Displays files as a tree" ], - "markdownDescription": "Specifies how the _Search & Compare_ view will display files", + "markdownDescription": "Specifies how the _Pull Request_ view will display files", "scope": "window", - "order": 20 + "order": 30 }, - "gitlens.views.searchAndCompare.files.threshold": { + "gitlens.views.pullRequest.files.threshold": { "type": "number", "default": 5, - "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Search & Compare_ view. Only applies when `#gitlens.views.searchAndCompare.files.layout#` is set to `auto`", + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Pull Request_ view. Only applies when `#gitlens.views.pullRequest.files.layout#` is set to `auto`", "scope": "window", - "order": 21 + "order": 31 }, - "gitlens.views.searchAndCompare.files.compact": { + "gitlens.views.pullRequest.files.compact": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Search & Compare_ view. Only applies when `#gitlens.views.searchAndCompare.files.layout#` is set to `tree` or `auto`", + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Pull Request_ view. Only applies when `#gitlens.views.pullRequest.files.layout#` is set to `tree` or `auto`", "scope": "window", - "order": 22 + "order": 32 }, - "gitlens.views.searchAndCompare.avatars": { + "gitlens.views.pullRequest.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Pull Request_ view will display file icons", + "scope": "window", + "order": 33 + }, + "gitlens.views.pullRequest.avatars": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Search & Compare_ view", + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Pull Request_ view", "scope": "window", - "order": 30 + "order": 40 } } }, { - "id": "file-blame", - "title": "File Blame", - "order": 100, + "id": "repositories-view", + "title": "Repositories View", + "order": 140, "properties": { - "gitlens.blame.toggleMode": { - "type": "string", - "default": "file", + "gitlens.views.repositories.showBranchComparison": { + "type": [ + "boolean", + "string" + ], "enum": [ - "file", - "window" + false, + "branch", + "working" ], "enumDescriptions": [ - "Toggles each file individually", - "Toggles the window, i.e. all files at once" + "Hides the branch comparison", + "Compares the current branch with a user-selected reference", + "Compares the working tree with a user-selected reference" ], - "markdownDescription": "Specifies how the file blame annotations will be toggled", + "default": "working", + "markdownDescription": "Specifies whether to show a comparison of the current branch or the working tree with a user-selected reference (branch, tag, etc) in the _Repositories_ view", "scope": "window", "order": 10 }, - "gitlens.blame.format": { - "type": "string", - "default": "${message|50?} ${agoOrDate|14-}", - "markdownDescription": "Specifies the format of the file blame annotations. See [_Commit Tokens_](https://github.com/gitkraken/vscode-gitlens/wiki/Custom-Formatting#commit-tokens) in the GitLens docs. Date formatting is controlled by the `#gitlens.blame.dateFormat#` setting", + "gitlens.views.repositories.showUpstreamStatus": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the upstream status of the current branch for each repository in the _Repositories_ view", + "scope": "window", + "order": 11 + }, + "gitlens.views.repositories.includeWorkingTree": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to include working tree file status for each repository in the _Repositories_ view", + "scope": "window", + "order": 12 + }, + "gitlens.views.repositories.pullRequests.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to query for pull requests associated with branches and commits in the _Repositories_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", "order": 20 }, - "gitlens.blame.heatmap.enabled": { + "gitlens.views.repositories.pullRequests.showForBranches": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to provide a heatmap indicator in the file blame annotations", + "markdownDescription": "Specifies whether to show pull requests (if any) associated with branches in the _Repositories_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 21 + }, + "gitlens.views.repositories.pullRequests.showForCommits": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Repositories_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 22 + }, + "gitlens.views.repositories.showCommits": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the commits on the current branch for each repository in the _Repositories_ view", "scope": "window", "order": 30 }, - "gitlens.blame.heatmap.location": { - "type": "string", - "default": "right", - "enum": [ - "left", - "right" - ], - "enumDescriptions": [ - "Adds a heatmap indicator on the left edge of the file blame annotations", - "Adds a heatmap indicator on the right edge of the file blame annotations" - ], - "markdownDescription": "Specifies where the heatmap indicators will be shown in the file blame annotations", + "gitlens.views.repositories.showBranches": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the branches for each repository in the _Repositories_ view", "scope": "window", "order": 31 }, - "gitlens.blame.avatars": { + "gitlens.views.repositories.showRemotes": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show avatar images in the file blame annotations", + "markdownDescription": "Specifies whether to show the remotes for each repository in the _Repositories_ view", "scope": "window", - "order": 40 + "order": 32 }, - "gitlens.blame.compact": { + "gitlens.views.repositories.showStashes": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to compact (deduplicate) matching adjacent file blame annotations", + "markdownDescription": "Specifies whether to show the stashes for each repository in the _Repositories_ view", "scope": "window", - "order": 50 + "order": 33 }, - "gitlens.blame.highlight.enabled": { + "gitlens.views.repositories.showTags": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to highlight lines associated with the current line", + "markdownDescription": "Specifies whether to show the tags for each repository in the _Repositories_ view", "scope": "window", - "order": 60 + "order": 34 }, - "gitlens.blame.highlight.locations": { - "type": "array", - "default": [ - "gutter", - "line", - "overview" - ], - "items": { - "type": "string", - "enum": [ - "gutter", - "line", - "overview" - ], - "enumDescriptions": [ - "Adds an indicator to the gutter", - "Adds a full-line highlight background color", - "Adds an indicator to the scroll bar" - ] - }, - "minItems": 1, - "maxItems": 3, - "uniqueItems": true, - "markdownDescription": "Specifies where the associated line highlights will be shown", + "gitlens.views.repositories.showContributors": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the contributors for each repository in the _Repositories_ view", "scope": "window", - "order": 61 + "order": 35 }, - "gitlens.blame.separateLines": { + "gitlens.views.repositories.showWorktrees": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether file blame annotations will be separated by a small gap", + "markdownDescription": "Specifies whether to show the worktrees for each repository in the _Repositories_ view", "scope": "window", - "order": 70 + "order": 36 }, - "gitlens.blame.dateFormat": { - "type": [ - "string", - "null" - ], - "default": null, - "markdownDescription": "Specifies how to format absolute dates (e.g. using the `${date}` token) in file blame annotations. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", - "scope": "window", - "order": 80 - } - } - }, - { - "id": "file-changes", - "title": "File Changes", - "order": 101, - "properties": { - "gitlens.changes.toggleMode": { - "type": "string", - "default": "file", - "enum": [ - "file", - "window" - ], - "enumDescriptions": [ - "Toggles each file individually", - "Toggles the window, i.e. all files at once" - ], - "markdownDescription": "Specifies how the file changes annotations will be toggled", - "scope": "window", - "order": 10 - }, - "gitlens.changes.locations": { - "type": "array", - "default": [ - "gutter", - "line", - "overview" - ], - "items": { - "type": "string", - "enum": [ - "gutter", - "line", - "overview" - ], - "enumDescriptions": [ - "Adds an indicator to the gutter", - "Adds a full-line highlight background color", - "Adds an indicator to the scroll bar" - ] - }, - "minItems": 1, - "maxItems": 3, - "uniqueItems": true, - "markdownDescription": "Specifies where the indicators of the file changes annotations will be shown", - "scope": "window", - "order": 20 - } - } - }, - { - "id": "file-heatmap", - "title": "File Heatmap", - "order": 102, - "properties": { - "gitlens.heatmap.toggleMode": { - "type": "string", - "default": "file", - "enum": [ - "file", - "window" - ], - "enumDescriptions": [ - "Toggles each file individually", - "Toggles the window, i.e. all files at once" - ], - "markdownDescription": "Specifies how the file heatmap annotations will be toggled", + "gitlens.views.repositories.showIncomingActivity": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to show the experimental incoming activity for each repository in the _Repositories_ view", "scope": "window", - "order": 10 + "order": 37 }, - "gitlens.heatmap.locations": { - "type": "array", - "default": [ - "gutter", - "line", - "overview" - ], - "items": { - "type": "string", - "enum": [ - "gutter", - "line", - "overview" - ], - "enumDescriptions": [ - "Adds an indicator to the gutter", - "Adds a full-line highlight background color", - "Adds an indicator to the scroll bar" - ] - }, - "minItems": 1, - "maxItems": 3, - "uniqueItems": true, - "markdownDescription": "Specifies where the indicators of the file heatmap annotations will be shown", + "gitlens.views.repositories.autoRefresh": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to automatically refresh the _Repositories_ view when the repository or the file system changes", "scope": "window", - "order": 20 + "order": 40 }, - "gitlens.heatmap.fadeLines": { + "gitlens.views.repositories.autoReveal": { "type": "boolean", "default": true, - "markdownDescription": "Specifies the whether to fade out older lines", + "markdownDescription": "Specifies whether to automatically reveal repositories in the _Repositories_ view when opening files", "scope": "window", - "order": 21 + "order": 50 }, - "gitlens.heatmap.ageThreshold": { - "type": "number", - "default": 90, - "markdownDescription": "Specifies the age of the most recent change (in days) after which the file heatmap annotations will be cold rather than hot (i.e. will use `#gitlens.heatmap.coldColor#` instead of `#gitlens.heatmap.hotColor#`)", + "gitlens.views.repositories.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Repositories_ view", "scope": "window", - "order": 30 + "order": 60 }, - "gitlens.heatmap.coldColor": { + "gitlens.views.repositories.branches.layout": { "type": "string", - "default": "#0a60f6", - "markdownDescription": "Specifies the base color of the file heatmap annotations when the most recent change is older (cold) than the `#gitlens.heatmap.ageThreshold#` value", + "default": "tree", + "enum": [ + "list", + "tree" + ], + "enumDescriptions": [ + "Displays branches as a list", + "Displays branches as a tree when branch names contain slashes `/`" + ], + "markdownDescription": "Specifies how the _Repositories_ view will display branches", "scope": "window", - "order": 40 + "order": 70 }, - "gitlens.heatmap.hotColor": { + "gitlens.views.repositories.files.layout": { "type": "string", - "default": "#f66a0a", - "markdownDescription": "Specifies the base color of the file heatmap annotations when the most recent change is newer (hot) than the `#gitlens.heatmap.ageThreshold#` value", - "scope": "window", - "order": 50 - } - } - }, - { - "id": "graph", - "title": "Commit Graph", - "order": 105, - "properties": { - "gitlens.graph.defaultItemLimit": { - "type": "number", - "default": 500, - "markdownDescription": "Specifies the default number of items to show in the _Commit Graph_. Use 0 to specify no limit", - "scope": "window", - "order": 10 - }, - "gitlens.graph.pageItemLimit": { - "type": "number", - "default": 200, - "markdownDescription": "Specifies the number of additional items to fetch when paginating in the _Commit Graph_. Use 0 to specify no limit", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" + ], + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.repositories.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _Repositories_ view will display files", "scope": "window", - "order": 11 + "order": 80 }, - "gitlens.graph.searchItemLimit": { + "gitlens.views.repositories.files.threshold": { "type": "number", - "default": 100, - "markdownDescription": "Specifies the number of results to gather when searching in the _Commit Graph_. Use 0 to specify no limit", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Repositories_ view. Only applies when `#gitlens.views.repositories.files.layout#` is set to `auto`", "scope": "window", - "order": 12 + "order": 81 }, - "gitlens.graph.scrollMarkers.enabled": { + "gitlens.views.repositories.files.compact": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show markers on the scrollbar in the _Commit Graph_", + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Repositories_ view. Only applies when `#gitlens.views.repositories.files.layout#` is set to `tree` or `auto`", "scope": "window", - "order": 13 + "order": 82 }, - "gitlens.graph.scrollMarkers.additionalTypes": { - "type": "array", - "default": [ - "localBranches", - "stashes" + "gitlens.views.repositories.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" ], - "items": { - "type": "string", - "enum": [ - "localBranches", - "remoteBranches", - "tags", - "stashes" - ], - "enumDescriptions": [ - "Marks the location of local branches", - "Marks the location of remote branches", - "Marks the location of tags", - "Marks the location of stashes" - ] - }, - "minItems": 0, - "maxItems": 4, - "uniqueItems": true, - "markdownDescription": "Specifies additional markers to show on the scrollbar in the _Commit Graph_", + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Repositories_ view will display file icons", "scope": "window", - "order": 14 + "order": 83 }, - "gitlens.graph.scrollRowPadding": { - "type": "number", - "default": 0, - "markdownDescription": "Specifies the number of rows from the edge at which the graph will scroll when using keyboard or search to change the selected row", + "gitlens.views.repositories.compact": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to show the _Repositories_ view in a compact display density", "scope": "window", - "order": 14 + "order": 90 }, - "gitlens.graph.showDetailsView": { + "gitlens.views.repositories.branches.showBranchComparison": { "type": [ "boolean", "string" ], - "default": "selection", "enum": [ false, - "open", - "selection" + "branch" ], "enumDescriptions": [ - "Never shows the _Commit Details_ view automatically", - "Shows the _Commit Details_ view automatically only when opening the _Commit Graph_", - "Shows the _Commit Details_ view automatically when selection changes in the _Commit Graph_" + "Hides the branch comparison", + "Compares the branch with a user-selected reference" ], - "markdownDescription": "Specifies when to show the _Commit Details_ view for the selected row in the _Commit Graph_", + "default": "branch", + "markdownDescription": "Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag, etc) under each branch in the _Repositories_ view", "scope": "window", - "order": 20 + "order": 100 }, - "gitlens.graph.showGhostRefsOnRowHover": { + "gitlens.views.repositories.enabled": { + "deprecationMessage": "Deprecated. This setting is no longer used", + "markdownDeprecationMessage": "Deprecated. This setting is no longer used" + } + } + }, + { + "id": "file-history-view", + "title": "File History View", + "order": 150, + "properties": { + "gitlens.views.fileHistory.pullRequests.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show a ghost branch / tag when hovering over or selecting a row in the _Commit Graph_", + "markdownDescription": "Specifies whether to query for pull requests associated with commits in the _File History_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", "order": 21 }, - "gitlens.graph.highlightRowsOnRefHover": { + "gitlens.views.fileHistory.pullRequests.showForCommits": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to highlight rows associated with the branch / tag when hovering over it in the _Commit Graph_", + "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _File History_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", "order": 22 }, - "gitlens.graph.dimMergeCommits": { - "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to dim (deemphasize) merge commit rows in the _Commit Graph_", - "scope": "window", - "order": 23 - }, - "gitlens.graph.showRemoteNames": { - "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to show remote names on remote branches in the _Commit Graph_", - "scope": "window", - "order": 24 - }, - "gitlens.graph.avatars": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show avatar images instead of author initials and remote icons in the _Commit Graph_", + "gitlens.views.fileHistory.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" + ], + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.fileHistory.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _File History_ view will display files", "scope": "window", - "order": 25 + "order": 30 }, - "gitlens.graph.showUpstreamStatus": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to show a local branch's upstream status in the _Commit Graph_", + "gitlens.views.fileHistory.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _File History_ view. Only applies when `#gitlens.views.fileHistory.files.layout#` is set to `auto`", "scope": "window", - "order": 26 + "order": 31 }, - "gitlens.graph.pullRequests.enabled": { + "gitlens.views.fileHistory.files.compact": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show associated pull requests on remote branches in the _Commit Graph_. Requires a connection to a supported remote service (e.g. GitHub)", + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _File History_ view. Only applies when `#gitlens.views.fileHistory.files.layout#` is set to `tree` or `auto`", "scope": "window", - "order": 27 + "order": 32 }, - "gitlens.graph.commitOrdering": { + "gitlens.views.fileHistory.files.icon": { "type": "string", - "default": "date", - "enum": [ - "date", - "author-date", - "topo" - ], - "enumDescriptions": [ - "Shows commits in reverse chronological order of the commit timestamp", - "Shows commits in reverse chronological order of the author timestamp", - "Shows commits in reverse chronological order of the commit timestamp, but avoids intermixing multiple lines of history" - ], - "markdownDescription": "Specifies the order by which commits will be shown on the _Commit Graph_", - "scope": "window", - "order": 30 - }, - "gitlens.graph.dateStyle": { - "type": [ - "string", - "null" - ], - "default": "relative", + "default": "type", "enum": [ - "relative", - "absolute" + "status", + "type" ], "enumDescriptions": [ - "e.g. 1 day ago", - "e.g. July 25th, 2018 7:18pm" + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" ], - "markdownDescription": "Specifies how dates will be displayed in the _Commit Graph_", + "markdownDescription": "Specifies how the _File History_ view will display file icons", "scope": "window", - "order": 40 + "order": 33 }, - "gitlens.graph.dateFormat": { - "type": [ - "string", - "null" - ], - "default": null, - "markdownDescription": "Specifies how absolute dates will be formatted in the _Commit Graph_. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", + "gitlens.views.fileHistory.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of status icons in the _File History_ view", "scope": "window", - "order": 41 + "order": 20 }, - "gitlens.graph.statusBar.enabled": { + "gitlens.advanced.fileHistoryFollowsRenames": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show the _Commit Graph_ in the status bar", + "markdownDescription": "Specifies whether file histories will follow renames", "scope": "window", "order": 100 }, - "gitlens.graph.experimental.minimap.enabled": { + "gitlens.advanced.fileHistoryShowAllBranches": { "type": "boolean", "default": false, - "markdownDescription": "Specifies whether to show an experimental minimap of commit activity above the _Commit Graph_", + "markdownDescription": "Specifies whether file histories will show commits from all branches", "scope": "window", - "order": 100 + "order": 101 }, - "gitlens.graph.experimental.minimap.additionalTypes": { - "type": "array", - "default": [ - "localBranches", - "stashes" - ], - "items": { - "type": "string", - "enum": [ - "localBranches", - "remoteBranches", - "tags", - "stashes" - ], - "enumDescriptions": [ - "Marks the location of local branches", - "Marks the location of remote branches", - "Marks the location of tags", - "Marks the location of stashes" - ] - }, - "minItems": 0, - "maxItems": 4, - "uniqueItems": true, - "markdownDescription": "Specifies additional markers to show on the minimap in the _Commit Graph_", + "gitlens.advanced.fileHistoryShowMergeCommits": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether file histories will show merge commits", "scope": "window", - "order": 101 + "order": 102 } } }, { "id": "visual-history", - "title": "Visual File History", - "order": 106, + "title": "Visual File History (ᴘʀᴏ)", + "order": 155, "properties": { + "gitlens.visualHistory.allowMultiple": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to allow opening multiple instances of the _Visual File History_ in the editor area", + "scope": "window", + "order": 10 + }, "gitlens.visualHistory.queryLimit": { "type": "number", "default": 20, - "markdownDescription": "Specifies the limit on the how many commits can be queried for statistics in the Visual File History, because of rate limits. Only applies to virtual workspaces.", - "scope": "window" + "markdownDescription": "Specifies the limit on the how many commits can be queried for statistics in the _Visual File History_, because of rate limits. Only applies to virtual workspaces.", + "scope": "window", + "order": 20 } } }, { - "id": "rebase-editor", - "title": "Interactive Rebase Editor", - "order": 107, + "id": "line-history-view", + "title": "Line History View", + "order": 160, "properties": { - "gitlens.rebaseEditor.ordering": { - "type": "string", - "default": "desc", - "enum": [ - "asc", - "desc" - ], - "enumDescriptions": [ - "Shows oldest commit first", - "Shows newest commit first" - ], - "markdownDescription": "Specifies how Git commits are displayed in the _Interactive Rebase Editor_", + "gitlens.views.lineHistory.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of status icons in the _Line History_ view", "scope": "window", "order": 10 }, - "gitlens.rebaseEditor.showDetailsView": { - "type": [ - "boolean", - "string" - ], - "default": "selection", - "enum": [ - false, - "open", - "selection" - ], - "enumDescriptions": [ - "Never shows the _Commit Details_ view automatically", - "Shows the _Commit Details_ view automatically only when opening the _Interactive Rebase Editor_", - "Shows the _Commit Details_ view automatically when selection changes in the _Interactive Rebase Editor_" - ], - "markdownDescription": "Specifies when to show the _Commit Details_ view for the selected row in the _Interactive Rebase Editor_", - "scope": "window", - "order": 20 + "gitlens.views.lineHistory.enabled": { + "deprecationMessage": "Deprecated. This setting is no longer used", + "markdownDeprecationMessage": "Deprecated. This setting is no longer used" } } }, { - "id": "git-command-palette", - "title": "Git Command Palette", - "order": 110, + "id": "branches-view", + "title": "Branches View", + "order": 170, "properties": { - "gitlens.gitCommands.sortBy": { - "type": "string", - "default": "usage", + "gitlens.views.branches.showBranchComparison": { + "type": [ + "boolean", + "string" + ], "enum": [ - "name", - "usage" + false, + "branch" ], "enumDescriptions": [ - "Sorts commands by name", - "Sorts commands by last used date" + "Hides the branch comparison", + "Compares the branch with a user-selected reference" ], - "markdownDescription": "Specifies how Git commands are sorted in the _Git Command Palette_", + "default": "branch", + "markdownDescription": "Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag, etc) in the _Branches_ view", "scope": "window", "order": 10 }, - "gitlens.gitCommands.skipConfirmations": { - "type": "array", - "default": [ - "fetch:command", - "stash-push:command", - "switch:command" - ], - "items": { - "type": "string", - "enum": [ - "branch-create:command", - "branch-create:menu", - "co-authors:command", - "co-authors:menu", - "fetch:command", - "fetch:menu", - "pull:command", - "pull:menu", - "push:command", - "push:menu", - "stash-apply:command", - "stash-apply:menu", - "stash-pop:command", - "stash-pop:menu", - "stash-push:command", - "stash-push:menu", - "switch:command", - "switch:menu", - "tag-create:command", - "tag-create:menu" - ], - "enumDescriptions": [ - "Skips branch create confirmations when run from a command, e.g. a view action", - "Skips branch create confirmations when run from the Git Command Palette", - "Skips co-author confirmations when run from a command, e.g. a view action", - "Skips co-author confirmations when run from the Git Command Palette", - "Skips fetch confirmations when run from a command, e.g. a view action", - "Skips fetch confirmations when run from the Git Command Palette", - "Skips pull confirmations when run from a command, e.g. a view action", - "Skips pull confirmations when run from the Git Command Palette", - "Skips push confirmations when run from a command, e.g. a view action", - "Skips push confirmations when run from the Git Command Palette", - "Skips stash apply confirmations when run from a command, e.g. a view action", - "Skips stash apply confirmations when run from the Git Command Palette", - "Skips stash pop confirmations when run from a command, e.g. a view action", - "Skips stash pop confirmations when run from the Git Command Palette", - "Skips stash push confirmations when run from a command, e.g. a view action", - "Skips stash push confirmations when run from the Git Command Palette", - "Skips switch confirmations when run from a command, e.g. a view action", - "Skips switch confirmations when run from the Git Command Palette", - "Skips tag create confirmations when run from a command, e.g. a view action", - "Skips tag create confirmations when run from the Git Command Palette" - ] - }, - "minItems": 0, - "maxItems": 14, - "uniqueItems": true, - "markdownDescription": "Specifies which (and when) Git commands will skip the confirmation step, using the format: `git-command-name:(menu|command)`", + "gitlens.views.branches.pullRequests.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to query for pull requests associated with each branch and commits in the _Branches_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", "order": 20 }, - "gitlens.gitCommands.closeOnFocusOut": { + "gitlens.views.branches.pullRequests.showForBranches": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to dismiss the _Git Command Palette_ when focus is lost (if not, press `ESC` to dismiss)", + "markdownDescription": "Specifies whether to show pull requests (if any) associated with each branch in the _Branches_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 30 + "order": 21 }, - "gitlens.gitCommands.search.showResultsInSideBar": { - "type": [ - "boolean", - "null" - ], - "default": null, - "markdownDescription": "Specifies whether to show the commit search results directly in the quick pick menu, in the Side Bar, or will be based on the context", + "gitlens.views.branches.pullRequests.showForCommits": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Branches_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 40 + "order": 22 }, - "gitlens.gitCommands.search.matchAll": { - "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to match all or any commit message search patterns", + "gitlens.views.branches.branches.layout": { + "type": "string", + "default": "tree", + "enum": [ + "list", + "tree" + ], + "enumDescriptions": [ + "Displays branches as a list", + "Displays branches as a tree when branch names contain slashes `/`" + ], + "markdownDescription": "Specifies how the _Branches_ view will display branches", + "scope": "window", + "order": 30 + }, + "gitlens.views.branches.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" + ], + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.branches.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _Branches_ view will display files", "scope": "window", "order": 50 }, - "gitlens.gitCommands.search.matchCase": { - "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to match commit search patterns with or without regard to casing", + "gitlens.views.branches.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Branches_ view. Only applies when `#gitlens.views.branches.files.layout#` is set to `auto`", "scope": "window", "order": 51 }, - "gitlens.gitCommands.search.matchRegex": { + "gitlens.views.branches.files.compact": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to match commit search patterns using regular expressions", + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Branches_ view. Only applies when `#gitlens.views.branches.files.layout#` is set to `tree` or `auto`", "scope": "window", "order": 52 }, - "gitlens.gitCommands.search.showResultsInView": { - "deprecationMessage": "Deprecated. This setting has been renamed to gitlens.gitCommands.search.showResultsInSideBar", - "markdownDeprecationMessage": "Deprecated. This setting has been renamed to `#gitlens.gitCommands.search.showResultsInSideBar#`" + "gitlens.views.branches.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Branches_ view will display file icons", + "scope": "window", + "order": 53 + }, + "gitlens.views.branches.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Branches_ view", + "scope": "window", + "order": 60 + }, + "gitlens.views.branches.reveal": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to reveal branches in the _Branches_ view, otherwise they revealed in the _Repositories_ view", + "scope": "window", + "order": 70 } } }, { - "id": "integrations", - "title": "Integrations", - "order": 111, + "id": "remotes-view", + "title": "Remotes View", + "order": 180, "properties": { - "gitlens.autolinks": { - "type": [ - "array", - "null" - ], - "default": null, - "items": { - "type": "object", - "required": [ - "prefix", - "url" - ], - "properties": { - "prefix": { - "type": "string", - "description": "Specifies the short prefix to use to generate autolinks for the external resource" - }, - "title": { - "type": [ - "string", - "null" - ], - "default": null, - "description": "Specifies an optional title for the generated autolink. Use `` as the variable for the reference number" - }, - "url": { - "type": "string", - "description": "Specifies the URL of the external resource you want to link to. Use `` as the variable for the reference number" - }, - "alphanumeric": { - "type": "boolean", - "description": "Specifies whether alphanumeric characters should be allowed in ``", - "default": false - }, - "ignoreCase": { - "type": "boolean", - "description": "Specifies whether case should be ignored when matching the prefix", - "default": false - } - }, - "additionalProperties": false - }, - "uniqueItems": true, - "markdownDescription": "Specifies autolinks to external resources in commit messages. Use `` as the variable for the reference number", + "gitlens.views.remotes.pullRequests.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to query for pull requests associated with each branch and commits in the _Remotes_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", "order": 10 }, - "gitlens.integrations.enabled": { + "gitlens.views.remotes.pullRequests.showForBranches": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to enable rich integrations with any supported remote services", + "markdownDescription": "Specifies whether to show pull requests (if any) associated with each branch in the _Remotes_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 11 + }, + "gitlens.views.remotes.pullRequests.showForCommits": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Remotes_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 12 + }, + "gitlens.views.remotes.branches.layout": { + "type": "string", + "default": "tree", + "enum": [ + "list", + "tree" + ], + "enumDescriptions": [ + "Displays branches as a list", + "Displays branches as a tree when branch names contain slashes `/`" + ], + "markdownDescription": "Specifies how the _Remotes_ view will display branches", "scope": "window", "order": 20 }, - "gitlens.remotes": { - "type": [ - "array", - "null" + "gitlens.views.remotes.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" ], - "default": null, - "items": { - "type": "object", - "required": [ - "type" - ], - "oneOf": [ - { - "required": [ - "domain" - ] - }, - { - "required": [ - "regex" - ] - } - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "Custom", - "AzureDevOps", - "Bitbucket", - "BitbucketServer", - "Gerrit", - "GoogleSource", - "Gitea", - "GitHub", - "GitLab" - ], - "description": "Specifies the type of the custom remote service" - }, - "domain": { - "type": "string", - "description": "Specifies the domain name used to match this custom configuration to a Git remote" - }, - "regex": { - "type": "string", - "description": "Specifies a regular expression used to match this custom configuration to a Git remote and capture the \"domain name\" and \"path\"" - }, - "name": { - "type": "string", - "description": "Specifies an optional friendly name for the custom remote service" - }, - "protocol": { - "type": "string", - "default": "https", - "description": "Specifies an optional URL protocol for the custom remote service" - }, - "ignoreSSLErrors": { - "type": "boolean", - "default": false, - "description": "Specifies whether to ignore invalid SSL certificate errors when connecting to the remote service" - }, - "urls": { - "type": "object", - "required": [ - "repository", - "branches", - "branch", - "commit", - "file", - "fileInCommit", - "fileInBranch", - "fileLine", - "fileRange" - ], - "properties": { - "repository": { - "type": "string", - "markdownDescription": "Specifies the format of a repository URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path" - }, - "branches": { - "type": "string", - "markdownDescription": "Specifies the format of a branches URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${branch}` — branch" - }, - "branch": { - "type": "string", - "markdownDescription": "Specifies the format of a branch URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${branch}` — branch" - }, - "commit": { - "type": "string", - "markdownDescription": "Specifies the format of a commit URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${id}` — commit SHA" - }, - "file": { - "type": "string", - "markdownDescription": "Specifies the format of a file URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${file}` — file name\\\n`${line}` — formatted line information" - }, - "fileInBranch": { - "type": "string", - "markdownDescription": "Specifies the format of a branch file URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${file}` — file name\\\n`${branch}` — branch\\\n`${line}` — formatted line information" - }, - "fileInCommit": { - "type": "string", - "markdownDescription": "Specifies the format of a commit file URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${file}` — file name\\\n`${id}` — commit SHA\\\n`${line}` — formatted line information" - }, - "fileLine": { - "type": "string", - "markdownDescription": "Specifies the format of a line in a file URL for the custom remote service\n\nAvailable tokens\\\n`${line}` — line" - }, - "fileRange": { - "type": "string", - "markdownDescription": "Specifies the format of a range in a file URL for the custom remote service\n\nAvailable tokens\\\n`${start}` — starting line\\\n`${end}` — ending line" - } - }, - "additionalProperties": false - } - } - }, - "uniqueItems": true, - "markdownDescription": "Specifies custom remote services to be matched with Git remotes to detect custom domains for built-in remote services or provide support for custom remote services", - "scope": "resource", + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.remotes.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _Remotes_ view will display files", + "scope": "window", "order": 30 }, - "gitlens.partners": { - "type": [ - "object", - "null" + "gitlens.views.remotes.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Remotes_ view. Only applies when `#gitlens.views.remotes.files.layout#` is set to `auto`", + "scope": "window", + "order": 31 + }, + "gitlens.views.remotes.files.compact": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Remotes_ view. Only applies when `#gitlens.views.remotes.files.layout#` is set to `tree` or `auto`", + "scope": "window", + "order": 32 + }, + "gitlens.views.remotes.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" ], - "additionalProperties": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "default": true, - "description": "Specifies whether the partner integration should be shown" - } - }, - "additionalProperties": true, - "description": "Specifies the configuration of a partner integration" - }, - "default": null, - "description": "Specifies the configuration of a partner integration", + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Remotes_ view will display file icons", + "scope": "window", + "order": 33 + }, + "gitlens.views.remotes.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Remotes_ view", "scope": "window", "order": 40 }, - "gitlens.liveshare.allowGuestAccess": { + "gitlens.views.remotes.reveal": { "type": "boolean", "default": true, - "description": "Specifies whether to allow guest access to GitLens features when using Visual Studio Live Share", + "markdownDescription": "Specifies whether to reveal remotes in the _Remotes_ view, otherwise they revealed in the _Repositories_ view", "scope": "window", "order": 50 } } }, { - "id": "terminal", - "title": "Terminal", - "order": 112, + "id": "stashes-view", + "title": "Stashes View", + "order": 190, "properties": { - "gitlens.terminalLinks.enabled": { - "type": "boolean", - "default": true, - "markdownDescription": "Specifies whether to enable terminal links — autolinks in the integrated terminal to quickly jump to more details for commits, branches, tags, and more", + "gitlens.views.stashes.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" + ], + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.stashes.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _Stashes_ view will display files", "scope": "window", "order": 10 }, - "gitlens.terminalLinks.showDetailsView": { + "gitlens.views.stashes.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Stashes_ view. Only applies when `#gitlens.views.stashes.files.layout#` is set to `auto`", + "scope": "window", + "order": 11 + }, + "gitlens.views.stashes.files.compact": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show the _Commit Details_ view when clicking on a commit link in the integrated terminal", + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Stashes_ view. Only applies when `#gitlens.views.stashes.files.layout#` is set to `tree` or `auto`", "scope": "window", - "order": 20 + "order": 12 }, - "gitlens.terminal.overrideGitEditor": { + "gitlens.views.stashes.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Stashes_ view will display file icons", + "scope": "window", + "order": 13 + }, + "gitlens.views.stashes.reveal": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to use VS Code as Git's `core.editor` for Gitlens terminal commands", + "markdownDescription": "Specifies whether to reveal stashes in the _Stashes_ view, otherwise they revealed in the _Repositories_ view", "scope": "window", - "order": 100 + "order": 20 } } }, { - "id": "date-times", - "title": "Date & Times", - "order": 120, + "id": "tags-view", + "title": "Tags View", + "order": 200, "properties": { - "gitlens.defaultDateStyle": { + "gitlens.views.tags.branches.layout": { "type": "string", - "default": "relative", + "default": "tree", "enum": [ - "relative", - "absolute" + "list", + "tree" ], "enumDescriptions": [ - "e.g. 1 day ago", - "e.g. July 25th, 2018 7:18pm" + "Displays tags as a list", + "Displays tags as a tree when tags names contain slashes `/`" ], - "markdownDescription": "Specifies how dates will be displayed by default", + "markdownDescription": "Specifies how the _Tags_ view will display tags", "scope": "window", "order": 10 }, - "gitlens.defaultDateFormat": { - "type": [ - "string", - "null" + "gitlens.views.tags.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" ], - "default": null, - "markdownDescription": "Specifies how absolute dates will be formatted by default. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", - "scope": "window", - "order": 20 - }, - "gitlens.defaultDateLocale": { - "type": [ - "string", - "null" + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.tags.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" ], - "default": null, - "markdownDescription": "Specifies the locale, a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag#List_of_major_primary_language_subtags), to use for date formatting, defaults to the VS Code locale. Use `system` to follow the current system locale, or choose a specific locale, e.g `en-US` — US English, `en-GB` — British English, `de-DE` — German, 'ja-JP = Japanese, etc.", + "markdownDescription": "Specifies how the _Tags_ view will display files", "scope": "window", - "order": 21 + "order": 30 }, - "gitlens.defaultDateShortFormat": { - "type": [ - "string", - "null" - ], - "default": null, - "markdownDescription": "Specifies how short absolute dates will be formatted by default. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", + "gitlens.views.tags.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Tags_ view. Only applies when `#gitlens.views.tags.files.layout#` is set to `auto`", "scope": "window", - "order": 22 + "order": 31 }, - "gitlens.defaultTimeFormat": { - "type": [ - "string", - "null" - ], - "default": null, - "markdownDescription": "Specifies how times will be formatted by default. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", + "gitlens.views.tags.files.compact": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Tags_ view. Only applies when `#gitlens.views.tags.files.layout#` is set to `tree` or `auto`", "scope": "window", - "order": 30 + "order": 32 }, - "gitlens.defaultDateSource": { + "gitlens.views.tags.files.icon": { "type": "string", - "default": "authored", + "default": "type", "enum": [ - "authored", - "committed" + "status", + "type" ], "enumDescriptions": [ - "Uses the date when the changes were authored (i.e. originally written)", - "Uses the date when the changes were committed" + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" ], - "markdownDescription": "Specifies whether commit dates should use the authored or committed date", + "markdownDescription": "Specifies how the _Tags_ view will display file icons", + "scope": "window", + "order": 33 + }, + "gitlens.views.tags.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Tags_ view", "scope": "window", "order": 40 + }, + "gitlens.views.tags.reveal": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to reveal tags in the _Tags_ view, otherwise they revealed in the _Repositories_ view", + "scope": "window", + "order": 50 } } }, { - "id": "menus-toolbars", - "title": "Menus & Toolbars", - "order": 121, + "id": "worktrees-view", + "title": "Worktrees View (ᴘʀᴏ)", + "order": 210, "properties": { - "gitlens.menus": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "editor": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "blame": { - "type": "boolean" - }, - "clipboard": { - "type": "boolean" - }, - "compare": { - "type": "boolean" - }, - "history": { - "type": "boolean" - }, - "remote": { - "type": "boolean" - } - } - } - ] - }, - "editorGroup": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "blame": { - "type": "boolean" - }, - "compare": { - "type": "boolean" - } - } - } - ] - }, - "editorTab": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "clipboard": { - "type": "boolean" - }, - "compare": { - "type": "boolean" - }, - "history": { - "type": "boolean" - }, - "remote": { - "type": "boolean" - } - } - } - ] - }, - "explorer": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "clipboard": { - "type": "boolean" - }, - "compare": { - "type": "boolean" - }, - "history": { - "type": "boolean" - }, - "remote": { - "type": "boolean" - } - } - } - ] - }, - "scm": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "graph": { - "type": "boolean" - } - } - } - ] - }, - "scmRepositoryInline": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "graph": { - "type": "boolean" - } - } - } - ] - }, - "scmRepository": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "authors": { - "type": "boolean" - }, - "graph": { - "type": "boolean" - } - } - } - ] - }, - "scmGroupInline": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "stash": { - "type": "boolean" - } - } - } - ] - }, - "scmGroup": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "compare": { - "type": "boolean" - }, - "openClose": { - "type": "boolean" - }, - "stash": { - "type": "boolean" - } - } - } - ] - }, - "scmItemInline": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "stash": { - "type": "boolean" - } - } - } - ] - }, - "scmItem": { - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "object", - "properties": { - "clipboard": { - "type": "boolean" - }, - "compare": { - "type": "boolean" - }, - "history": { - "type": "boolean" - }, - "remote": { - "type": "boolean" - }, - "stash": { - "type": "boolean" - } - } - } - ] - } - }, - "additionalProperties": false - } + "gitlens.worktrees.promptForLocation": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to prompt for a path when creating new worktrees", + "scope": "resource", + "order": 10 + }, + "gitlens.worktrees.defaultLocation": { + "type": "string", + "default": null, + "markdownDescription": "Specifies the default path in which new worktrees will be created", + "scope": "resource", + "order": 11 + }, + "gitlens.worktrees.openAfterCreate": { + "type": "string", + "default": "prompt", + "enum": [ + "always", + "alwaysNewWindow", + "onlyWhenEmpty", + "never", + "prompt" ], - "default": { - "editor": { - "blame": false, - "clipboard": true, - "compare": true, - "history": false, - "remote": false - }, - "editorGroup": { - "blame": true, - "compare": true - }, - "editorTab": { - "clipboard": true, - "compare": true, - "history": true, - "remote": true - }, - "explorer": { - "clipboard": true, - "compare": true, - "history": true, - "remote": true - }, - "scm": { - "graph": true - }, - "scmRepositoryInline": { - "graph": true - }, - "scmRepository": { - "authors": true, - "graph": false - }, - "scmGroupInline": { - "stash": true - }, - "scmGroup": { - "compare": true, - "openClose": true, - "stash": true - }, - "scmItemInline": {}, - "scmItem": { - "clipboard": true, - "compare": true, - "history": true, - "remote": false, - "stash": true - } - }, - "markdownDescription": "Specifies which commands will be added to which menus", + "enumDescriptions": [ + "Always open the new worktree in the current window", + "Always open the new worktree in a new window", + "Only open the new worktree in the current window when no folder is opened", + "Never open the new worktree", + "Always prompt to open the new worktree" + ], + "markdownDescription": "Specifies how and when to open a worktree after it is created", + "scope": "resource", + "order": 12 + }, + "gitlens.views.worktrees.showBranchComparison": { + "type": [ + "boolean", + "string" + ], + "enum": [ + false, + "branch" + ], + "enumDescriptions": [ + "Hides the branch comparison", + "Compares the worktree branch with a user-selected reference" + ], + "default": "working", + "markdownDescription": "Specifies whether to show a comparison of the worktree branch with a user-selected reference (branch, tag, etc) in the _Worktrees_ view", "scope": "window", - "order": 10 + "order": 20 + }, + "gitlens.views.worktrees.pullRequests.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to query for pull requests associated with the worktree branch and commits in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 30 + }, + "gitlens.views.worktrees.pullRequests.showForBranches": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show pull requests (if any) associated with the worktree branch in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 31 + }, + "gitlens.views.worktrees.pullRequests.showForCommits": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 32 + }, + "gitlens.views.worktrees.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" + ], + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.worktrees.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _Worktrees_ view will display files", + "scope": "window", + "order": 40 + }, + "gitlens.views.worktrees.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Worktrees_ view. Only applies when `#gitlens.views.worktrees.files.layout#` is set to `auto`", + "scope": "window", + "order": 41 + }, + "gitlens.views.worktrees.files.compact": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Worktrees_ view. Only applies when `#gitlens.views.worktrees.files.layout#` is set to `tree` or `auto`", + "scope": "window", + "order": 42 + }, + "gitlens.views.worktrees.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Worktrees_ view will display file icons", + "scope": "window", + "order": 43 + }, + "gitlens.views.worktrees.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Worktrees_ view", + "scope": "window", + "order": 50 + }, + "gitlens.views.worktrees.reveal": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to reveal worktrees in the _Worktrees_ view, otherwise they revealed in the _Repositories_ view", + "scope": "window", + "order": 60 } } }, { - "id": "keyboard", - "title": "Keyboard Shortcuts", - "order": 122, + "id": "contributors-view", + "title": "Contributors View", + "order": 220, "properties": { - "gitlens.keymap": { + "gitlens.views.contributors.showAllBranches": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to show commits from all branches in the _Contributors_ view", + "scope": "window", + "order": 10 + }, + "gitlens.views.contributors.showStatistics": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to show contributor statistics in the _Contributors_ view. This can take a while to compute depending on the repository size", + "scope": "window", + "order": 20 + }, + "gitlens.views.contributors.pullRequests.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to query for pull requests associated with branches and commits in the _Contributors_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 30 + }, + "gitlens.views.contributors.pullRequests.showForCommits": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Contributors_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 31 + }, + "gitlens.views.contributors.files.layout": { "type": "string", - "default": "chorded", + "default": "auto", "enum": [ - "alternate", - "chorded", - "none" + "auto", + "list", + "tree" ], "enumDescriptions": [ - "Adds an alternate set of shortcut keys that start with `Alt` (âŒĨ on macOS)", - "Adds a chorded set of shortcut keys that start with `Ctrl+Alt+G` (`âŒĨ⌘G` on macOS)", - "No shortcut keys will be added" + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.contributors.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" ], - "markdownDescription": "Specifies the keymap to use for GitLens shortcut keys", + "markdownDescription": "Specifies how the _Contributors_ view will display files", "scope": "window", - "order": 10 + "order": 50 + }, + "gitlens.views.contributors.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Contributors_ view. Only applies when `#gitlens.views.contributors.files.layout#` is set to `auto`", + "scope": "window", + "order": 51 + }, + "gitlens.views.contributors.files.compact": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Contributors_ view. Only applies when `#gitlens.views.contributors.files.layout#` is set to `tree` or `auto`", + "scope": "window", + "order": 52 + }, + "gitlens.views.contributors.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Contributors_ view will display file icons", + "scope": "window", + "order": 53 + }, + "gitlens.views.contributors.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Contributors_ view", + "scope": "window", + "order": 60 + }, + "gitlens.views.contributors.reveal": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to reveal contributors in the _Contributors_ view, otherwise they revealed in the _Repositories_ view", + "scope": "window", + "order": 20 } } }, { - "id": "modes", - "title": "Modes", - "order": 123, + "id": "search-compare-view", + "title": "Search & Compare View", + "order": 230, "properties": { - "gitlens.mode.statusBar.enabled": { + "gitlens.views.searchAndCompare.pullRequests.enabled": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to provide the active GitLens mode in the status bar", + "markdownDescription": "Specifies whether to query for pull requests associated with commits in the _Search & Compare_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", "order": 10 }, - "gitlens.mode.statusBar.alignment": { + "gitlens.views.searchAndCompare.pullRequests.showForCommits": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Search & Compare_ view. Requires a connection to a supported remote service (e.g. GitHub)", + "scope": "window", + "order": 11 + }, + "gitlens.views.searchAndCompare.files.layout": { "type": "string", - "default": "right", + "default": "auto", "enum": [ - "left", - "right" + "auto", + "list", + "tree" ], "enumDescriptions": [ - "Aligns to the left", - "Aligns to the right" + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.searchAndCompare.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" ], - "markdownDescription": "Specifies the active GitLens mode alignment in the status bar", + "markdownDescription": "Specifies how the _Search & Compare_ view will display files", "scope": "window", - "order": 11 + "order": 20 }, - "gitlens.mode.active": { + "gitlens.views.searchAndCompare.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Search & Compare_ view. Only applies when `#gitlens.views.searchAndCompare.files.layout#` is set to `auto`", + "scope": "window", + "order": 21 + }, + "gitlens.views.searchAndCompare.files.compact": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Search & Compare_ view. Only applies when `#gitlens.views.searchAndCompare.files.layout#` is set to `tree` or `auto`", + "scope": "window", + "order": 22 + }, + "gitlens.views.searchAndCompare.files.icon": { "type": "string", - "markdownDescription": "Specifies the active GitLens mode, if any", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Search & Compare_ view will display file icons", "scope": "window", - "order": 20 + "order": 33 }, - "gitlens.modes": { - "type": "object", - "properties": { - "zen": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Specifies the friendly name of this user-defined mode" - }, - "statusBarItemName": { - "type": "string", - "description": "Specifies the name shown in the status bar when this user-defined mode is active" - }, - "description": { - "type": "string", - "description": "Specifies the description of this user-defined mode" - }, - "codeLens": { - "type": "boolean", - "description": "Specifies whether to show any Git CodeLens when this user-defined mode is active" - }, - "currentLine": { - "type": "boolean", - "description": "Specifies whether to show a blame annotation for the current line when this user-defined mode is active" - }, - "hovers": { - "type": "boolean", - "description": "Specifies whether to show any hovers when this user-defined mode is active" - }, - "statusBar": { - "type": "boolean", - "description": "Specifies whether to show blame information in the status bar when this user-defined mode is active" - } - } - }, - "review": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Specifies the friendly name of this user-defined mode" - }, - "statusBarItemName": { - "type": "string", - "description": "Specifies the name shown in the status bar when this user-defined mode is active" - }, - "description": { - "type": "string", - "description": "Specifies the description of this user-defined mode" - }, - "codeLens": { - "type": "boolean", - "description": "Specifies whether to show any Git CodeLens when this user-defined mode is active" - }, - "currentLine": { - "type": "boolean", - "description": "Specifies whether to show a blame annotation for the current line when this user-defined mode is active" - }, - "hovers": { - "type": "boolean", - "description": "Specifies whether to show any hovers when this user-defined mode is active" - }, - "statusBar": { - "type": "boolean", - "description": "Specifies whether to show blame information in the status bar when this user-defined mode is active" - } - } - } - }, - "additionalProperties": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "Specifies the friendly name of this user-defined mode" - }, - "statusBarItemName": { - "type": "string", - "description": "Specifies the name shown in the status bar when this user-defined mode is active" - }, - "description": { - "type": "string", - "description": "Specifies the description of this user-defined mode" - }, - "annotations": { - "type": "string", - "enum": [ - "blame", - "changes", - "heatmap" - ], - "enumDescriptions": [ - "Shows the file blame annotations", - "Shows the file changes annotations", - "Shows the file heatmap annotations" - ], - "description": "Specifies which (if any) file annotations will be shown when this user-defined mode is active" - }, - "codeLens": { - "type": "boolean", - "description": "Specifies whether to show any Git CodeLens when this user-defined mode is active" - }, - "currentLine": { - "type": "boolean", - "description": "Specifies whether to show a blame annotation for the current line when this user-defined mode is active" - }, - "hovers": { - "type": "boolean", - "description": "Specifies whether to show any hovers when this user-defined mode is active" - }, - "statusBar": { - "type": "boolean", - "description": "Specifies whether to show blame information in the status bar when this user-defined mode is active" - } - } - }, - "default": { - "zen": { - "name": "Zen", - "statusBarItemName": "Zen", - "description": "for a zen-like experience, disables many visual features", - "codeLens": false, - "currentLine": false, - "hovers": false, - "statusBar": false - }, - "review": { - "name": "Review", - "statusBarItemName": "Reviewing", - "description": "for reviewing code, enables many visual features", - "codeLens": true, - "currentLine": true, - "hovers": true - } - }, - "markdownDescription": "Specifies the user-defined GitLens modes", + "gitlens.views.searchAndCompare.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Search & Compare_ view", "scope": "window", "order": 30 } } }, { - "id": "advanced", - "title": "Advanced", - "order": 1000, + "id": "cloud-patches-view", + "title": "Cloud Patches View (ᴘʀᴇᴠÉĒᴇᴡ)", + "order": 240, "properties": { - "gitlens.detectNestedRepositories": { + "gitlens.views.drafts.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" + ], + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.drafts.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _Cloud Patches_ view will display files", + "scope": "window", + "order": 30 + }, + "gitlens.views.drafts.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Cloud Patches_ view. Only applies when `#gitlens.views.drafts.files.layout#` is set to `auto`", + "scope": "window", + "order": 31 + }, + "gitlens.views.drafts.files.compact": { "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to attempt to detect nested repositories when opening files", - "scope": "resource", - "order": 0 + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Cloud Patches_ view. Only applies when `#gitlens.views.drafts.files.layout#` is set to `tree` or `auto`", + "scope": "window", + "order": 32 }, - "gitlens.telemetry.enabled": { + "gitlens.views.drafts.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _Cloud Patches_ view will display file icons", + "scope": "window", + "order": 33 + }, + "gitlens.views.drafts.avatars": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to allow the collection of product usage telemetry", + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Cloud Patches_ view", "scope": "window", - "order": 1 + "order": 40 + } + } + }, + { + "id": "patch-details-view", + "title": "Patch Details View", + "order": 250, + "properties": { + "gitlens.views.patchDetails.files.layout": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "list", + "tree" + ], + "enumDescriptions": [ + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.patchDetails.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" + ], + "markdownDescription": "Specifies how the _Patch Details_ view will display files", + "scope": "window", + "order": 30 }, - "gitlens.advanced.messages": { - "type": "object", - "default": { - "suppressCommitHasNoPreviousCommitWarning": false, - "suppressCommitNotFoundWarning": false, - "suppressCreatePullRequestPrompt": false, - "suppressDebugLoggingWarning": false, - "suppressFileNotUnderSourceControlWarning": false, - "suppressGitDisabledWarning": false, - "suppressGitMissingWarning": false, - "suppressGitVersionWarning": false, - "suppressLineUncommittedWarning": false, - "suppressNoRepositoryWarning": false, - "suppressRebaseSwitchToTextWarning": false, - "suppressIntegrationDisconnectedTooManyFailedRequestsWarning": false, - "suppressIntegrationRequestFailed500Warning": false, - "suppressIntegrationRequestTimedOutWarning": false - }, - "properties": { - "suppressCommitHasNoPreviousCommitWarning": { - "type": "boolean", - "default": false, - "description": "Commit Has No Previous Commit Warning" - }, - "suppressCommitNotFoundWarning": { - "type": "boolean", - "default": false, - "description": "Commit Not Found Warning" - }, - "suppressCreatePullRequestPrompt": { - "type": "boolean", - "default": false, - "description": "Create Pull Request Prompt" - }, - "suppressDebugLoggingWarning": { - "type": "boolean", - "default": false, - "description": "Debug Logging Warning" - }, - "suppressFileNotUnderSourceControlWarning": { - "type": "boolean", - "default": false, - "description": "File Not Under Source Control Warning" - }, - "suppressGitDisabledWarning": { - "type": "boolean", - "default": false, - "description": "Git Disabled Warning" - }, - "suppressGitMissingWarning": { - "type": "boolean", - "default": false, - "description": "Git Missing Warning" - }, - "suppressGitVersionWarning": { - "type": "boolean", - "default": false, - "description": "Git Version Warning" - }, - "suppressLineUncommittedWarning": { - "type": "boolean", - "default": false, - "description": "Line Uncommitted Warning" - }, - "suppressNoRepositoryWarning": { - "type": "boolean", - "default": false, - "description": "No Repository Warning" - }, - "suppressRebaseSwitchToTextWarning": { - "type": "boolean", - "default": false, - "description": "Rebase Switch To Text Warning" - }, - "suppressIntegrationDisconnectedTooManyFailedRequestsWarning": { - "type": "boolean", - "default": false, - "description": "Integration Disconnected; Too Many Failed Requests Warning" - }, - "suppressIntegrationRequestFailed500Warning": { - "type": "boolean", - "default": false, - "description": "Integration Request Failed (500 status code) Warning" - }, - "suppressIntegrationRequestTimedOutWarning": { - "type": "boolean", - "default": false, - "description": "Integration Request Timed Out Warning" - } - }, - "additionalProperties": false, - "markdownDescription": "Specifies which messages should be suppressed", - "scope": "window", - "order": 5 - }, - "gitlens.advanced.repositorySearchDepth": { - "type": "number", - "default": null, - "markdownDescription": "Specifies how many folders deep to search for repositories. Defaults to `#git.repositoryScanMaxDepth#`", - "scope": "resource", - "order": 10 - }, - "gitlens.advanced.abbreviatedShaLength": { + "gitlens.views.patchDetails.files.threshold": { "type": "number", - "default": 7, - "markdownDescription": "Specifies the length of abbreviated commit SHAs", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Patch Details_ view. Only applies when `#gitlens.views.patchDetails.files.layout#` is set to `auto`", "scope": "window", - "order": 20 + "order": 31 }, - "gitlens.advanced.abbreviateShaOnCopy": { + "gitlens.views.patchDetails.files.compact": { "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to copy full or abbreviated commit SHAs to the clipboard. Abbreviates to the length of `#gitlens.advanced.abbreviatedShaLength#`.", + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Patch Details_ view. Only applies when `#gitlens.views.patchDetails.files.layout#` is set to `tree` or `auto`", "scope": "window", - "order": 21 + "order": 32 }, - "gitlens.advanced.commitOrdering": { - "type": [ - "string", - "null" - ], - "default": null, + "gitlens.views.patchDetails.files.icon": { + "type": "string", + "default": "type", "enum": [ - null, - "date", - "author-date", - "topo" + "status", + "type" ], "enumDescriptions": [ - "Shows commits in reverse chronological order", - "Shows commits in reverse chronological order of the commit timestamp", - "Shows commits in reverse chronological order of the author timestamp", - "Shows commits in reverse chronological order of the commit timestamp, but avoids intermixing multiple lines of history" + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" ], - "markdownDescription": "Specifies the order by which commits will be shown. If unspecified, commits will be shown in reverse chronological order", + "markdownDescription": "Specifies how the _Patch Details_ view will display file icons", "scope": "window", - "order": 30 + "order": 33 }, - "gitlens.blame.ignoreWhitespace": { + "gitlens.views.patchDetails.avatars": { "type": "boolean", - "default": false, - "markdownDescription": "Specifies whether to ignore whitespace when comparing revisions during blame operations", - "scope": "resource", + "default": true, + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Patch Details_ view", + "scope": "window", "order": 40 - }, - "gitlens.advanced.blame.customArguments": { + } + } + }, + { + "id": "workspaces-view", + "title": "GitKraken Workspaces View (ᴘʀᴇᴠÉĒᴇᴡ)", + "order": 260, + "properties": { + "gitlens.views.workspaces.showBranchComparison": { "type": [ - "array", - "null" + "boolean", + "string" ], - "default": null, - "items": { - "type": "string" - }, - "markdownDescription": "Specifies additional arguments to pass to the `git blame` command", - "scope": "resource", - "order": 41 + "enum": [ + false, + "branch", + "working" + ], + "enumDescriptions": [ + "Hides the branch comparison", + "Compares the current branch with a user-selected reference", + "Compares the working tree with a user-selected reference" + ], + "default": "working", + "markdownDescription": "Specifies whether to show a comparison of the current branch or the working tree with a user-selected reference (branch, tag, etc) in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 10 }, - "gitlens.advanced.blame.delayAfterEdit": { - "type": "number", - "default": 5000, - "markdownDescription": "Specifies the time (in milliseconds) to wait before re-blaming an unsaved document after an edit. Use 0 to specify an infinite wait", + "gitlens.views.workspaces.showUpstreamStatus": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the upstream status of the current branch for each repository in the _GitKraken Workspaces_ view", "scope": "window", - "order": 42 + "order": 11 }, - "gitlens.advanced.blame.sizeThresholdAfterEdit": { - "type": "number", - "default": 5000, - "markdownDescription": "Specifies the maximum document size (in lines) allowed to be re-blamed after an edit while still unsaved. Use 0 to specify no maximum", + "gitlens.views.workspaces.includeWorkingTree": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to include working tree file status for each repository in the _GitKraken Workspaces_ view", "scope": "window", - "order": 43 + "order": 12 }, - "gitlens.advanced.similarityThreshold": { - "type": [ - "number", - "null" - ], - "default": null, - "markdownDescription": "Specifies the amount (percent) of similarity a deleted and added file pair must have to be considered a rename", + "gitlens.views.workspaces.pullRequests.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to query for pull requests associated with branches and commits in the _GitKraken Workspaces_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 50 + "order": 20 }, - "gitlens.advanced.externalDiffTool": { - "type": [ - "string", - "null" - ], - "default": null, - "markdownDescription": "Specifies an optional external diff tool to use when comparing files. Must be a configured [Git difftool](https://git-scm.com/docs/git-config#Documentation/git-config.txt-difftool).", + "gitlens.views.workspaces.pullRequests.showForBranches": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show pull requests (if any) associated with branches in the _GitKraken Workspaces_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 60 + "order": 21 }, - "gitlens.advanced.externalDirectoryDiffTool": { - "type": [ - "string", - "null" - ], - "default": null, - "markdownDescription": "Specifies an optional external diff tool to use when comparing directories. Must be a configured [Git difftool](https://git-scm.com/docs/git-config#Documentation/git-config.txt-difftool).", + "gitlens.views.workspaces.pullRequests.showForCommits": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _GitKraken Workspaces_ view. Requires a connection to a supported remote service (e.g. GitHub)", "scope": "window", - "order": 61 + "order": 22 }, - "gitlens.advanced.quickPick.closeOnFocusOut": { + "gitlens.views.workspaces.showCommits": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to dismiss quick pick menus when focus is lost (if not, press `ESC` to dismiss)", + "markdownDescription": "Specifies whether to show the commits on the current branch for each repository in the _GitKraken Workspaces_ view", "scope": "window", - "order": 70 + "order": 30 }, - "gitlens.advanced.maxListItems": { - "type": "number", - "default": 200, - "markdownDescription": "Specifies the maximum number of items to show in a list. Use 0 to specify no maximum", + "gitlens.views.workspaces.showBranches": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the branches for each repository in the _GitKraken Workspaces_ view", "scope": "window", - "order": 80 + "order": 31 }, - "gitlens.advanced.maxSearchItems": { - "type": "number", - "default": 200, - "markdownDescription": "Specifies the maximum number of items to show in a search. Use 0 to specify no maximum", + "gitlens.views.workspaces.showRemotes": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the remotes for each repository in the _GitKraken Workspaces_ view", "scope": "window", - "order": 81 + "order": 32 }, - "gitlens.advanced.caching.enabled": { + "gitlens.views.workspaces.showStashes": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether git output will be cached — changing the default is not recommended", + "markdownDescription": "Specifies whether to show the stashes for each repository in the _GitKraken Workspaces_ view", "scope": "window", - "order": 90 + "order": 33 }, - "gitlens.debug": { + "gitlens.views.workspaces.showTags": { "type": "boolean", - "default": false, - "markdownDescription": "Specifies debug mode", + "default": true, + "markdownDescription": "Specifies whether to show the tags for each repository in the _GitKraken Workspaces_ view", "scope": "window", - "order": 100 + "order": 34 }, - "gitlens.deepLinks.schemeOverride": { - "type": [ - "boolean", - "string" - ], - "default": false, - "markdownDescription": "Specifies whether to override the default deep link scheme (vscode://) with the environment value or a specified value", + "gitlens.views.workspaces.showContributors": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the contributors for each repository in the _GitKraken Workspaces_ view", "scope": "window", - "order": 110 + "order": 35 }, - "gitlens.advanced.useSymmetricDifferenceNotation": { - "deprecationMessage": "Deprecated. This setting is no longer used", - "markdownDescription": "Deprecated. This setting is no longer used" - } - } - }, - { - "id": "general", - "title": "General", - "order": 0, - "properties": { - "gitlens.showWelcomeOnInstall": { + "gitlens.views.workspaces.showWorktrees": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show the Welcome (Quick Setup) experience on first install", + "markdownDescription": "Specifies whether to show the worktrees for each repository in the _GitKraken Workspaces_ view", "scope": "window", - "order": 10 + "order": 36 }, - "gitlens.showWhatsNewAfterUpgrades": { + "gitlens.views.workspaces.showIncomingActivity": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to show the experimental incoming activity for each repository in the _GitKraken Workspaces_ view", + "scope": "window", + "order": 37 + }, + "gitlens.views.workspaces.avatars": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to show the What's New notification after upgrading to new feature releases", + "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _GitKraken Workspaces_ view", "scope": "window", - "order": 20 + "order": 60 }, - "gitlens.outputLevel": { + "gitlens.views.workspaces.branches.layout": { "type": "string", - "default": "errors", + "default": "tree", "enum": [ - "silent", - "errors", - "verbose", - "debug" + "list", + "tree" ], "enumDescriptions": [ - "Logs nothing", - "Logs only errors", - "Logs all errors, warnings, and messages", - "Logs all errors, warnings, and messages with extra context useful for debugging" + "Displays branches as a list", + "Displays branches as a tree when branch names contain slashes `/`" ], - "markdownDescription": "Specifies how much (if any) output will be sent to the GitLens output channel", + "markdownDescription": "Specifies how the _GitKraken Workspaces_ view will display branches", "scope": "window", - "order": 30 + "order": 70 }, - "gitlens.defaultGravatarsStyle": { + "gitlens.views.workspaces.files.layout": { "type": "string", - "default": "robohash", + "default": "auto", "enum": [ - "identicon", - "mp", - "monsterid", - "retro", - "robohash", - "wavatar" + "auto", + "list", + "tree" ], "enumDescriptions": [ - "A geometric pattern", - "A simple, cartoon-style silhouetted outline of a person (does not vary by email hash)", - "A monster with different colors, faces, etc", - "8-bit arcade-style pixelated faces", - "A robot with different colors, faces, etc", - "A face with differing features and backgrounds" + "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.workspaces.files.threshold#` value and the number of files at each nesting level", + "Displays files as a list", + "Displays files as a tree" ], - "markdownDescription": "Specifies the style of the gravatar default (fallback) images", + "markdownDescription": "Specifies how the _GitKraken Workspaces_ view will display files", "scope": "window", - "order": 40 + "order": 80 }, - "gitlens.fileAnnotations.command": { + "gitlens.views.workspaces.files.threshold": { + "type": "number", + "default": 5, + "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _GitKraken Workspaces_ view. Only applies when `#gitlens.views.workspaces.files.layout#` is set to `auto`", + "scope": "window", + "order": 81 + }, + "gitlens.views.workspaces.files.compact": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _GitKraken Workspaces_ view. Only applies when `#gitlens.views.workspaces.files.layout#` is set to `tree` or `auto`", + "scope": "window", + "order": 82 + }, + "gitlens.views.workspaces.files.icon": { + "type": "string", + "default": "type", + "enum": [ + "status", + "type" + ], + "enumDescriptions": [ + "Shows the file's status as the icon", + "Shows the file's type (theme icon) as the icon" + ], + "markdownDescription": "Specifies how the _GitKraken Workspaces_ view will display file icons", + "scope": "window", + "order": 83 + }, + "gitlens.views.workspaces.compact": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to show the _GitKraken Workspaces_ view in a compact display density", + "scope": "window", + "order": 90 + }, + "gitlens.views.workspaces.branches.showBranchComparison": { "type": [ - "string", - "null" + "boolean", + "string" ], - "default": null, "enum": [ - null, - "blame", - "heatmap", - "changes" + false, + "branch" ], "enumDescriptions": [ - "Shows a menu to choose which file annotations to toggle", - "Toggles file blame annotations", - "Toggles file heatmap annotations", - "Toggles file changes annotations" + "Hides the branch comparison", + "Compares the branch with a user-selected reference" ], - "markdownDescription": "Specifies whether the file annotations button in the editor title shows a menu or immediately toggles the specified file annotations", + "default": "branch", + "markdownDescription": "Specifies whether to show a comparison of the branch with a user-selected reference (branch, tag, etc) under each branch in the _GitKraken Workspaces_ view", "scope": "window", - "order": 50 + "order": 100 + } + } + }, + { + "id": "rebase-editor", + "title": "Interactive Rebase Editor", + "order": 600, + "properties": { + "gitlens.rebaseEditor.ordering": { + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ], + "enumDescriptions": [ + "Shows oldest commit first", + "Shows newest commit first" + ], + "markdownDescription": "Specifies how Git commits are displayed in the _Interactive Rebase Editor_", + "scope": "window", + "order": 10 }, - "gitlens.proxy": { + "gitlens.rebaseEditor.showDetailsView": { "type": [ - "object", - "null" + "boolean", + "string" + ], + "default": "selection", + "enum": [ + false, + "open", + "selection" + ], + "enumDescriptions": [ + "Never shows the _Commit Details_ view automatically", + "Shows the _Commit Details_ view automatically only when opening the _Interactive Rebase Editor_", + "Shows the _Commit Details_ view automatically when selection changes in the _Interactive Rebase Editor_" + ], + "markdownDescription": "Specifies when to show the _Commit Details_ view for the selected row in the _Interactive Rebase Editor_", + "scope": "window", + "order": 20 + } + } + }, + { + "id": "git-command-palette", + "title": "Git Command Palette", + "order": 700, + "properties": { + "gitlens.gitCommands.avatars": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show avatar images in quick pick menus when applicable", + "scope": "window", + "order": 5 + }, + "gitlens.gitCommands.sortBy": { + "type": "string", + "default": "usage", + "enum": [ + "name", + "usage" + ], + "enumDescriptions": [ + "Sorts commands by name", + "Sorts commands by last used date" + ], + "markdownDescription": "Specifies how Git commands are sorted in the _Git Command Palette_", + "scope": "window", + "order": 10 + }, + "gitlens.gitCommands.skipConfirmations": { + "type": "array", + "default": [ + "fetch:command", + "stash-push:command" ], - "default": null, "items": { - "type": "object", - "required": [ - "url", - "strictSSL" + "type": "string", + "enum": [ + "branch-create:command", + "branch-create:menu", + "co-authors:command", + "co-authors:menu", + "fetch:command", + "fetch:menu", + "pull:command", + "pull:menu", + "push:command", + "push:menu", + "stash-apply:command", + "stash-apply:menu", + "stash-pop:command", + "stash-pop:menu", + "stash-push:command", + "stash-push:menu", + "switch:command", + "switch:menu", + "tag-create:command", + "tag-create:menu" ], - "properties": { - "url": { - "type": [ - "string", - "null" - ], - "default": null, - "description": "Specifies the URL of the proxy server to use" - }, - "strictSSL": { - "type": "boolean", - "description": "Specifies whether the proxy server certificate should be verified against the list of supplied CAs", - "default": true - } - }, - "additionalProperties": false + "enumDescriptions": [ + "Skips branch create confirmations when run from a command, e.g. a view action", + "Skips branch create confirmations when run from the Git Command Palette", + "Skips co-author confirmations when run from a command, e.g. a view action", + "Skips co-author confirmations when run from the Git Command Palette", + "Skips fetch confirmations when run from a command, e.g. a view action", + "Skips fetch confirmations when run from the Git Command Palette", + "Skips pull confirmations when run from a command, e.g. a view action", + "Skips pull confirmations when run from the Git Command Palette", + "Skips push confirmations when run from a command, e.g. a view action", + "Skips push confirmations when run from the Git Command Palette", + "Skips stash apply confirmations when run from a command, e.g. a view action", + "Skips stash apply confirmations when run from the Git Command Palette", + "Skips stash pop confirmations when run from a command, e.g. a view action", + "Skips stash pop confirmations when run from the Git Command Palette", + "Skips stash push confirmations when run from a command, e.g. a view action", + "Skips stash push confirmations when run from the Git Command Palette", + "Skips switch confirmations when run from a command, e.g. a view action", + "Skips switch confirmations when run from the Git Command Palette", + "Skips tag create confirmations when run from a command, e.g. a view action", + "Skips tag create confirmations when run from the Git Command Palette" + ] }, + "minItems": 0, + "maxItems": 14, "uniqueItems": true, - "description": "Specifies the proxy configuration to use. If not specified, the proxy configuration will be determined based on VS Code or OS settings", + "markdownDescription": "Specifies which (and when) Git commands will skip the confirmation step, using the format: `git-command-name:(menu|command)`", "scope": "window", - "order": 55 + "order": 20 }, - "gitlens.plusFeatures.enabled": { + "gitlens.gitCommands.closeOnFocusOut": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to hide or show GitLens+ features that are not accessible given the opened repositories and current subscription", + "markdownDescription": "Specifies whether to dismiss the _Git Command Palette_ when focus is lost (if not, press `ESC` to dismiss)", "scope": "window", - "order": 60 + "order": 30 }, - "gitlens.virtualRepositories.enabled": { + "gitlens.gitCommands.search.showResultsInSideBar": { + "type": [ + "boolean", + "null" + ], + "default": null, + "markdownDescription": "Specifies whether to show the commit search results directly in the quick pick menu, in the Side Bar, or will be based on the context", + "scope": "window", + "order": 40 + }, + "gitlens.gitCommands.search.matchAll": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to match all or any commit message search patterns", + "scope": "window", + "order": 50 + }, + "gitlens.gitCommands.search.matchCase": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to match commit search patterns with or without regard to casing", + "scope": "window", + "order": 51 + }, + "gitlens.gitCommands.search.matchRegex": { "type": "boolean", "default": true, - "markdownDescription": "Specifies whether to enable virtual repositories support", + "markdownDescription": "Specifies whether to match commit search patterns using regular expressions", "scope": "window", - "order": 70 + "order": 52 }, - "gitlens.insiders": { - "deprecationMessage": "Deprecated. Use the Insiders edition of GitLens instead", - "markdownDeprecationMessage": "Deprecated. Use the [Insiders edition](https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens-insiders) of GitLens instead" + "gitlens.gitCommands.search.showResultsInView": { + "deprecationMessage": "Deprecated. This setting has been renamed to gitlens.gitCommands.search.showResultsInSideBar", + "markdownDeprecationMessage": "Deprecated. This setting has been renamed to `#gitlens.gitCommands.search.showResultsInSideBar#`" } } - } - ], - "configurationDefaults": { - "[azure-pipelines]": { - "gitlens.codeLens.scopes": [ - "document" - ] }, - "[ansible]": { - "gitlens.codeLens.scopes": [ - "document" - ] + { + "id": "integrations", + "title": "Integrations", + "order": 800, + "properties": { + "gitlens.autolinks": { + "type": [ + "array", + "null" + ], + "default": null, + "items": { + "type": "object", + "required": [ + "prefix", + "url" + ], + "properties": { + "prefix": { + "type": "string", + "description": "Specifies the short prefix to use to generate autolinks for the external resource" + }, + "title": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "Specifies an optional title for the generated autolink. Use `` as the variable for the reference number" + }, + "url": { + "type": "string", + "description": "Specifies the URL of the external resource you want to link to. Use `` as the variable for the reference number" + }, + "alphanumeric": { + "type": "boolean", + "description": "Specifies whether alphanumeric characters should be allowed in ``", + "default": false + }, + "ignoreCase": { + "type": "boolean", + "description": "Specifies whether case should be ignored when matching the prefix", + "default": false + } + }, + "additionalProperties": false + }, + "uniqueItems": true, + "markdownDescription": "Specifies autolinks to external resources in commit messages. Use `` as the variable for the reference number", + "scope": "window", + "order": 10 + }, + "gitlens.integrations.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to enable rich integrations with any supported remote services", + "scope": "window", + "order": 20 + }, + "gitlens.cloudIntegrations.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to use the GitKraken cloud integration when authenticating with GitHub", + "scope": "window", + "order": 30 + }, + "gitlens.remotes": { + "type": [ + "array", + "null" + ], + "default": null, + "items": { + "type": "object", + "required": [ + "type" + ], + "oneOf": [ + { + "required": [ + "domain" + ] + }, + { + "required": [ + "regex" + ] + } + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "Custom", + "AzureDevOps", + "Bitbucket", + "BitbucketServer", + "Gerrit", + "GoogleSource", + "Gitea", + "GitHub", + "GitLab" + ], + "description": "Specifies the type of the custom remote service" + }, + "domain": { + "type": "string", + "description": "Specifies the domain name used to match this custom configuration to a Git remote" + }, + "regex": { + "type": "string", + "description": "Specifies a regular expression used to match this custom configuration to a Git remote and capture the \"domain name\" and \"path\"" + }, + "name": { + "type": "string", + "description": "Specifies an optional friendly name for the custom remote service" + }, + "protocol": { + "type": "string", + "default": "https", + "description": "Specifies an optional URL protocol for the custom remote service" + }, + "ignoreSSLErrors": { + "type": "boolean", + "default": false, + "description": "Specifies whether to ignore invalid SSL certificate errors when connecting to the remote service" + }, + "urls": { + "type": "object", + "required": [ + "repository", + "branches", + "branch", + "commit", + "file", + "fileInCommit", + "fileInBranch", + "fileLine", + "fileRange" + ], + "properties": { + "repository": { + "type": "string", + "markdownDescription": "Specifies the format of a repository URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path" + }, + "branches": { + "type": "string", + "markdownDescription": "Specifies the format of a branches URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${branch}` — branch" + }, + "branch": { + "type": "string", + "markdownDescription": "Specifies the format of a branch URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${branch}` — branch" + }, + "commit": { + "type": "string", + "markdownDescription": "Specifies the format of a commit URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${id}` — commit SHA" + }, + "file": { + "type": "string", + "markdownDescription": "Specifies the format of a file URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${file}` — file name\\\n`${line}` — formatted line information" + }, + "fileInBranch": { + "type": "string", + "markdownDescription": "Specifies the format of a branch file URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${file}` — file name\\\n`${branch}` — branch\\\n`${line}` — formatted line information" + }, + "fileInCommit": { + "type": "string", + "markdownDescription": "Specifies the format of a commit file URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${file}` — file name\\\n`${id}` — commit SHA\\\n`${line}` — formatted line information" + }, + "fileLine": { + "type": "string", + "markdownDescription": "Specifies the format of a line in a file URL for the custom remote service\n\nAvailable tokens\\\n`${line}` — line" + }, + "fileRange": { + "type": "string", + "markdownDescription": "Specifies the format of a range in a file URL for the custom remote service\n\nAvailable tokens\\\n`${start}` — starting line\\\n`${end}` — ending line" + } + }, + "additionalProperties": false + } + } + }, + "uniqueItems": true, + "markdownDescription": "Specifies custom remote services to be matched with Git remotes to detect custom domains for built-in remote services or provide support for custom remote services", + "scope": "resource", + "order": 30 + }, + "gitlens.partners": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Specifies whether the partner integration should be shown" + } + }, + "additionalProperties": true, + "description": "Specifies the configuration of a partner integration" + }, + "default": null, + "description": "Specifies the configuration of a partner integration", + "scope": "window", + "order": 40 + }, + "gitlens.liveshare.enabled": { + "type": "boolean", + "default": true, + "description": "Specifies whether to enable integration with Visual Studio Live Share", + "scope": "window", + "order": 50 + }, + "gitlens.liveshare.allowGuestAccess": { + "type": "boolean", + "default": true, + "description": "Specifies whether to allow guest access to GitLens features when using Visual Studio Live Share", + "scope": "window", + "order": 51 + } + } }, - "[css]": { - "gitlens.codeLens.scopes": [ - "document" - ] - }, - "[html]": { - "gitlens.codeLens.scopes": [ - "document" - ] - }, - "[json]": { - "gitlens.codeLens.scopes": [ - "document" - ] - }, - "[jsonc]": { - "gitlens.codeLens.scopes": [ - "document" - ] - }, - "[less]": { - "gitlens.codeLens.scopes": [ - "document" - ] - }, - "[postcss]": { - "gitlens.codeLens.scopes": [ - "document" - ] - }, - "[python]": { - "gitlens.codeLens.symbolScopes": [ - "!Module" - ] - }, - "[scss]": { - "gitlens.codeLens.scopes": [ - "document" - ] - }, - "[stylus]": { - "gitlens.codeLens.scopes": [ - "document" - ] - }, - "[vue]": { - "gitlens.codeLens.scopes": [ - "document" - ] - }, - "[yaml]": { - "gitlens.codeLens.scopes": [ - "document" - ] - } - }, - "colors": [ - { - "id": "gitlens.gutterBackgroundColor", - "description": "Specifies the background color of the file blame annotations", - "defaults": { - "dark": "#FFFFFF13", - "light": "#0000000C", - "highContrast": "#FFFFFF13" - } + { + "id": "terminal", + "title": "Terminal", + "order": 900, + "properties": { + "gitlens.terminalLinks.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to enable terminal links — autolinks in the integrated terminal to quickly jump to more details for commits, branches, tags, and more", + "scope": "window", + "order": 10 + }, + "gitlens.terminalLinks.showDetailsView": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the _Commit Details_ view when clicking on a commit link in the integrated terminal", + "scope": "window", + "order": 20 + }, + "gitlens.terminal.overrideGitEditor": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to use VS Code as Git's `core.editor` for Gitlens terminal commands", + "scope": "window", + "order": 100 + } + } }, { - "id": "gitlens.gutterForegroundColor", - "description": "Specifies the foreground color of the file blame annotations", - "defaults": { - "dark": "#BEBEBE", - "light": "#747474", - "highContrast": "#BEBEBE" + "id": "ai", + "title": "AI (ᴇxᴘᴇʀÉĒᴍᴇɴᴛᴀʟ)", + "order": 1000, + "properties": { + "gitlens.ai.experimental.generateCommitMessage.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to enable GitLens' experimental, AI-powered, on-demand commit message generation", + "scope": "window", + "order": 1 + }, + "gitlens.experimental.generateCommitMessagePrompt": { + "type": "string", + "default": "Now, please generate a commit message. Ensure that it includes a precise and informative subject line that succinctly summarizes the crux of the changes in under 50 characters. If necessary, follow with an explanatory body providing insight into the nature of the changes, the reasoning behind them, and any significant consequences or considerations arising from them. Conclude with any relevant issue references at the end of the message.", + "markdownDescription": "Specifies the prompt to use to tell the AI provider how to structure or format the generated commit message", + "scope": "window", + "order": 2 + }, + "gitlens.experimental.generateCloudPatchMessagePrompt": { + "type": "string", + "default": "Now, please generate a title and optional description. Ensure that it includes a precise and informative subject line that succinctly summarizes the crux of the changes in under 50 characters. If necessary, follow with an explanatory body providing insight into the nature of the changes, the reasoning behind them, and any significant consequences or considerations arising from them. Conclude with any relevant issue references at the end of the message.", + "markdownDescription": "Specifies the prompt to use to tell the AI provider how to structure or format the generated title and description", + "scope": "window", + "order": 3 + }, + "gitlens.experimental.generateCodeSuggestMessagePrompt": { + "type": "string", + "default": "Now, please generate a title and optional description. Ensure that it includes a precise and informative subject line that succinctly summarizes the crux of the changes in under 50 characters. If necessary, follow with an explanatory body providing insight into the nature of the changes, the reasoning behind them, and any significant consequences or considerations arising from them. Conclude with any relevant issue references at the end of the message.", + "markdownDescription": "Specifies the prompt to use to tell the AI provider how to structure or format the generated title and description", + "scope": "window", + "order": 3 + }, + "gitlens.ai.experimental.model": { + "type": [ + "string", + "null" + ], + "default": null, + "enum": [ + "openai:gpt-4o", + "openai:gpt-4o-mini", + "openai:gpt-4-turbo", + "openai:gpt-4-turbo-preview", + "openai:gpt-4", + "openai:gpt-4-32k", + "openai:gpt-3.5-turbo", + "openai:gpt-3.5-turbo-16k", + "anthropic:claude-3-opus-20240229", + "anthropic:claude-3-5-sonnet-20240620", + "anthropic:claude-3-sonnet-20240229", + "anthropic:claude-3-haiku-20240307", + "anthropic:claude-2.1", + "anthropic:claude-2", + "anthropic:claude-instant-1", + "google:gemini-1.5-pro-latest", + "google:gemini-1.5-flash-latest", + "google:gemini-1.0-pro", + "vscode" + ], + "enumDescriptions": [ + "OpenAI GPT-4 Omni", + "OpenAI GPT-4 Omni Mini", + "OpenAI GPT-4 Turbo with Vision", + "OpenAI GPT-4 Turbo Preview", + "OpenAI GPT-4", + "OpenAI GPT-4 32k", + "OpenAI GPT-3.5 Turbo", + "OpenAI GPT-3.5 Turbo 16k", + "Anthropic Claude 3 Opus", + "Anthropic Claude 3.5 Sonnet", + "Anthropic Claude 3 Sonnet", + "Anthropic Claude 3 Haiku", + "Anthropic Claude 2.1", + "Anthropic Claude 2", + "Anthropic Claude Instant 1.2", + "Google Gemini 1.5 Pro (Latest)", + "Google Gemini 1.5 Flash", + "Google Gemini 1.0 Pro", + "VS Code Extension" + ], + "markdownDescription": "Specifies the AI model to use for GitLens' experimental AI features", + "scope": "window", + "order": 100 + }, + "gitlens.ai.experimental.openai.url": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "Specifies a custom URL to use for access to an OpenAI model via Azure. Azure URLs should be in the following format: https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}/chat/completions?api-version={api-version}", + "scope": "window", + "order": 102 + }, + "gitlens.ai.experimental.vscode.model": { + "type": [ + "string", + "null" + ], + "default": null, + "pattern": "^(.*):(.*)$", + "markdownDescription": "Specifies the VS Code provided model to use for GitLens' experimental AI features, formatted as `vendor:family`", + "scope": "window", + "order": 105 + } } }, { - "id": "gitlens.gutterUncommittedForegroundColor", - "description": "Specifies the foreground color of an uncommitted line in the file blame annotations", - "defaults": { - "dark": "#00BCF299", - "light": "#00BCF299", - "highContrast": "#00BCF2FF" + "id": "date-times", + "title": "Date & Times", + "order": 1100, + "properties": { + "gitlens.defaultDateStyle": { + "type": "string", + "default": "relative", + "enum": [ + "relative", + "absolute" + ], + "enumDescriptions": [ + "e.g. 1 day ago", + "e.g. July 25th, 2018 7:18pm" + ], + "markdownDescription": "Specifies how dates will be displayed by default", + "scope": "window", + "order": 10 + }, + "gitlens.defaultDateFormat": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "Specifies how absolute dates will be formatted by default. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", + "scope": "window", + "order": 20 + }, + "gitlens.defaultDateLocale": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "Specifies the locale, a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag#List_of_major_primary_language_subtags), to use for date formatting, defaults to the VS Code locale. Use `system` to follow the current system locale, or choose a specific locale, e.g `en-US` — US English, `en-GB` — British English, `de-DE` — German, `ja-JP` = Japanese, etc.", + "scope": "window", + "order": 21 + }, + "gitlens.defaultDateShortFormat": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "Specifies how short absolute dates will be formatted by default. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", + "scope": "window", + "order": 22 + }, + "gitlens.defaultTimeFormat": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "Specifies how times will be formatted by default. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for supported formats", + "scope": "window", + "order": 30 + }, + "gitlens.defaultDateSource": { + "type": "string", + "default": "authored", + "enum": [ + "authored", + "committed" + ], + "enumDescriptions": [ + "Uses the date when the changes were authored (i.e. originally written)", + "Uses the date when the changes were committed" + ], + "markdownDescription": "Specifies whether commit dates should use the authored or committed date", + "scope": "window", + "order": 40 + } } }, { - "id": "gitlens.trailingLineBackgroundColor", - "description": "Specifies the background color of the blame annotation for the current line", - "defaults": { - "dark": "#00000000", - "light": "#00000000", - "highContrast": "#00000000" + "id": "sorting", + "title": "Sorting", + "order": 1200, + "properties": { + "gitlens.sortRepositoriesBy": { + "type": "string", + "default": "discovered", + "enum": [ + "discovered", + "lastFetched:desc", + "lastFetched:asc", + "name:asc", + "name:desc" + ], + "enumDescriptions": [ + "Sorts repositories by discovery or workspace order", + "Sorts repositories by last fetched date in descending order", + "Sorts repositories by last fetched date in ascending order", + "Sorts repositories by name in ascending order", + "Sorts repositories by name in descending order" + ], + "markdownDescription": "Specifies how repositories are sorted in quick pick menus and views", + "scope": "window", + "order": 10 + }, + "gitlens.sortBranchesBy": { + "type": "string", + "default": "date:desc", + "enum": [ + "date:desc", + "date:asc", + "name:asc", + "name:desc" + ], + "enumDescriptions": [ + "Sorts branches by the most recent commit date in descending order", + "Sorts branches by the most recent commit date in ascending order", + "Sorts branches by name in ascending order", + "Sorts branches by name in descending order" + ], + "markdownDescription": "Specifies how branches are sorted in quick pick menus and views", + "scope": "window", + "order": 20 + }, + "gitlens.sortTagsBy": { + "type": "string", + "default": "date:desc", + "enum": [ + "date:desc", + "date:asc", + "name:asc", + "name:desc" + ], + "enumDescriptions": [ + "Sorts tags by date in descending order", + "Sorts tags by date in ascending order", + "Sorts tags by name in ascending order", + "Sorts tags by name in descending order" + ], + "markdownDescription": "Specifies how tags are sorted in quick pick menus and views", + "scope": "window", + "order": 30 + }, + "gitlens.sortContributorsBy": { + "type": "string", + "default": "count:desc", + "enum": [ + "count:desc", + "count:asc", + "date:desc", + "date:asc", + "name:asc", + "name:desc" + ], + "enumDescriptions": [ + "Sorts contributors by commit count in descending order", + "Sorts contributors by commit count in ascending order", + "Sorts contributors by the most recent commit date in descending order", + "Sorts contributors by the most recent commit date in ascending order", + "Sorts contributors by name in ascending order", + "Sorts contributors by name in descending order" + ], + "markdownDescription": "Specifies how contributors are sorted in quick pick menus and views", + "scope": "window", + "order": 40 + } } }, { - "id": "gitlens.trailingLineForegroundColor", - "description": "Specifies the foreground color of the blame annotation for the current line", - "defaults": { - "dark": "#99999959", - "light": "#99999959", + "id": "menus-toolbars", + "title": "Menus & Toolbars", + "order": 1300, + "properties": { + "gitlens.menus": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "editor": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "blame": { + "type": "boolean" + }, + "clipboard": { + "type": "boolean" + }, + "compare": { + "type": "boolean" + }, + "history": { + "type": "boolean" + }, + "remote": { + "type": "boolean" + } + } + } + ] + }, + "editorGroup": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "blame": { + "type": "boolean" + }, + "compare": { + "type": "boolean" + } + } + } + ] + }, + "editorGutter": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "compare": { + "type": "boolean" + }, + "remote": { + "type": "boolean" + }, + "share": { + "type": "boolean" + } + } + } + ] + }, + "editorTab": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "clipboard": { + "type": "boolean" + }, + "compare": { + "type": "boolean" + }, + "history": { + "type": "boolean" + }, + "remote": { + "type": "boolean" + } + } + } + ] + }, + "explorer": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "clipboard": { + "type": "boolean" + }, + "compare": { + "type": "boolean" + }, + "history": { + "type": "boolean" + }, + "remote": { + "type": "boolean" + } + } + } + ] + }, + "ghpr": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "worktree": { + "type": "boolean" + } + } + } + ] + }, + "scm": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "graph": { + "type": "boolean" + } + } + } + ] + }, + "scmRepositoryInline": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "graph": { + "type": "boolean" + }, + "stash": { + "type": "boolean" + } + } + } + ] + }, + "scmRepository": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "authors": { + "type": "boolean" + }, + "generateCommitMessage": { + "type": "boolean" + }, + "graph": { + "type": "boolean" + }, + "patch": { + "type": "boolean" + } + } + } + ] + }, + "scmGroupInline": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "stash": { + "type": "boolean" + } + } + } + ] + }, + "scmGroup": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "compare": { + "type": "boolean" + }, + "openClose": { + "type": "boolean" + }, + "patch": { + "type": "boolean" + }, + "stash": { + "type": "boolean" + } + } + } + ] + }, + "scmItemInline": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "stash": { + "type": "boolean" + } + } + } + ] + }, + "scmItem": { + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "object", + "properties": { + "clipboard": { + "type": "boolean" + }, + "compare": { + "type": "boolean" + }, + "history": { + "type": "boolean" + }, + "remote": { + "type": "boolean" + }, + "share": { + "type": "boolean" + }, + "stash": { + "type": "boolean" + } + } + } + ] + } + }, + "additionalProperties": false + } + ], + "default": { + "editor": { + "blame": true, + "clipboard": true, + "compare": true, + "history": true, + "remote": true + }, + "editorGroup": { + "blame": true, + "compare": true + }, + "editorGutter": { + "compare": true, + "remote": true, + "share": true + }, + "editorTab": { + "clipboard": true, + "compare": true, + "history": true, + "remote": true + }, + "explorer": { + "clipboard": true, + "compare": true, + "history": true, + "remote": true + }, + "ghpr": { + "worktree": true + }, + "scm": { + "graph": true + }, + "scmRepositoryInline": { + "graph": true, + "stash": false + }, + "scmRepository": { + "authors": true, + "generateCommitMessage": true, + "patch": true, + "graph": false + }, + "scmGroupInline": { + "stash": true + }, + "scmGroup": { + "compare": true, + "openClose": true, + "patch": true, + "stash": true + }, + "scmItemInline": { + "stash": false + }, + "scmItem": { + "clipboard": true, + "compare": true, + "history": true, + "remote": true, + "share": true, + "stash": true + } + }, + "markdownDescription": "Specifies which commands will be added to which menus", + "scope": "window", + "order": 10 + } + } + }, + { + "id": "keyboard", + "title": "Keyboard Shortcuts", + "order": 1400, + "properties": { + "gitlens.keymap": { + "type": "string", + "default": "chorded", + "enum": [ + "alternate", + "chorded", + "none" + ], + "enumDescriptions": [ + "Adds an alternate set of shortcut keys that start with `Alt` (âŒĨ on macOS)", + "Adds a chorded set of shortcut keys that start with `Ctrl+Shift+G` (`âŒĨ⌘G` on macOS)", + "No shortcut keys will be added" + ], + "markdownDescription": "Specifies the keymap to use for GitLens shortcut keys", + "scope": "window", + "order": 10 + } + } + }, + { + "id": "modes", + "title": "Modes", + "order": 1500, + "properties": { + "gitlens.mode.statusBar.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to provide the active GitLens mode in the status bar", + "scope": "window", + "order": 10 + }, + "gitlens.mode.statusBar.alignment": { + "type": "string", + "default": "right", + "enum": [ + "left", + "right" + ], + "enumDescriptions": [ + "Aligns to the left", + "Aligns to the right" + ], + "markdownDescription": "Specifies the active GitLens mode alignment in the status bar", + "scope": "window", + "order": 11 + }, + "gitlens.mode.active": { + "type": "string", + "markdownDescription": "Specifies the active GitLens mode, if any", + "scope": "window", + "order": 20 + }, + "gitlens.modes": { + "type": "object", + "properties": { + "zen": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Specifies the friendly name of this user-defined mode" + }, + "statusBarItemName": { + "type": "string", + "description": "Specifies the name shown in the status bar when this user-defined mode is active" + }, + "description": { + "type": "string", + "description": "Specifies the description of this user-defined mode" + }, + "codeLens": { + "type": "boolean", + "description": "Specifies whether to show any Git CodeLens when this user-defined mode is active" + }, + "currentLine": { + "type": "boolean", + "description": "Specifies whether to show an inline blame annotation for the current line when this user-defined mode is active" + }, + "hovers": { + "type": "boolean", + "description": "Specifies whether to show any hovers when this user-defined mode is active" + }, + "statusBar": { + "type": "boolean", + "description": "Specifies whether to show blame information in the status bar when this user-defined mode is active" + } + } + }, + "review": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Specifies the friendly name of this user-defined mode" + }, + "statusBarItemName": { + "type": "string", + "description": "Specifies the name shown in the status bar when this user-defined mode is active" + }, + "description": { + "type": "string", + "description": "Specifies the description of this user-defined mode" + }, + "codeLens": { + "type": "boolean", + "description": "Specifies whether to show any Git CodeLens when this user-defined mode is active" + }, + "currentLine": { + "type": "boolean", + "description": "Specifies whether to show an inline blame annotation for the current line when this user-defined mode is active" + }, + "hovers": { + "type": "boolean", + "description": "Specifies whether to show any hovers when this user-defined mode is active" + }, + "statusBar": { + "type": "boolean", + "description": "Specifies whether to show blame information in the status bar when this user-defined mode is active" + } + } + } + }, + "additionalProperties": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Specifies the friendly name of this user-defined mode" + }, + "statusBarItemName": { + "type": "string", + "description": "Specifies the name shown in the status bar when this user-defined mode is active" + }, + "description": { + "type": "string", + "description": "Specifies the description of this user-defined mode" + }, + "annotations": { + "type": "string", + "enum": [ + "blame", + "changes", + "heatmap" + ], + "enumDescriptions": [ + "Shows the file blame annotations", + "Shows the file changes annotations", + "Shows the file heatmap annotations" + ], + "description": "Specifies which (if any) file annotations will be shown when this user-defined mode is active" + }, + "codeLens": { + "type": "boolean", + "description": "Specifies whether to show any Git CodeLens when this user-defined mode is active" + }, + "currentLine": { + "type": "boolean", + "description": "Specifies whether to show an inline blame annotation for the current line when this user-defined mode is active" + }, + "hovers": { + "type": "boolean", + "description": "Specifies whether to show any hovers when this user-defined mode is active" + }, + "statusBar": { + "type": "boolean", + "description": "Specifies whether to show blame information in the status bar when this user-defined mode is active" + } + } + }, + "default": { + "zen": { + "name": "Zen", + "statusBarItemName": "Zen", + "description": "for a zen-like experience, disables many visual features", + "codeLens": false, + "currentLine": false, + "hovers": false, + "statusBar": false + }, + "review": { + "name": "Review", + "statusBarItemName": "Reviewing", + "description": "for reviewing code, enables many visual features", + "codeLens": true, + "currentLine": true, + "hovers": true + } + }, + "markdownDescription": "Specifies the user-defined GitLens modes", + "scope": "window", + "order": 30 + } + } + }, + { + "id": "gitkraken", + "title": "GitKraken", + "order": 9000, + "properties": { + "gitlens.gitKraken.activeOrganizationId": { + "type": "string", + "markdownDescription": "Specifies the ID of the user's active GitKraken organization in GitLens", + "scope": "window", + "order": 1 + } + } + }, + { + "id": "experimental", + "title": "Experimental", + "order": 9500, + "properties": { + "gitlens.cloudPatches.experimental.layout": { + "type": "string", + "default": "view", + "enum": [ + "editor", + "view" + ], + "enumDescriptions": [ + "Prefer showing Cloud Patches in the editor area", + "Prefer showing Cloud Patches in a view" + ], + "markdownDescription": "(Experimental) Specifies the preferred layout of for _Cloud Patches_", + "scope": "window", + "order": 20 + } + } + }, + { + "id": "advanced", + "title": "Advanced", + "order": 10000, + "properties": { + "gitlens.detectNestedRepositories": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to attempt to detect nested repositories when opening files", + "scope": "resource", + "order": 0 + }, + "gitlens.telemetry.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to allow GitLens to send product usage telemetry.\n\n_**Note:** For GitLens to send any telemetry BOTH this setting and VS Code telemetry must be enabled. If either one is disabled no telemetry will be sent._", + "scope": "window", + "order": 1 + }, + "gitlens.advanced.messages": { + "type": "object", + "default": { + "suppressCommitHasNoPreviousCommitWarning": false, + "suppressCommitNotFoundWarning": false, + "suppressCreatePullRequestPrompt": false, + "suppressDebugLoggingWarning": false, + "suppressFileNotUnderSourceControlWarning": false, + "suppressGitDisabledWarning": false, + "suppressGitMissingWarning": false, + "suppressGitVersionWarning": false, + "suppressLineUncommittedWarning": false, + "suppressNoRepositoryWarning": false, + "suppressRebaseSwitchToTextWarning": false, + "suppressIntegrationDisconnectedTooManyFailedRequestsWarning": false, + "suppressIntegrationRequestFailed500Warning": false, + "suppressIntegrationRequestTimedOutWarning": false, + "suppressBlameInvalidIgnoreRevsFileWarning": false, + "suppressBlameInvalidIgnoreRevsFileBadRevisionWarning": false + }, + "properties": { + "suppressCommitHasNoPreviousCommitWarning": { + "type": "boolean", + "default": false, + "description": "Commit Has No Previous Commit Warning" + }, + "suppressCommitNotFoundWarning": { + "type": "boolean", + "default": false, + "description": "Commit Not Found Warning" + }, + "suppressCreatePullRequestPrompt": { + "type": "boolean", + "default": false, + "description": "Create Pull Request Prompt" + }, + "suppressDebugLoggingWarning": { + "type": "boolean", + "default": false, + "description": "Debug Logging Warning" + }, + "suppressFileNotUnderSourceControlWarning": { + "type": "boolean", + "default": false, + "description": "File Not Under Source Control Warning" + }, + "suppressGitDisabledWarning": { + "type": "boolean", + "default": false, + "description": "Git Disabled Warning" + }, + "suppressGitMissingWarning": { + "type": "boolean", + "default": false, + "description": "Git Missing Warning" + }, + "suppressGitVersionWarning": { + "type": "boolean", + "default": false, + "description": "Git Version Warning" + }, + "suppressLineUncommittedWarning": { + "type": "boolean", + "default": false, + "description": "Line Uncommitted Warning" + }, + "suppressNoRepositoryWarning": { + "type": "boolean", + "default": false, + "description": "No Repository Warning" + }, + "suppressRebaseSwitchToTextWarning": { + "type": "boolean", + "default": false, + "description": "Rebase Switch To Text Warning" + }, + "suppressIntegrationDisconnectedTooManyFailedRequestsWarning": { + "type": "boolean", + "default": false, + "description": "Integration Disconnected; Too Many Failed Requests Warning" + }, + "suppressIntegrationRequestFailed500Warning": { + "type": "boolean", + "default": false, + "description": "Integration Request Failed (500 status code) Warning" + }, + "suppressIntegrationRequestTimedOutWarning": { + "type": "boolean", + "default": false, + "description": "Integration Request Timed Out Warning" + }, + "suppressBlameInvalidIgnoreRevsFileWarning": { + "type": "boolean", + "default": false, + "description": "Invalid Blame IgnoreRevs File Warning" + }, + "suppressBlameInvalidIgnoreRevsFileBadRevisionWarning": { + "type": "boolean", + "default": false, + "description": "Invalid Revision in Blame IgnoreRevs File Warning" + } + }, + "additionalProperties": false, + "markdownDescription": "Specifies which messages should be suppressed", + "scope": "window", + "order": 5 + }, + "gitlens.advanced.repositorySearchDepth": { + "type": [ + "number", + "null" + ], + "default": null, + "markdownDescription": "Specifies how many folders deep to search for repositories. Defaults to `#git.repositoryScanMaxDepth#`", + "scope": "resource", + "order": 10 + }, + "gitlens.advanced.abbreviatedShaLength": { + "type": "number", + "default": 7, + "markdownDescription": "Specifies the length of abbreviated commit SHAs", + "scope": "window", + "order": 20 + }, + "gitlens.advanced.abbreviateShaOnCopy": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to copy full or abbreviated commit SHAs to the clipboard. Abbreviates to the length of `#gitlens.advanced.abbreviatedShaLength#`.", + "scope": "window", + "order": 21 + }, + "gitlens.advanced.commitOrdering": { + "type": [ + "string", + "null" + ], + "default": null, + "enum": [ + null, + "date", + "author-date", + "topo" + ], + "enumDescriptions": [ + "Shows commits in reverse chronological order", + "Shows commits in reverse chronological order of the commit timestamp", + "Shows commits in reverse chronological order of the author timestamp", + "Shows commits in reverse chronological order of the commit timestamp, but avoids intermixing multiple lines of history" + ], + "markdownDescription": "Specifies the order by which commits will be shown. If unspecified, commits will be shown in reverse chronological order", + "scope": "window", + "order": 30 + }, + "gitlens.blame.ignoreWhitespace": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies whether to ignore whitespace when comparing revisions during blame operations", + "scope": "resource", + "order": 40 + }, + "gitlens.advanced.blame.customArguments": { + "type": [ + "array", + "null" + ], + "default": null, + "items": { + "type": "string" + }, + "markdownDescription": "Specifies additional arguments to pass to the `git blame` command", + "scope": "resource", + "order": 41 + }, + "gitlens.advanced.similarityThreshold": { + "type": [ + "number", + "null" + ], + "default": null, + "markdownDescription": "Specifies the amount (percent) of similarity a deleted and added file pair must have to be considered a rename", + "scope": "window", + "order": 50 + }, + "gitlens.advanced.externalDiffTool": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "Specifies an optional external diff tool to use when comparing files. Must be a configured [Git difftool](https://git-scm.com/docs/git-config#Documentation/git-config.txt-difftool).", + "scope": "window", + "order": 60 + }, + "gitlens.advanced.externalDirectoryDiffTool": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "Specifies an optional external diff tool to use when comparing directories. Must be a configured [Git difftool](https://git-scm.com/docs/git-config#Documentation/git-config.txt-difftool).", + "scope": "window", + "order": 61 + }, + "gitlens.advanced.quickPick.closeOnFocusOut": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to dismiss quick pick menus when focus is lost (if not, press `ESC` to dismiss)", + "scope": "window", + "order": 70 + }, + "gitlens.advanced.maxListItems": { + "type": "number", + "default": 200, + "markdownDescription": "Specifies the maximum number of items to show in a list. Use 0 to specify no maximum", + "scope": "window", + "order": 80 + }, + "gitlens.advanced.maxSearchItems": { + "type": "number", + "default": 200, + "markdownDescription": "Specifies the maximum number of items to show in a search. Use 0 to specify no maximum", + "scope": "window", + "order": 81 + }, + "gitlens.advanced.caching.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether git output will be cached — changing the default is not recommended", + "scope": "window", + "order": 90 + }, + "gitlens.debug": { + "type": "boolean", + "default": false, + "markdownDescription": "Specifies debug mode", + "scope": "window", + "order": 100 + }, + "gitlens.deepLinks.schemeOverride": { + "type": [ + "boolean", + "string" + ], + "default": false, + "markdownDescription": "Specifies whether to override the default deep link scheme (vscode://) with the environment value or a specified value", + "scope": "window", + "order": 110 + }, + "gitlens.advanced.useSymmetricDifferenceNotation": { + "deprecationMessage": "Deprecated. This setting is no longer used", + "markdownDescription": "Deprecated. This setting is no longer used" + } + } + }, + { + "id": "general", + "title": "General", + "order": 0, + "properties": { + "gitlens.showWelcomeOnInstall": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the Welcome experience on first install", + "scope": "window", + "order": 10 + }, + "gitlens.showWhatsNewAfterUpgrades": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to show the What's New notification after upgrading to new feature releases", + "scope": "window", + "order": 20 + }, + "gitlens.outputLevel": { + "type": "string", + "default": "warn", + "enum": [ + "off", + "error", + "warn", + "info", + "debug" + ], + "enumDescriptions": [ + "Logs nothing", + "Logs only errors", + "Logs errors and warnings", + "Logs errors, warnings, and messages", + "Logs verbose errors, warnings, and messages. Best for issue reporting." + ], + "markdownDescription": "Specifies how much (if any) output will be sent to the GitLens output channel", + "scope": "window", + "order": 30 + }, + "gitlens.defaultGravatarsStyle": { + "type": "string", + "default": "robohash", + "enum": [ + "identicon", + "mp", + "monsterid", + "retro", + "robohash", + "wavatar" + ], + "enumDescriptions": [ + "A geometric pattern", + "A simple, cartoon-style silhouetted outline of a person (does not vary by email hash)", + "A monster with different colors, faces, etc", + "8-bit arcade-style pixelated faces", + "A robot with different colors, faces, etc", + "A face with differing features and backgrounds" + ], + "markdownDescription": "Specifies the style of the gravatar default (fallback) images", + "scope": "window", + "order": 40 + }, + "gitlens.proxy": { + "type": [ + "object", + "null" + ], + "default": null, + "items": { + "type": "object", + "required": [ + "url", + "strictSSL" + ], + "properties": { + "url": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "Specifies the URL of the proxy server to use" + }, + "strictSSL": { + "type": "boolean", + "description": "Specifies whether the proxy server certificate should be verified against the list of supplied CAs", + "default": true + } + }, + "additionalProperties": false + }, + "uniqueItems": true, + "description": "Specifies the proxy configuration to use. If not specified, the proxy configuration will be determined based on VS Code or OS settings", + "scope": "window", + "order": 55 + }, + "gitlens.plusFeatures.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to hide or show features that require a trial or paid plan and are not accessible given the opened repositories and current trial or plan", + "scope": "window", + "order": 60 + }, + "gitlens.virtualRepositories.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "Specifies whether to enable virtual repositories support", + "scope": "window", + "order": 70 + }, + "gitlens.insiders": { + "deprecationMessage": "Deprecated. Use the pre-release edition of GitLens instead", + "markdownDeprecationMessage": "Deprecated. Use the pre-release of GitLens instead" + } + } + } + ], + "configurationDefaults": { + "[ansible][azure-pipelines][css][dockerfile][dockercompose][html][json][jsonc][less][postcss][scss][stylus][vue][yaml]": { + "gitlens.codeLens.scopes": [ + "document" + ] + }, + "[python]": { + "gitlens.codeLens.symbolScopes": [ + "!Module" + ] + } + }, + "colors": [ + { + "id": "gitlens.gutterBackgroundColor", + "description": "Specifies the background color of the file blame annotations", + "defaults": { + "dark": "#FFFFFF13", + "light": "#0000000C", + "highContrast": "#FFFFFF13" + } + }, + { + "id": "gitlens.gutterForegroundColor", + "description": "Specifies the foreground color of the file blame annotations", + "defaults": { + "dark": "#BEBEBE", + "light": "#747474", + "highContrast": "#BEBEBE" + } + }, + { + "id": "gitlens.gutterUncommittedForegroundColor", + "description": "Specifies the foreground color of an uncommitted line in the file blame annotations", + "defaults": { + "dark": "#00BCF299", + "light": "#00BCF299", + "highContrast": "#00BCF2FF" + } + }, + { + "id": "gitlens.trailingLineBackgroundColor", + "description": "Specifies the background color of the inline blame annotation for the current line", + "defaults": { + "dark": "#00000000", + "light": "#00000000", + "highContrast": "#00000000" + } + }, + { + "id": "gitlens.trailingLineForegroundColor", + "description": "Specifies the foreground color of the inline blame annotation for the current line", + "defaults": { + "dark": "#99999959", + "light": "#99999959", "highContrast": "#99999999" } }, @@ -4149,9 +5184,9 @@ "id": "gitlens.decorations.branchUnpublishedForegroundColor", "description": "Specifies the decoration foreground color of branches that are not yet published to an upstream", "defaults": { - "dark": "#35b15e", - "light": "#35b15e", - "highContrast": "#4dff88" + "dark": "sideBar.foreground", + "light": "sideBar.foreground", + "highContrast": "sideBar.foreground" } }, { @@ -4164,7 +5199,52 @@ } }, { - "id": "gitlens.decorations.worktreeView.hasUncommittedChangesForegroundColor", + "id": "gitlens.decorations.statusMergingOrRebasingConflictForegroundColor", + "description": "Specifies the decoration foreground color of the status during a rebase operation with conflicts", + "defaults": { + "light": "#ad0707", + "dark": "#c74e39", + "highContrast": "#c74e39" + } + }, + { + "id": "gitlens.decorations.statusMergingOrRebasingForegroundColor", + "description": "Specifies the decoration foreground color of the status during a rebase operation", + "defaults": { + "dark": "#D8AF1B", + "light": "#D8AF1B", + "highContrast": "#D8AF1B" + } + }, + { + "id": "gitlens.decorations.workspaceRepoMissingForegroundColor", + "description": "Specifies the decoration foreground color of workspace repos which are missing a local path", + "defaults": { + "dark": "#909090", + "light": "#949494", + "highContrast": "#d3d3d3" + } + }, + { + "id": "gitlens.decorations.workspaceCurrentForegroundColor", + "description": "Specifies the decoration foreground color of workspaces which are currently open as a Code Workspace file", + "defaults": { + "dark": "#35b15e", + "light": "#35b15e", + "highContrast": "#4dff88" + } + }, + { + "id": "gitlens.decorations.workspaceRepoOpenForegroundColor", + "description": "Specifies the decoration foreground color of workspace repos which are open in the current workspace", + "defaults": { + "dark": "#35b15e", + "light": "#35b15e", + "highContrast": "#4dff88" + } + }, + { + "id": "gitlens.decorations.worktreeHasUncommittedChangesForegroundColor", "description": "Specifies the decoration foreground color for worktrees that have uncommitted changes", "defaults": { "light": "#895503", @@ -4172,6 +5252,15 @@ "highContrast": "#E2C08D" } }, + { + "id": "gitlens.decorations.worktreeMissingForegroundColor", + "description": "Specifies the decoration foreground color for worktrees cannot be found on disk", + "defaults": { + "light": "#ad0707", + "dark": "#c74e39", + "highContrast": "#c74e39" + } + }, { "id": "gitlens.graphLane1Color", "description": "Specifies the color for the first commit lane of the _Commit Graph_ visualization", @@ -4362,6 +5451,26 @@ "highContrast": "#3087cf" } }, + { + "id": "gitlens.graphMinimapMarkerPullRequestsColor", + "description": "Specifies the color marking pull requests on the minimap of the _Commit Graph_", + "defaults": { + "light": "#ff8f18", + "highContrastLight": "#ff8f18", + "dark": "#c76801", + "highContrast": "#c76801" + } + }, + { + "id": "gitlens.graphScrollMarkerPullRequestsColor", + "description": "Specifies the color marking pull requests on the scrollbar of the _Commit Graph_", + "defaults": { + "light": "#ff8f18", + "highContrastLight": "#ff8f18", + "dark": "#c76801", + "highContrast": "#c76801" + } + }, { "id": "gitlens.graphMinimapMarkerRemoteBranchesColor", "description": "Specifies the color marking remote branches on the minimap of the _Commit Graph_", @@ -4383,114 +5492,388 @@ } }, { - "id": "gitlens.graphMinimapMarkerStashesColor", - "description": "Specifies the color marking stashes on the minimap of the _Commit Graph_", - "defaults": { - "light": "#e467e4", - "highContrastLight": "#e467e4", - "dark": "#b34db3", - "highContrast": "#b34db3" - } + "id": "gitlens.graphMinimapMarkerStashesColor", + "description": "Specifies the color marking stashes on the minimap of the _Commit Graph_", + "defaults": { + "light": "#e467e4", + "highContrastLight": "#e467e4", + "dark": "#b34db3", + "highContrast": "#b34db3" + } + }, + { + "id": "gitlens.graphScrollMarkerStashesColor", + "description": "Specifies the color marking stashes on the scrollbar of the _Commit Graph_", + "defaults": { + "light": "#e467e4", + "highContrastLight": "#e467e4", + "dark": "#b34db3", + "highContrast": "#b34db3" + } + }, + { + "id": "gitlens.graphMinimapMarkerTagsColor", + "description": "Specifies the color marking tags on the minimap of the _Commit Graph_", + "defaults": { + "light": "#d2a379", + "highContrastLight": "#d2a379", + "dark": "#6b562e", + "highContrast": "#6b562e" + } + }, + { + "id": "gitlens.graphScrollMarkerTagsColor", + "description": "Specifies the color marking tags on the scrollbar of the _Commit Graph_", + "defaults": { + "light": "#d2a379", + "highContrastLight": "#d2a379", + "dark": "#6b562e", + "highContrast": "#6b562e" + } + }, + { + "id": "gitlens.launchpadIndicatorMergeableColor", + "description": "Specifies the color of the _Launchpad_ indicator icon when the priority is mergeable", + "defaults": { + "light": "#42c954", + "dark": "#3fb950", + "highContrast": "#68ff79" + } + }, + { + "id": "gitlens.launchpadIndicatorMergeableHoverColor", + "description": "Specifies the color of the _Launchpad_ indicator icon in the hover when the priority is mergeable", + "defaults": { + "light": "#42c954", + "dark": "#3fb950", + "highContrast": "#68ff79" + } + }, + { + "id": "gitlens.launchpadIndicatorBlockedColor", + "description": "Specifies the color of the _Launchpad_ indicator icon when the priority is blocked", + "defaults": { + "light": "#ad0707", + "dark": "#c74e39", + "highContrast": "#ff003c" + } + }, + { + "id": "gitlens.launchpadIndicatorBlockedHoverColor", + "description": "Specifies the color of the _Launchpad_ indicator icon in the hover when the priority is blocked", + "defaults": { + "light": "#ad0707", + "dark": "#c74e39", + "highContrast": "#ff003c" + } + }, + { + "id": "gitlens.launchpadIndicatorAttentionColor", + "description": "Specifies the color of the _Launchpad_ indicator icon when the priority is follow-up or needs review", + "defaults": { + "dark": "#D8AF1B", + "light": "#cc9b15", + "highContrast": "#D8AF1B" + } + }, + { + "id": "gitlens.launchpadIndicatorAttentionHoverColor", + "description": "Specifies the color of the _Launchpad_ indicator icon in the hover when the priority is follow-up or needs review", + "defaults": { + "dark": "#D8AF1B", + "light": "#cc9b15", + "highContrast": "#D8AF1B" + } + } + ], + "commands": [ + { + "command": "gitlens.generateCommitMessage", + "title": "Generate Commit Message (GitLens)...", + "category": "GitLens" + }, + { + "command": "gitlens.reset", + "title": "Reset Stored Data...", + "category": "GitLens" + }, + { + "command": "gitlens.resetAIKey", + "title": "Reset Stored AI Keys...", + "category": "GitLens" + }, + { + "command": "gitlens.plus.login", + "title": "Sign In to GitKraken...", + "category": "GitLens" + }, + { + "command": "gitlens.plus.logout", + "title": "Sign Out of GitKraken", + "category": "GitLens" + }, + { + "command": "gitlens.plus.signUp", + "title": "Sign Up for GitKraken...", + "category": "GitLens" + }, + { + "command": "gitlens.plus.startPreviewTrial", + "title": "Preview Pro", + "category": "GitLens" + }, + { + "command": "gitlens.plus.reactivateProTrial", + "title": "Reactivate Pro Trial", + "category": "GitLens" + }, + { + "command": "gitlens.plus.manage", + "title": "Manage Your Account...", + "category": "GitLens" + }, + { + "command": "gitlens.plus.cloudIntegrations.connect", + "title": "Connect Integrations...", + "category": "GitLens" + }, + { + "command": "gitlens.plus.cloudIntegrations.manage", + "title": "Manage Integrations...", + "category": "GitLens" + }, + { + "command": "gitlens.plus.upgrade", + "title": "Upgrade to Pro...", + "category": "GitLens" + }, + { + "command": "gitlens.plus.hide", + "title": "Hide Pro Features", + "category": "GitLens" + }, + { + "command": "gitlens.plus.restore", + "title": "Restore Pro Features", + "category": "GitLens" + }, + { + "command": "gitlens.plus.refreshRepositoryAccess", + "title": "Refresh Repository Access", + "category": "GitLens" + }, + { + "command": "gitlens.gk.switchOrganization", + "title": "Switch Organization...", + "category": "GitLens" + }, + { + "command": "gitlens.getStarted", + "title": "Get Started", + "category": "GitLens" + }, + { + "command": "gitlens.showPatchDetailsPage", + "title": "Show Patch Details", + "category": "GitLens" + }, + { + "command": "gitlens.applyPatchFromClipboard", + "title": "Apply Copied Changes (Patch)", + "category": "GitLens" + }, + { + "command": "gitlens.pastePatchFromClipboard", + "title": "Paste Copied Changes (Patch)", + "category": "GitLens" + }, + { + "command": "gitlens.copyPatchToClipboard", + "title": "Copy Changes (Patch)", + "category": "GitLens" + }, + { + "command": "gitlens.copyWorkingChangesToWorktree", + "title": "Copy Working Changes to Worktree...", + "category": "GitLens" + }, + { + "command": "gitlens.graph.copyWorkingChangesToWorktree", + "title": "Copy Working Changes to Worktree...", + "category": "GitLens" + }, + { + "command": "gitlens.createPatch", + "title": "Create Patch...", + "category": "GitLens" + }, + { + "command": "gitlens.graph.createPatch", + "title": "Create Patch...", + "category": "GitLens" + }, + { + "command": "gitlens.createCloudPatch", + "title": "Create Patch...", + "category": "GitLens" + }, + { + "command": "gitlens.graph.createCloudPatch", + "title": "Create Patch...", + "category": "GitLens" + }, + { + "command": "gitlens.shareAsCloudPatch", + "title": "Share as Cloud Patch...", + "category": "GitLens" + }, + { + "command": "gitlens.openCloudPatch", + "title": "Open Cloud Patch...", + "category": "GitLens" + }, + { + "command": "gitlens.openPatch", + "title": "Open Patch...", + "category": "GitLens" + }, + { + "command": "gitlens.showBranchesView", + "title": "Show Branches View", + "category": "GitLens" + }, + { + "command": "gitlens.showCommitDetailsView", + "title": "Show Inspect View", + "category": "GitLens" + }, + { + "command": "gitlens.showCommitsView", + "title": "Show Commits View", + "category": "GitLens" + }, + { + "command": "gitlens.showContributorsView", + "title": "Show Contributors View", + "category": "GitLens" + }, + { + "command": "gitlens.showDraftsView", + "title": "Show Cloud Patches View", + "category": "GitLens" }, { - "id": "gitlens.graphScrollMarkerStashesColor", - "description": "Specifies the color marking stashes on the scrollbar of the _Commit Graph_", - "defaults": { - "light": "#e467e4", - "highContrastLight": "#e467e4", - "dark": "#b34db3", - "highContrast": "#b34db3" - } + "command": "gitlens.showFileHistoryView", + "title": "Show File History View", + "category": "GitLens" }, { - "id": "gitlens.graphMinimapMarkerTagsColor", - "description": "Specifies the color marking tags on the minimap of the _Commit Graph_", - "defaults": { - "light": "#d2a379", - "highContrastLight": "#d2a379", - "dark": "#6b562e", - "highContrast": "#6b562e" - } + "command": "gitlens.showLaunchpad", + "title": "Open Launchpad", + "category": "GitLens", + "icon": "$(rocket)" }, { - "id": "gitlens.graphScrollMarkerTagsColor", - "description": "Specifies the color marking tags on the scrollbar of the _Commit Graph_", - "defaults": { - "light": "#d2a379", - "highContrastLight": "#d2a379", - "dark": "#6b562e", - "highContrast": "#6b562e" - } - } - ], - "commands": [ + "command": "gitlens.showLaunchpadView", + "title": "Show Launchpad View", + "category": "GitLens", + "icon": "$(rocket)" + }, { - "command": "gitlens.plus.learn", - "title": "Learn about GitLens+ Features", - "category": "GitLens+" + "command": "gitlens.showFocusPage", + "title": "Open Launchpad in Editor", + "category": "GitLens", + "icon": "$(rocket)" }, { - "command": "gitlens.plus.loginOrSignUp", - "title": "Sign In to GitLens+...", - "category": "GitLens+" + "command": "gitlens.launchpad.split", + "title": "Split Launchpad in Editor", + "category": "GitLens", + "icon": "$(split-horizontal)" }, { - "command": "gitlens.plus.logout", - "title": "Sign out of GitLens+", - "category": "GitLens+" + "command": "gitlens.launchpad.indicator.toggle", + "title": "Toggle Launchpad Indicator", + "category": "GitLens", + "icon": "$(rocket)" }, { - "command": "gitlens.plus.startPreviewTrial", - "title": "Try GitLens+ Features Now", - "category": "GitLens+" + "command": "gitlens.showGraph", + "title": "Show Commit Graph", + "category": "GitLens", + "icon": "$(gitlens-graph)" }, { - "command": "gitlens.plus.manage", - "title": "Manage Your GitLens+ Account...", - "category": "GitLens+" + "command": "gitlens.showGraphPage", + "title": "Show Commit Graph in Editor", + "category": "GitLens", + "icon": "$(gitlens-graph)" + }, + { + "command": "gitlens.graph.split", + "title": "Split Commit Graph", + "category": "GitLens", + "icon": "$(split-horizontal)" }, { - "command": "gitlens.plus.purchase", - "title": "Upgrade to GitLens Pro...", - "category": "GitLens+" + "command": "gitlens.showGraphView", + "title": "Show Commit Graph View", + "category": "GitLens", + "icon": "$(gitlens-graph)" }, { - "command": "gitlens.plus.hide", - "title": "Hide GitLens+ Features", - "category": "GitLens+" + "command": "gitlens.toggleGraph", + "title": "Toggle Commit Graph", + "category": "GitLens", + "icon": "$(gitlens-graph)" }, { - "command": "gitlens.plus.restore", - "title": "Restore GitLens+ Features", - "category": "GitLens+" + "command": "gitlens.toggleMaximizedGraph", + "title": "Toggle Maximized Commit Graph", + "category": "GitLens", + "icon": "$(gitlens-graph)" }, { - "command": "gitlens.plus.reset", - "title": "Reset", - "category": "GitLens+" + "command": "gitlens.showHomeView", + "title": "Show Home View", + "category": "GitLens" }, { - "command": "gitlens.getStarted", - "title": "Get Started", + "command": "gitlens.showAccountView", + "title": "Show Account View", "category": "GitLens" }, { - "command": "gitlens.showGraphPage", - "title": "Show Commit Graph", - "category": "GitLens+", + "command": "gitlens.showInCommitGraph", + "title": "Open in Commit Graph", + "category": "GitLens", "icon": "$(gitlens-graph)" }, { - "command": "gitlens.showInCommitGraph", + "command": "gitlens.showInCommitGraphView", "title": "Open in Commit Graph", - "category": "GitLens+", + "category": "GitLens", "icon": "$(gitlens-graph)" }, { - "command": "gitlens.showFocusPage", - "title": "Show Focus View", - "category": "GitLens+", - "icon": "$(layers)" + "command": "gitlens.showLineHistoryView", + "title": "Show Line History View", + "category": "GitLens" + }, + { + "command": "gitlens.showRemotesView", + "title": "Show Remotes View", + "category": "GitLens" + }, + { + "command": "gitlens.showRepositoriesView", + "title": "Show Repositories View", + "category": "GitLens" + }, + { + "command": "gitlens.showSearchAndCompareView", + "title": "Show Search And Compare Commits View", + "category": "GitLens" }, { "command": "gitlens.showSettingsPage", @@ -4499,155 +5882,112 @@ "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#views", + "command": "gitlens.showSettingsPage!views", "title": "Open Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#autolinks", + "command": "gitlens.showSettingsPage!autolinks", "title": "Configure Autolinks", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#branches-view", + "command": "gitlens.showSettingsPage!file-annotations", + "title": "Open File Annotation Settings", + "category": "GitLens", + "icon": "$(gear)" + }, + { + "command": "gitlens.showSettingsPage!branches-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#commits-view", + "command": "gitlens.showSettingsPage!commits-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#contributors-view", + "command": "gitlens.showSettingsPage!contributors-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#file-history-view", + "command": "gitlens.showSettingsPage!file-history-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#line-history-view", + "command": "gitlens.showSettingsPage!line-history-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#remotes-view", + "command": "gitlens.showSettingsPage!remotes-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#repositories-view", + "command": "gitlens.showSettingsPage!repositories-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#search-compare-view", + "command": "gitlens.showSettingsPage!search-compare-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#stashes-view", + "command": "gitlens.showSettingsPage!stashes-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#tags-view", + "command": "gitlens.showSettingsPage!tags-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#worktrees-view", + "command": "gitlens.showSettingsPage!worktrees-view", "title": "Open View Settings", "category": "GitLens", "icon": "$(gear)" }, { - "command": "gitlens.showSettingsPage#commit-graph", + "command": "gitlens.showSettingsPage!commit-graph", "title": "Open Commit Graph Settings", - "category": "GitLens+", + "category": "GitLens", "icon": "$(gear)" }, { "command": "gitlens.showTimelinePage", - "title": "Open Visual File History of Active File", - "category": "GitLens+", - "icon": "$(gitlens-history)" - }, - { - "command": "gitlens.refreshTimelinePage", - "title": "Refresh", + "title": "Show Visual File History", "category": "GitLens", - "icon": "$(refresh)" - }, - { - "command": "gitlens.showWelcomePage", - "title": "Welcome (Quick Setup)", - "category": "GitLens" - }, - { - "command": "gitlens.showBranchesView", - "title": "Show Branches View", - "category": "GitLens" - }, - { - "command": "gitlens.showCommitDetailsView", - "title": "Show Commit Details View", - "category": "GitLens" - }, - { - "command": "gitlens.showCommitsView", - "title": "Show Commits View", - "category": "GitLens" - }, - { - "command": "gitlens.showContributorsView", - "title": "Show Contributors View", - "category": "GitLens" - }, - { - "command": "gitlens.showFileHistoryView", - "title": "Show File History View", - "category": "GitLens" - }, - { - "command": "gitlens.showHomeView", - "title": "Show Home View", - "category": "GitLens" - }, - { - "command": "gitlens.showLineHistoryView", - "title": "Show Line History View", - "category": "GitLens" + "icon": "$(graph-scatter)" }, { - "command": "gitlens.showRemotesView", - "title": "Show Remotes View", - "category": "GitLens" - }, - { - "command": "gitlens.showRepositoriesView", - "title": "Show Repositories View", - "category": "GitLens" + "command": "gitlens.showInTimeline", + "title": "Open Visual File History", + "category": "GitLens", + "icon": "$(graph-scatter)" }, { - "command": "gitlens.showSearchAndCompareView", - "title": "Show Search And Compare Commits View", - "category": "GitLens" + "command": "gitlens.timeline.split", + "title": "Split Visual File History", + "category": "GitLens", + "icon": "$(split-horizontal)" }, { "command": "gitlens.showStashesView", @@ -4662,12 +6002,22 @@ { "command": "gitlens.showTimelineView", "title": "Show Visual File History View", - "category": "GitLens+" + "category": "GitLens" + }, + { + "command": "gitlens.showWelcomePage", + "title": "Welcome", + "category": "GitLens" }, { "command": "gitlens.showWorktreesView", "title": "Show Worktrees View", - "category": "GitLens+" + "category": "GitLens" + }, + { + "command": "gitlens.showWorkspacesView", + "title": "Show GitKraken Workspaces View", + "category": "GitLens" }, { "command": "gitlens.compareWith", @@ -4702,21 +6052,21 @@ "title": "Open Changes with Next Revision", "category": "GitLens", "icon": "$(gitlens-next-commit)", - "enablement": "gitlens:activeFileStatus =~ /revision/" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.diffWithNextInDiffLeft", "title": "Open Changes with Next Revision", "category": "GitLens", "icon": "$(gitlens-next-commit)", - "enablement": "gitlens:activeFileStatus =~ /revision/" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.diffWithNextInDiffRight", "title": "Open Changes with Next Revision", "category": "GitLens", "icon": "$(gitlens-next-commit)", - "enablement": "gitlens:activeFileStatus =~ /revision/" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.diffWithPrevious", @@ -4741,6 +6091,16 @@ "title": "Open Line Changes with Previous Revision", "category": "GitLens" }, + { + "command": "gitlens.diffFolderWithRevision", + "title": "Open Folder Changes with Revision...", + "category": "GitLens" + }, + { + "command": "gitlens.diffFolderWithRevisionFrom", + "title": "Open Folder Changes with Branch or Tag...", + "category": "GitLens" + }, { "command": "gitlens.diffWithRevision", "title": "Open Changes with Revision...", @@ -4789,140 +6149,210 @@ "command": "gitlens.toggleFileBlame", "title": "Toggle File Blame", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileBlameInDiffLeft", "title": "Toggle File Blame", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileBlameInDiffRight", "title": "Toggle File Blame", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" + }, + { + "command": "gitlens.annotations.nextChange", + "title": "Next Change", + "icon": "$(arrow-down)" + }, + { + "command": "gitlens.annotations.previousChange", + "title": "Previous Change", + "icon": "$(arrow-up)" }, { "command": "gitlens.clearFileAnnotations", "title": "Clear File Annotations", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git-orange.svg", - "light": "images/light/icon-git-orange.svg" - } + "icon": "$(gitlens-gitlens-filled)" }, { "command": "gitlens.computingFileAnnotations", "title": "Computing File Annotations...", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git-progress.svg", - "light": "images/light/icon-git-progress.svg" - } + "icon": "$(gitlens-gitlens-filled)" }, { "command": "gitlens.toggleFileHeatmap", "title": "Toggle File Heatmap", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileHeatmapInDiffLeft", "title": "Toggle File Heatmap", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileHeatmapInDiffRight", "title": "Toggle File Heatmap", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileChanges", "title": "Toggle File Changes", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" }, { "command": "gitlens.toggleFileChangesOnly", "title": "Toggle File Changes", "category": "GitLens", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "icon": "$(gitlens-gitlens)" + }, + { + "command": "gitlens.toggleLineBlame", + "title": "Toggle Line Blame", + "category": "GitLens" + }, + { + "command": "gitlens.toggleCodeLens", + "title": "Toggle Git CodeLens", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands", + "title": "Git Command Palette", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.branch", + "title": "Git Branch...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.branch.create", + "title": "Git Create Branch...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.branch.delete", + "title": "Git Delete Branch...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.branch.prune", + "title": "Git Prune Branches...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.branch.rename", + "title": "Git Rename Branch...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.checkout", + "title": "Git Checkout...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.cherryPick", + "title": "Git Cherry Pick...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.history", + "title": "Git History (log)...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.merge", + "title": "Git Merge...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.rebase", + "title": "Git Rebase...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.remote", + "title": "Git Remote...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.remote.add", + "title": "Git Add Remote...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.remote.prune", + "title": "Git Prune Remote...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.remote.remove", + "title": "Git Remove Remote...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.reset", + "title": "Git Reset...", + "category": "GitLens" }, { - "command": "gitlens.toggleLineBlame", - "title": "Toggle Line Blame", + "command": "gitlens.gitCommands.revert", + "title": "Git Revert...", "category": "GitLens" }, { - "command": "gitlens.toggleCodeLens", - "title": "Toggle Git CodeLens", + "command": "gitlens.gitCommands.show", + "title": "Git Show...", "category": "GitLens" }, { - "command": "gitlens.gitCommands", - "title": "Git Command Palette", + "command": "gitlens.gitCommands.stash", + "title": "Git Stash...", "category": "GitLens" }, { - "command": "gitlens.gitCommands.branch", - "title": "Git Branch...", + "command": "gitlens.gitCommands.stash.drop", + "title": "Git Drop Stash...", "category": "GitLens" }, { - "command": "gitlens.gitCommands.cherryPick", - "title": "Git Cherry Pick...", + "command": "gitlens.gitCommands.stash.list", + "title": "Git Stash List...", "category": "GitLens" }, { - "command": "gitlens.gitCommands.merge", - "title": "Git Merge...", + "command": "gitlens.gitCommands.stash.pop", + "title": "Git Pop Stash...", "category": "GitLens" }, { - "command": "gitlens.gitCommands.rebase", - "title": "Git Rebase...", + "command": "gitlens.gitCommands.stash.push", + "title": "Git Push Stash...", "category": "GitLens" }, { - "command": "gitlens.gitCommands.reset", - "title": "Git Reset...", + "command": "gitlens.gitCommands.stash.rename", + "title": "Git Rename Stash...", "category": "GitLens" }, { - "command": "gitlens.gitCommands.revert", - "title": "Git Revert...", + "command": "gitlens.gitCommands.status", + "title": "Git Status...", "category": "GitLens" }, { "command": "gitlens.gitCommands.switch", - "title": "Git Switch...", + "title": "Git Switch to...", "category": "GitLens" }, { @@ -4930,10 +6360,40 @@ "title": "Git Tag...", "category": "GitLens" }, + { + "command": "gitlens.gitCommands.tag.create", + "title": "Git Create Tag...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.tag.delete", + "title": "Git Delete Tag...", + "category": "GitLens" + }, { "command": "gitlens.gitCommands.worktree", "title": "Git Worktree...", - "category": "GitLens+" + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.worktree.create", + "title": "Git Create Worktree...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.worktree.delete", + "title": "Git Delete Worktree...", + "category": "GitLens" + }, + { + "command": "gitlens.gitCommands.worktree.open", + "title": "Git Open Worktree...", + "category": "GitLens" + }, + { + "command": "gitlens.switchAIModel", + "title": "Switch AI Model", + "category": "GitLens" }, { "command": "gitlens.switchMode", @@ -4951,8 +6411,8 @@ "category": "GitLens" }, { - "command": "gitlens.setViewsLayout", - "title": "Set Views Layout", + "command": "gitlens.resetViewsLayout", + "title": "Reset Views Layout", "category": "GitLens" }, { @@ -4973,13 +6433,19 @@ }, { "command": "gitlens.showCommitInView", - "title": "Open Commit Details", + "title": "Inspect Commit Details", + "category": "GitLens", + "icon": "$(eye)" + }, + { + "command": "gitlens.showLineCommitInView", + "title": "Inspect Line Commit Details", "category": "GitLens", "icon": "$(eye)" }, { "command": "gitlens.showInDetailsView", - "title": "Open Details", + "title": "Inspect Details", "category": "GitLens", "icon": "$(eye)" }, @@ -5005,12 +6471,12 @@ }, { "command": "gitlens.showQuickCommitDetails", - "title": "Show Commit", + "title": "Quick Show Commit", "category": "GitLens" }, { "command": "gitlens.showQuickCommitFileDetails", - "title": "Show Line Commit", + "title": "Quick Show Line Commit", "category": "GitLens" }, { @@ -5018,21 +6484,21 @@ "title": "Show Revision Commit", "category": "GitLens", "icon": "$(gitlens-commit-horizontal)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.showQuickRevisionDetailsInDiffLeft", "title": "Show Revision Commit", "category": "GitLens", "icon": "$(gitlens-commit-horizontal)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.showQuickRevisionDetailsInDiffRight", "title": "Show Revision Commit", "category": "GitLens", "icon": "$(gitlens-commit-horizontal)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.showQuickFileHistory", @@ -5066,19 +6532,19 @@ }, { "command": "gitlens.addAuthors", - "title": "Add Co-authors", + "title": "Add Co-authors...", "category": "GitLens", "icon": "$(person-add)" }, { "command": "gitlens.connectRemoteProvider", - "title": "Connect to Remote", + "title": "Connect Remote Integration", "category": "GitLens", "icon": "$(plug)" }, { "command": "gitlens.disconnectRemoteProvider", - "title": "Disconnect from Remote", + "title": "Disconnect Remote Integration", "category": "GitLens", "icon": "$(gitlens-unplug)" }, @@ -5100,6 +6566,12 @@ "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.copyRelativePathToClipboard", + "title": "Copy Relative Path", + "category": "GitLens", + "icon": "$(copy)" + }, { "command": "gitlens.closeUnchangedFiles", "title": "Close Unchanged Files", @@ -5110,6 +6582,11 @@ "title": "Open Changed Files", "category": "GitLens" }, + { + "command": "gitlens.openOnlyChangedFiles", + "title": "Open Changed & Close Unchanged Files", + "category": "GitLens" + }, { "command": "gitlens.openBranchesOnRemote", "title": "Open Branches on Remote", @@ -5128,12 +6605,42 @@ "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.copyDeepLinkToComparison", + "title": "Copy Link to Comparison", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.copyDeepLinkToFile", + "title": "Copy Link to File", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.copyDeepLinkToFileAtRevision", + "title": "Copy Link to File at Revision...", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.copyDeepLinkToLines", + "title": "Copy Link to Code", + "category": "GitLens", + "icon": "$(copy)" + }, { "command": "gitlens.copyDeepLinkToRepo", "title": "Copy Link to Repository", "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.copyDeepLinkToWorkspace", + "title": "Copy Link to Workspace", + "category": "GitLens", + "icon": "$(copy)" + }, { "command": "gitlens.copyDeepLinkToTag", "title": "Copy Link to Tag", @@ -5152,6 +6659,18 @@ "category": "GitLens", "icon": "$(globe)" }, + { + "command": "gitlens.views.openBranchOnRemote", + "title": "Open Branch on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, + { + "command": "gitlens.views.openBranchOnRemote.multi", + "title": "Open Branches on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, { "command": "gitlens.openCurrentBranchOnRemote", "title": "Open Current Branch on Remote", @@ -5170,12 +6689,36 @@ "category": "GitLens", "icon": "$(globe)" }, + { + "command": "gitlens.views.openCommitOnRemote", + "title": "Open Commit on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, + { + "command": "gitlens.views.openCommitOnRemote.multi", + "title": "Open Commits on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, { "command": "gitlens.copyRemoteCommitUrl", "title": "Copy Remote Commit URL", "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.views.copyRemoteCommitUrl", + "title": "Copy Remote Commit URL", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.views.copyRemoteCommitUrl.multi", + "title": "Copy Remote Commit URLs", + "category": "GitLens", + "icon": "$(copy)" + }, { "command": "gitlens.openComparisonOnRemote", "title": "Open Comparison on Remote", @@ -5241,30 +6784,6 @@ "icon": "$(gitlens-open-revision)", "category": "GitLens" }, - { - "command": "gitlens.openAutolinkUrl", - "title": "Open Autolink URL", - "category": "GitLens", - "icon": "$(globe)" - }, - { - "command": "gitlens.copyAutolinkUrl", - "title": "Copy Autolink URL", - "category": "GitLens", - "icon": "$(copy)" - }, - { - "command": "gitlens.openIssueOnRemote", - "title": "Open Issue on Remote", - "category": "GitLens", - "icon": "$(globe)" - }, - { - "command": "gitlens.copyRemoteIssueUrl", - "title": "Copy Issue URL", - "category": "GitLens", - "icon": "$(copy)" - }, { "command": "gitlens.openPullRequestOnRemote", "title": "Open Pull Request on Remote", @@ -5277,6 +6796,12 @@ "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.createPullRequestOnRemote", + "title": "Create Pull Request on Remote", + "category": "GitLens", + "icon": "$(git-pull-request-create)" + }, { "command": "gitlens.openAssociatedPullRequestOnRemote", "title": "Open Associated Pull Request", @@ -5300,21 +6825,21 @@ "title": "Open File at Revision", "category": "GitLens", "icon": "$(gitlens-open-revision)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.openRevisionFileInDiffLeft", "title": "Open File at Revision", "category": "GitLens", "icon": "$(gitlens-open-revision)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.openRevisionFileInDiffRight", "title": "Open File at Revision", "category": "GitLens", "icon": "$(gitlens-open-revision)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ " }, { "command": "gitlens.openWorkingFile", @@ -5336,28 +6861,49 @@ }, { "command": "gitlens.stashApply", - "title": "Apply Stash", + "title": "Apply a Stash...", + "category": "GitLens", + "icon": "$(gitlens-stash-pop)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.views.stash.apply", + "title": "Apply Stash...", "category": "GitLens", "icon": "$(gitlens-stash-pop)", "enablement": "!operationInProgress" }, { - "command": "gitlens.views.deleteStash", - "title": "Delete Stash...", + "command": "gitlens.views.stash.delete", + "title": "Drop Stash...", + "category": "GitLens", + "icon": "$(trash)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.views.stash.delete.multi", + "title": "Drop Stashes...", "category": "GitLens", "icon": "$(trash)", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.stash.rename", + "title": "Rename Stash...", + "category": "GitLens", + "icon": "$(edit)", + "enablement": "!operationInProgress" + }, { "command": "gitlens.stashSave", - "title": "Stash All Changes", + "title": "Stash All Changes...", "category": "GitLens", "icon": "$(gitlens-stash-save)", "enablement": "!operationInProgress" }, { "command": "gitlens.stashSaveFiles", - "title": "Stash Changes", + "title": "Stash Changes...", "category": "GitLens", "icon": "$(gitlens-stash-save)", "enablement": "!operationInProgress" @@ -5372,21 +6918,6 @@ "title": "Open All Changes (difftool)", "category": "GitLens" }, - { - "command": "gitlens.resetAvatarCache", - "title": "Reset Avatar Cache", - "category": "GitLens" - }, - { - "command": "gitlens.resetSuppressedWarnings", - "title": "Reset Suppressed Warnings", - "category": "GitLens" - }, - { - "command": "gitlens.resetTrackedUsage", - "title": "Reset Tracked Usage", - "category": "GitLens" - }, { "command": "gitlens.inviteToLiveShare", "title": "Invite to Live Share", @@ -5398,28 +6929,28 @@ "title": "Browse Repository from Revision", "category": "GitLens", "icon": "$(folder-opened)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.browseRepoAtRevisionInNewWindow", "title": "Browse Repository from Revision in New Window", "category": "GitLens", "icon": "$(folder-opened)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.browseRepoBeforeRevision", "title": "Browse Repository from Before Revision", "category": "GitLens", "icon": "$(folder-opened)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.browseRepoBeforeRevisionInNewWindow", "title": "Browse Repository from Before Revision in New Window", "category": "GitLens", "icon": "$(folder-opened)", - "enablement": "gitlens:activeFileStatus =~ /revision/ && resourceScheme != git" + "enablement": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/" }, { "command": "gitlens.views.browseRepoAtRevision", @@ -5449,21 +6980,21 @@ "command": "gitlens.fetchRepositories", "title": "Fetch", "category": "GitLens", - "icon": "$(sync)", + "icon": "$(gitlens-repo-fetch)", "enablement": "!operationInProgress" }, { "command": "gitlens.pullRepositories", "title": "Pull", "category": "GitLens", - "icon": "$(arrow-down)", + "icon": "$(gitlens-repo-pull)", "enablement": "!operationInProgress" }, { "command": "gitlens.pushRepositories", "title": "Push", "category": "GitLens", - "icon": "$(arrow-up)", + "icon": "$(gitlens-repo-push)", "enablement": "!operationInProgress" }, { @@ -5505,14 +7036,14 @@ }, { "command": "gitlens.views.switchToCommit", - "title": "Switch to Commit...", + "title": "Checkout Commit...", "category": "GitLens", "icon": "$(gitlens-switch)", "enablement": "!operationInProgress" }, { "command": "gitlens.views.switchToTag", - "title": "Switch to Tag...", + "title": "Checkout Tag...", "category": "GitLens", "icon": "$(gitlens-switch)", "enablement": "!operationInProgress" @@ -5523,6 +7054,36 @@ "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.views.copyAsMarkdown", + "title": "Copy as Markdown", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.views.copyUrl", + "title": "Copy URL", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.views.copyUrl.multi", + "title": "Copy URLs", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.views.openUrl", + "title": "Open URL", + "category": "GitLens", + "icon": "$(globe)" + }, + { + "command": "gitlens.views.openUrl.multi", + "title": "Open URLs", + "category": "GitLens", + "icon": "$(globe)" + }, { "command": "gitlens.views.pruneRemote", "title": "Prune", @@ -5533,7 +7094,7 @@ "command": "gitlens.views.fetch", "title": "Fetch", "category": "GitLens", - "icon": "$(sync)", + "icon": "$(gitlens-repo-fetch)", "enablement": "!operationInProgress" }, { @@ -5554,21 +7115,21 @@ "command": "gitlens.views.pull", "title": "Pull", "category": "GitLens", - "icon": "$(arrow-down)", + "icon": "$(gitlens-repo-pull)", "enablement": "!operationInProgress" }, { "command": "gitlens.views.push", "title": "Push", "category": "GitLens", - "icon": "$(arrow-up)", + "icon": "$(gitlens-repo-push)", "enablement": "!operationInProgress" }, { "command": "gitlens.views.pushWithForce", "title": "Push (force)", "category": "GitLens", - "icon": "$(gitlens-arrow-up-force)", + "icon": "$(gitlens-repo-force-push)", "enablement": "!operationInProgress" }, { @@ -5576,6 +7137,11 @@ "title": "Open in Terminal", "category": "GitLens" }, + { + "command": "gitlens.views.openInIntegratedTerminal", + "title": "Open in Integrated Terminal", + "category": "GitLens" + }, { "command": "gitlens.views.setAsDefault", "title": "Set as Default", @@ -5620,20 +7186,32 @@ "category": "GitLens", "icon": "$(star-empty)" }, + { + "command": "gitlens.views.star.multi", + "title": "Add to Favorites", + "category": "GitLens", + "icon": "$(star-empty)" + }, { "command": "gitlens.views.unstar", "title": "Remove from Favorites", "category": "GitLens", "icon": "$(star-full)" }, + { + "command": "gitlens.views.unstar.multi", + "title": "Remove from Favorites", + "category": "GitLens", + "icon": "$(star-full)" + }, { "command": "gitlens.views.openDirectoryDiff", - "title": "Open Directory Compare", + "title": "Open Directory Comparison", "category": "GitLens" }, { "command": "gitlens.views.openDirectoryDiffWithWorking", - "title": "Open Directory Compare with Working Tree", + "title": "Directory Compare Working Tree to Here", "category": "GitLens" }, { @@ -5673,11 +7251,23 @@ { "command": "gitlens.views.openChangedFileDiffs", "title": "Open All Changes", + "icon": "$(diff-multiple)", "category": "GitLens" }, { "command": "gitlens.views.openChangedFileDiffsWithWorking", "title": "Open All Changes with Working Tree", + "icon": "$(diff-multiple)", + "category": "GitLens" + }, + { + "command": "gitlens.views.openChangedFileDiffsIndividually", + "title": "Open All Changes, Individually", + "category": "GitLens" + }, + { + "command": "gitlens.views.openChangedFileDiffsWithWorkingIndividually", + "title": "Open All Changes with Working Tree, Individually", "category": "GitLens" }, { @@ -5685,6 +7275,11 @@ "title": "Open Files at Revision", "category": "GitLens" }, + { + "command": "gitlens.views.openOnlyChangedFiles", + "title": "Open Changed & Close Unchanged Files", + "category": "GitLens" + }, { "command": "gitlens.views.applyChanges", "title": "Apply Changes", @@ -5698,11 +7293,28 @@ }, { "command": "gitlens.views.compareAncestryWithWorking", - "title": "Compare Ancestry with Working Tree", + "title": "Compare Working Tree to Common Base", + "category": "GitLens" + }, + { + "command": "gitlens.views.compareWithMergeBase", + "title": "Compare with Common Base", + "category": "GitLens" + }, + { + "command": "gitlens.views.openChangedFileDiffsWithMergeBase", + "title": "Open All Changes with Common Base", + "icon": "$(diff-multiple)", "category": "GitLens" }, { "command": "gitlens.views.compareWithHead", + "title": "Compare to/from HEAD", + "category": "GitLens", + "icon": "$(compare-changes)" + }, + { + "command": "gitlens.views.compareBranchWithHead", "title": "Compare with HEAD", "category": "GitLens", "icon": "$(compare-changes)" @@ -5734,13 +7346,13 @@ }, { "command": "gitlens.views.compareWithWorking", - "title": "Compare with Working Tree", + "title": "Compare Working Tree to Here", "category": "GitLens", "icon": "$(gitlens-compare-ref-working)" }, { "command": "gitlens.views.addAuthors", - "title": "Add Co-authors", + "title": "Add Co-authors...", "category": "GitLens", "icon": "$(person-add)" }, @@ -5751,11 +7363,10 @@ "icon": "$(person-add)" }, { - "command": "gitlens.views.title.applyStash", - "title": "Apply a Stash...", + "command": "gitlens.views.addAuthor.multi", + "title": "Add as Co-authors", "category": "GitLens", - "icon": "$(gitlens-stash-pop)", - "enablement": "!operationInProgress" + "icon": "$(person-add)" }, { "command": "gitlens.views.createWorktree", @@ -5766,7 +7377,7 @@ }, { "command": "gitlens.ghpr.views.openOrCreateWorktree", - "title": "Open Worktree for Pull Request via GitLens...", + "title": "Checkout Pull Request in Worktree (GitLens)...", "category": "GitLens", "enablement": "!operationInProgress" }, @@ -5784,6 +7395,19 @@ "icon": "$(trash)", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.deleteWorktree.multi", + "title": "Delete Worktrees...", + "category": "GitLens", + "icon": "$(trash)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.views.openInWorktree", + "title": "Open in Worktree", + "category": "GitLens", + "icon": "$(window)" + }, { "command": "gitlens.views.openWorktree", "title": "Open Worktree", @@ -5796,9 +7420,38 @@ "category": "GitLens", "icon": "$(empty-window)" }, + { + "command": "gitlens.graph.openInWorktree", + "title": "Open in Worktree", + "category": "GitLens", + "icon": "$(window)" + }, + { + "command": "gitlens.views.openWorktreeInNewWindow.multi", + "title": "Open Worktrees in New Window", + "category": "GitLens", + "icon": "$(empty-window)" + }, + { + "command": "gitlens.graph.openWorktree", + "title": "Open Worktree", + "category": "GitLens", + "icon": "$(window)" + }, + { + "command": "gitlens.graph.openWorktreeInNewWindow", + "title": "Open Worktree in New Window", + "category": "GitLens", + "icon": "$(empty-window)" + }, + { + "command": "gitlens.views.revealRepositoryInExplorer", + "title": "Reveal in File Explorer", + "category": "GitLens" + }, { "command": "gitlens.views.revealWorktreeInExplorer", - "title": "Reveal Worktree in File Explorer", + "title": "Reveal in File Explorer", "category": "GitLens" }, { @@ -5807,6 +7460,12 @@ "category": "GitLens", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.cherryPick.multi", + "title": "Cherry Pick Commits...", + "category": "GitLens", + "enablement": "!operationInProgress" + }, { "command": "gitlens.views.createBranch", "title": "Create Branch...", @@ -5828,6 +7487,13 @@ "icon": "$(trash)", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.deleteBranch.multi", + "title": "Delete Branches...", + "category": "GitLens", + "icon": "$(trash)", + "enablement": "!operationInProgress" + }, { "command": "gitlens.views.renameBranch", "title": "Rename Branch...", @@ -5855,6 +7521,13 @@ "icon": "$(trash)", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.deleteTag.multi", + "title": "Delete Tags...", + "category": "GitLens", + "icon": "$(trash)", + "enablement": "!operationInProgress" + }, { "command": "gitlens.views.mergeBranchInto", "title": "Merge Branch into Current Branch...", @@ -5865,7 +7538,7 @@ "command": "gitlens.views.pushToCommit", "title": "Push to Commit...", "category": "GitLens", - "icon": "$(arrow-up)", + "icon": "$(gitlens-repo-push)", "enablement": "!operationInProgress" }, { @@ -5904,6 +7577,12 @@ "category": "GitLens", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.resetToTip", + "title": "Reset Current Branch to Tip...", + "category": "GitLens", + "enablement": "!operationInProgress" + }, { "command": "gitlens.views.revert", "title": "Revert Commit...", @@ -5919,13 +7598,13 @@ }, { "command": "gitlens.views.setBranchComparisonToWorking", - "title": "Toggle Compare with: Branch", + "title": "Compare with Working Tree", "category": "GitLens", "icon": "$(compare-changes)" }, { "command": "gitlens.views.setBranchComparisonToBranch", - "title": "Toggle Compare with: Working Tree", + "title": "Compare with Branch (HEAD)", "category": "GitLens", "icon": "$(compare-changes)" }, @@ -5939,14 +7618,36 @@ "command": "gitlens.views.openPullRequest", "title": "Open Pull Request", "category": "GitLens", - "icon": "$(git-pull-request)" + "icon": "$(git-pull-request)" + }, + { + "command": "gitlens.views.openPullRequestChanges", + "title": "Open Pull Request Changes", + "category": "GitLens", + "icon": "$(diff-multiple)" + }, + { + "command": "gitlens.views.openPullRequestComparison", + "title": "Compare Pull Request", + "category": "GitLens", + "icon": "$(compare-changes)" }, { - "command": "gitlens.views.clearNode", - "title": "Clear", + "command": "gitlens.views.clearComparison", + "title": "Clear Comparison", "category": "GitLens", "icon": "$(close)" }, + { + "command": "gitlens.views.clearReviewed", + "title": "Clear Reviewed Files", + "category": "GitLens" + }, + { + "command": "gitlens.views.collapseNode", + "title": "Collapse", + "category": "GitLens" + }, { "command": "gitlens.views.dismissNode", "title": "Dismiss", @@ -6004,31 +7705,31 @@ }, { "command": "gitlens.views.branches.setLayoutToList", - "title": "Toggle View: Tree", + "title": "View as List", "category": "GitLens", "icon": "$(list-tree)" }, { "command": "gitlens.views.branches.setLayoutToTree", - "title": "Toggle View: List", + "title": "View as Tree", "category": "GitLens", "icon": "$(list-flat)" }, { "command": "gitlens.views.branches.setFilesLayoutToAuto", - "title": "Toggle Files View: Tree", + "title": "View Files as Auto", "category": "GitLens", "icon": "$(list-tree)" }, { "command": "gitlens.views.branches.setFilesLayoutToList", - "title": "Toggle Files View: Auto", + "title": "View Files as List", "category": "GitLens", "icon": "$(gitlens-list-auto)" }, { "command": "gitlens.views.branches.setFilesLayoutToTree", - "title": "Toggle Files View: List", + "title": "View Files as Tree", "category": "GitLens", "icon": "$(list-flat)" }, @@ -6062,6 +7763,24 @@ "title": "Hide Branch Pull Requests", "category": "GitLens" }, + { + "command": "gitlens.views.commitDetails.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.views.patchDetails.close", + "title": "Close Patch", + "category": "GitLens", + "icon": "$(close)" + }, + { + "command": "gitlens.views.patchDetails.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, { "command": "gitlens.views.commits.copy", "title": "Copy", @@ -6075,34 +7794,44 @@ }, { "command": "gitlens.views.commits.setFilesLayoutToAuto", - "title": "Toggle Files View: Tree", + "title": "View Files as Auto", "category": "GitLens", "icon": "$(list-tree)" }, { "command": "gitlens.views.commits.setFilesLayoutToList", - "title": "Toggle Files View: Auto", + "title": "View Files as List", "category": "GitLens", "icon": "$(gitlens-list-auto)" }, { "command": "gitlens.views.commits.setFilesLayoutToTree", - "title": "Toggle Files View: List", + "title": "View Files as Tree", "category": "GitLens", "icon": "$(list-flat)" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOn", - "title": "Toggle Filter: All Commits", + "command": "gitlens.views.commits.setCommitsFilterAuthors", + "title": "Filter Commits by Author...", "category": "GitLens", "icon": "$(filter)" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", - "title": "Toggle Filter: Only My Commits", + "command": "gitlens.views.commits.setCommitsFilterOff", + "title": "Clear Filter", "category": "GitLens", "icon": "$(filter-filled)" }, + { + "command": "gitlens.views.commits.setShowMergeCommitsOff", + "title": "Hide Merge Commits", + "category": "GitLens" + }, + { + "command": "gitlens.views.commits.setShowMergeCommitsOn", + "title": "Show Merge Commits", + "category": "GitLens" + }, { "command": "gitlens.views.commits.setShowAvatarsOn", "title": "Show Avatars", @@ -6146,30 +7875,30 @@ }, { "command": "gitlens.views.contributors.setFilesLayoutToAuto", - "title": "Toggle Files View: Tree", + "title": "View Files as Auto", "category": "GitLens", "icon": "$(list-tree)" }, { "command": "gitlens.views.contributors.setFilesLayoutToList", - "title": "Toggle Files View: Auto", + "title": "View Files as List", "category": "GitLens", "icon": "$(gitlens-list-auto)" }, { "command": "gitlens.views.contributors.setFilesLayoutToTree", - "title": "Toggle Files View: List", + "title": "View Files as Tree", "category": "GitLens", "icon": "$(list-flat)" }, { "command": "gitlens.views.contributors.setShowAllBranchesOn", - "title": "Toggle Filter: Only Current Branch", + "title": "View for All Branches", "category": "GitLens" }, { "command": "gitlens.views.contributors.setShowAllBranchesOff", - "title": "Toggle Filter: All Branches", + "title": "View for Current Branch Only", "category": "GitLens" }, { @@ -6182,6 +7911,16 @@ "title": "Hide Avatars", "category": "GitLens" }, + { + "command": "gitlens.views.contributors.setShowMergeCommitsOff", + "title": "Hide Merge Commits", + "category": "GitLens" + }, + { + "command": "gitlens.views.contributors.setShowMergeCommitsOn", + "title": "Show Merge Commits", + "category": "GitLens" + }, { "command": "gitlens.views.contributors.setShowStatisticsOn", "title": "Show Statistics", @@ -6192,6 +7931,57 @@ "title": "Hide Statistics", "category": "GitLens" }, + { + "command": "gitlens.views.drafts.copy", + "title": "Copy", + "category": "GitLens" + }, + { + "command": "gitlens.views.drafts.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.views.drafts.info", + "title": "Learn about Cloud Patches...", + "category": "GitLens", + "icon": "$(info)" + }, + { + "command": "gitlens.views.drafts.setShowAvatarsOn", + "title": "Show Avatars", + "category": "GitLens" + }, + { + "command": "gitlens.views.drafts.setShowAvatarsOff", + "title": "Hide Avatars", + "category": "GitLens" + }, + { + "command": "gitlens.views.drafts.create", + "title": "Create Cloud Patch...", + "category": "GitLens", + "icon": "$(add)" + }, + { + "command": "gitlens.views.drafts.delete", + "title": "Delete Cloud Patch...", + "category": "GitLens", + "icon": "$(trash)" + }, + { + "command": "gitlens.views.draft.open", + "title": "Open", + "category": "GitLens", + "icon": "$(eye)" + }, + { + "command": "gitlens.views.draft.openOnWeb", + "title": "Open on gitkraken.dev", + "category": "GitLens", + "icon": "$(globe)" + }, { "command": "gitlens.views.fileHistory.changeBase", "title": "Change Base...", @@ -6211,14 +8001,14 @@ }, { "command": "gitlens.views.fileHistory.setCursorFollowingOn", - "title": "Toggle History by: File", + "title": "View Line History", "category": "GitLens", "icon": "$(file)", "enablement": "gitlens:views:fileHistory:editorFollowing" }, { "command": "gitlens.views.fileHistory.setCursorFollowingOff", - "title": "Toggle History by: Selected Line(s)", + "title": "View File History", "category": "GitLens", "icon": "$(list-selection)", "enablement": "gitlens:views:fileHistory:editorFollowing || gitlens:views:fileHistory:cursorFollowing" @@ -6237,24 +8027,32 @@ }, { "command": "gitlens.views.fileHistory.setRenameFollowingOn", - "title": "Toggle Follow Renames: Off", - "category": "GitLens", - "enablement": "!config.gitlens.advanced.fileHistoryShowAllBranches" + "title": "Follow Renames", + "category": "GitLens" }, { "command": "gitlens.views.fileHistory.setRenameFollowingOff", - "title": "Toggle Follow Renames: On", - "category": "GitLens", - "enablement": "!config.gitlens.advanced.fileHistoryShowAllBranches" + "title": "Don't Follow Renames", + "category": "GitLens" }, { "command": "gitlens.views.fileHistory.setShowAllBranchesOn", - "title": "Toggle Filter: Only Current Branch", + "title": "View History for All Branches", "category": "GitLens" }, { "command": "gitlens.views.fileHistory.setShowAllBranchesOff", - "title": "Toggle Filter: All Branches", + "title": "View History for Current Branch Only", + "category": "GitLens" + }, + { + "command": "gitlens.views.fileHistory.setShowMergeCommitsOn", + "title": "Show Merge Commits", + "category": "GitLens" + }, + { + "command": "gitlens.views.fileHistory.setShowMergeCommitsOff", + "title": "Hide Merge Commits", "category": "GitLens" }, { @@ -6267,12 +8065,81 @@ "title": "Hide Avatars", "category": "GitLens" }, + { + "command": "gitlens.views.graph.openInTab", + "title": "Open in Editor", + "category": "GitLens", + "icon": "$(link-external)" + }, + { + "command": "gitlens.views.graph.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.views.graphDetails.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, { "command": "gitlens.views.home.refresh", "title": "Refresh", "category": "GitLens", "icon": "$(refresh)" }, + { + "command": "gitlens.views.account.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.views.launchpad.copy", + "title": "Copy", + "category": "GitLens" + }, + { + "command": "gitlens.views.launchpad.info", + "title": "Learn about Launchpad...", + "category": "GitLens", + "icon": "$(info)" + }, + { + "command": "gitlens.views.launchpad.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.views.launchpad.setFilesLayoutToAuto", + "title": "View Files as Auto", + "category": "GitLens", + "icon": "$(list-tree)" + }, + { + "command": "gitlens.views.launchpad.setFilesLayoutToList", + "title": "View Files as List", + "category": "GitLens", + "icon": "$(gitlens-list-auto)" + }, + { + "command": "gitlens.views.launchpad.setFilesLayoutToTree", + "title": "View Files as Tree", + "category": "GitLens", + "icon": "$(list-flat)" + }, + { + "command": "gitlens.views.launchpad.setShowAvatarsOn", + "title": "Show Avatars", + "category": "GitLens" + }, + { + "command": "gitlens.views.launchpad.setShowAvatarsOff", + "title": "Hide Avatars", + "category": "GitLens" + }, { "command": "gitlens.views.lineHistory.changeBase", "title": "Change Base...", @@ -6312,6 +8179,51 @@ "title": "Hide Avatars", "category": "GitLens" }, + { + "command": "gitlens.views.pullRequest.close", + "title": "Close", + "category": "GitLens", + "icon": "$(close)" + }, + { + "command": "gitlens.views.pullRequest.copy", + "title": "Copy", + "category": "GitLens" + }, + { + "command": "gitlens.views.pullRequest.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToAuto", + "title": "View Files as Auto", + "category": "GitLens", + "icon": "$(list-tree)" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToList", + "title": "View Files as List", + "category": "GitLens", + "icon": "$(gitlens-list-auto)" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToTree", + "title": "View Files as Tree", + "category": "GitLens", + "icon": "$(list-flat)" + }, + { + "command": "gitlens.views.pullRequest.setShowAvatarsOn", + "title": "Show Avatars", + "category": "GitLens" + }, + { + "command": "gitlens.views.pullRequest.setShowAvatarsOff", + "title": "Hide Avatars", + "category": "GitLens" + }, { "command": "gitlens.views.remotes.copy", "title": "Copy", @@ -6325,31 +8237,31 @@ }, { "command": "gitlens.views.remotes.setLayoutToList", - "title": "Toggle View: Tree", + "title": "View as List", "category": "GitLens", "icon": "$(list-tree)" }, { "command": "gitlens.views.remotes.setLayoutToTree", - "title": "Toggle View: List", + "title": "View as Tree", "category": "GitLens", "icon": "$(list-flat)" }, { "command": "gitlens.views.remotes.setFilesLayoutToAuto", - "title": "Toggle Files View: Tree", + "title": "View Files as Auto", "category": "GitLens", "icon": "$(list-tree)" }, { "command": "gitlens.views.remotes.setFilesLayoutToList", - "title": "Toggle Files View: Auto", + "title": "View Files as List", "category": "GitLens", "icon": "$(gitlens-list-auto)" }, { "command": "gitlens.views.remotes.setFilesLayoutToTree", - "title": "Toggle Files View: List", + "title": "View Files as Tree", "category": "GitLens", "icon": "$(list-flat)" }, @@ -6396,31 +8308,31 @@ }, { "command": "gitlens.views.repositories.setBranchesLayoutToList", - "title": "Toggle Branches View: Tree", + "title": "View Branches as List", "category": "GitLens", "icon": "$(list-tree)" }, { "command": "gitlens.views.repositories.setBranchesLayoutToTree", - "title": "Toggle Branches View: List", + "title": "View Branches as Tree", "category": "GitLens", "icon": "$(list-flat)" }, { "command": "gitlens.views.repositories.setFilesLayoutToAuto", - "title": "Toggle Files View: Tree", + "title": "View Files as Auto", "category": "GitLens", "icon": "$(list-tree)" }, { "command": "gitlens.views.repositories.setFilesLayoutToList", - "title": "Toggle Files View: Auto", + "title": "View Files as List", "category": "GitLens", "icon": "$(gitlens-list-auto)" }, { "command": "gitlens.views.repositories.setFilesLayoutToTree", - "title": "Toggle Files View: List", + "title": "View Files as Tree", "category": "GitLens", "icon": "$(list-flat)" }, @@ -6550,18 +8462,6 @@ "title": "Copy", "category": "GitLens" }, - { - "command": "gitlens.views.searchAndCompare.pin", - "title": "Pin", - "category": "GitLens", - "icon": "$(pin)" - }, - { - "command": "gitlens.views.searchAndCompare.unpin", - "title": "Unpin", - "category": "GitLens", - "icon": "$(pinned)" - }, { "command": "gitlens.views.searchAndCompare.refresh", "title": "Refresh", @@ -6582,33 +8482,33 @@ }, { "command": "gitlens.views.searchAndCompare.setFilesLayoutToAuto", - "title": "Toggle Files View: Tree", + "title": "View Files as Auto", "category": "GitLens", "icon": "$(list-tree)" }, { "command": "gitlens.views.searchAndCompare.setFilesLayoutToList", - "title": "Toggle Files View: Auto", + "title": "View Files as List", "category": "GitLens", "icon": "$(gitlens-list-auto)" }, { "command": "gitlens.views.searchAndCompare.setFilesLayoutToTree", - "title": "Toggle Files View: List", + "title": "View Files as Tree", "category": "GitLens", "icon": "$(list-flat)" }, { - "command": "gitlens.views.searchAndCompare.setKeepResultsToOn", - "title": "Keep Results", + "command": "gitlens.views.setResultsCommitsFilterAuthors", + "title": "Filter Commits by Author...", "category": "GitLens", - "icon": "$(unlock)" + "icon": "$(filter)" }, { - "command": "gitlens.views.searchAndCompare.setKeepResultsToOff", - "title": "Keep Results", + "command": "gitlens.views.setResultsCommitsFilterOff", + "title": "Clear Filter", "category": "GitLens", - "icon": "$(lock)" + "icon": "$(filter-filled)" }, { "command": "gitlens.views.searchAndCompare.setShowAvatarsOn", @@ -6656,82 +8556,180 @@ "icon": "$(refresh)" }, { - "command": "gitlens.views.stashes.setFilesLayoutToAuto", - "title": "Toggle Files View: Tree", + "command": "gitlens.views.stashes.setFilesLayoutToAuto", + "title": "View Files as Auto", + "category": "GitLens", + "icon": "$(list-tree)" + }, + { + "command": "gitlens.views.stashes.setFilesLayoutToList", + "title": "View Files as List", + "category": "GitLens", + "icon": "$(gitlens-list-auto)" + }, + { + "command": "gitlens.views.stashes.setFilesLayoutToTree", + "title": "View Files as Tree", + "category": "GitLens", + "icon": "$(list-flat)" + }, + { + "command": "gitlens.views.tags.copy", + "title": "Copy", + "category": "GitLens" + }, + { + "command": "gitlens.views.tags.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.views.tags.setLayoutToList", + "title": "View as List", + "category": "GitLens", + "icon": "$(list-tree)" + }, + { + "command": "gitlens.views.tags.setLayoutToTree", + "title": "View as Tree", + "category": "GitLens", + "icon": "$(list-flat)" + }, + { + "command": "gitlens.views.tags.setFilesLayoutToAuto", + "title": "View Files as Auto", + "category": "GitLens", + "icon": "$(list-tree)" + }, + { + "command": "gitlens.views.tags.setFilesLayoutToList", + "title": "View Files as List", + "category": "GitLens", + "icon": "$(gitlens-list-auto)" + }, + { + "command": "gitlens.views.tags.setFilesLayoutToTree", + "title": "View Files as Tree", + "category": "GitLens", + "icon": "$(list-flat)" + }, + { + "command": "gitlens.views.tags.setShowAvatarsOn", + "title": "Show Avatars", + "category": "GitLens" + }, + { + "command": "gitlens.views.tags.setShowAvatarsOff", + "title": "Hide Avatars", + "category": "GitLens" + }, + { + "command": "gitlens.views.timeline.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.views.workspaces.addRepos", + "title": "Add Repositories...", + "category": "GitLens", + "icon": "$(add)" + }, + { + "command": "gitlens.views.workspaces.addReposFromLinked", + "title": "Add Repositories from Linked Workspace...", + "category": "GitLens" + }, + { + "command": "gitlens.views.workspaces.info", + "title": "Learn about GitKraken Workspaces...", + "category": "GitLens", + "icon": "$(info)" + }, + { + "command": "gitlens.views.workspaces.convert", + "title": "Convert to Cloud Workspace...", + "category": "GitLens", + "icon": "$(cloud-upload)" + }, + { + "command": "gitlens.views.workspaces.create", + "title": "Create Cloud Workspace...", "category": "GitLens", - "icon": "$(list-tree)" + "icon": "$(add)" }, { - "command": "gitlens.views.stashes.setFilesLayoutToList", - "title": "Toggle Files View: Auto", + "command": "gitlens.views.workspaces.delete", + "title": "Delete Workspace...", "category": "GitLens", - "icon": "$(gitlens-list-auto)" + "icon": "$(trash)" }, { - "command": "gitlens.views.stashes.setFilesLayoutToTree", - "title": "Toggle Files View: List", + "command": "gitlens.views.workspaces.locateAllRepos", + "title": "Locate Repositories...", "category": "GitLens", - "icon": "$(list-flat)" + "icon": "$(location)" }, { - "command": "gitlens.views.tags.copy", - "title": "Copy", - "category": "GitLens" + "command": "gitlens.views.workspaces.createLocal", + "title": "Create VS Code Workspace...", + "category": "GitLens", + "icon": "$(empty-window)" }, { - "command": "gitlens.views.tags.refresh", - "title": "Refresh", + "command": "gitlens.views.workspaces.openLocal", + "title": "Open VS Code Workspace in Current Window...", "category": "GitLens", - "icon": "$(refresh)" + "icon": "$(window)" }, { - "command": "gitlens.views.tags.setLayoutToList", - "title": "Toggle View: Tree", + "command": "gitlens.views.workspaces.openLocalNewWindow", + "title": "Open VS Code Workspace in New Window...", "category": "GitLens", - "icon": "$(list-tree)" + "icon": "$(window)" }, { - "command": "gitlens.views.tags.setLayoutToTree", - "title": "Toggle View: List", - "category": "GitLens", - "icon": "$(list-flat)" + "command": "gitlens.views.workspaces.changeAutoAddSetting", + "title": "Change Linked Workspace Auto-Add Behavior...", + "category": "GitLens" }, { - "command": "gitlens.views.tags.setFilesLayoutToAuto", - "title": "Toggle Files View: Tree", + "command": "gitlens.views.workspaces.repo.locate", + "title": "Locate Repository...", "category": "GitLens", - "icon": "$(list-tree)" + "icon": "$(location)" }, { - "command": "gitlens.views.tags.setFilesLayoutToList", - "title": "Toggle Files View: Auto", + "command": "gitlens.views.workspaces.repo.open", + "title": "Open Repository", "category": "GitLens", - "icon": "$(gitlens-list-auto)" + "icon": "$(window)" }, { - "command": "gitlens.views.tags.setFilesLayoutToTree", - "title": "Toggle Files View: List", + "command": "gitlens.views.workspaces.repo.openInNewWindow", + "title": "Open Repository in New Window", "category": "GitLens", - "icon": "$(list-flat)" + "icon": "$(empty-window)" }, { - "command": "gitlens.views.tags.setShowAvatarsOn", - "title": "Show Avatars", + "command": "gitlens.views.workspaces.repo.addToWindow", + "title": "Add Repository to VS Code Workspace", "category": "GitLens" }, { - "command": "gitlens.views.tags.setShowAvatarsOff", - "title": "Hide Avatars", - "category": "GitLens" + "command": "gitlens.views.workspaces.repo.remove", + "title": "Remove from Workspace...", + "category": "GitLens", + "icon": "$(trash)" }, { - "command": "gitlens.views.timeline.openInTab", - "title": "Open in Editor Area", - "category": "GitLens", - "icon": "$(link-external)" + "command": "gitlens.views.workspaces.copy", + "title": "Copy", + "category": "GitLens" }, { - "command": "gitlens.views.timeline.refresh", + "command": "gitlens.views.workspaces.refresh", "title": "Refresh", "category": "GitLens", "icon": "$(refresh)" @@ -6749,19 +8747,19 @@ }, { "command": "gitlens.views.worktrees.setFilesLayoutToAuto", - "title": "Toggle Files View: Tree", + "title": "View Files as Auto", "category": "GitLens", "icon": "$(list-tree)" }, { "command": "gitlens.views.worktrees.setFilesLayoutToList", - "title": "Toggle Files View: Auto", + "title": "View Files as List", "category": "GitLens", "icon": "$(gitlens-list-auto)" }, { "command": "gitlens.views.worktrees.setFilesLayoutToTree", - "title": "Toggle Files View: List", + "title": "View Files as Tree", "category": "GitLens", "icon": "$(list-flat)" }, @@ -6805,25 +8803,43 @@ "title": "Disable Debug Logging", "category": "GitLens" }, + { + "command": "gitlens.launchpad.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { + "command": "gitlens.graph.switchToEditorLayout", + "title": "Prefer Commit Graph in Editor", + "category": "GitLens", + "enablement": "config.gitlens.graph.layout != editor" + }, + { + "command": "gitlens.graph.switchToPanelLayout", + "title": "Prefer Commit Graph in Panel", + "category": "GitLens", + "enablement": "config.gitlens.graph.layout != panel" + }, { "command": "gitlens.graph.push", "title": "Push", "category": "GitLens", - "icon": "$(arrow-up)", + "icon": "$(gitlens-repo-push)", "enablement": "!operationInProgress" }, { "command": "gitlens.graph.pull", "title": "Pull", "category": "GitLens", - "icon": "$(arrow-down)", + "icon": "$(gitlens-repo-pull)", "enablement": "!operationInProgress" }, { "command": "gitlens.graph.fetch", "title": "Fetch", "category": "GitLens", - "icon": "$(sync)", + "icon": "$(gitlens-repo-fetch)", "enablement": "!operationInProgress" }, { @@ -6863,12 +8879,6 @@ "category": "GitLens", "icon": "$(copy)" }, - { - "command": "gitlens.focus.refresh", - "title": "Refresh", - "category": "GitLens", - "icon": "$(refresh)" - }, { "command": "gitlens.graph.copyRemoteBranchUrl", "title": "Copy Remote Branch URL", @@ -6901,6 +8911,13 @@ "category": "GitLens", "enablement": "!operationInProgress" }, + { + "command": "gitlens.graph.publishBranch", + "title": "Publish Branch", + "category": "GitLens", + "icon": "$(cloud-upload)", + "enablement": "!operationInProgress" + }, { "command": "gitlens.graph.rebaseOntoBranch", "title": "Rebase Current Branch onto Branch...", @@ -6975,9 +8992,15 @@ "category": "GitLens", "icon": "$(copy)" }, + { + "command": "gitlens.graph.copyRemoteCommitUrl.multi", + "title": "Copy Remote Commit URLs", + "category": "GitLens", + "icon": "$(copy)" + }, { "command": "gitlens.graph.showInDetailsView", - "title": "Open Details", + "title": "Inspect Details", "category": "GitLens", "icon": "$(eye)" }, @@ -6993,177 +9016,386 @@ "icon": "$(globe)" }, { - "command": "gitlens.graph.rebaseOntoCommit", - "title": "Rebase Current Branch onto Commit...", + "command": "gitlens.graph.openCommitOnRemote.multi", + "title": "Open Commits on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, + { + "command": "gitlens.graph.rebaseOntoCommit", + "title": "Rebase Current Branch onto Commit...", + "category": "GitLens", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.resetCommit", + "title": "Reset Current Branch to Previous Commit...", + "category": "GitLens", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.resetToCommit", + "title": "Reset Current Branch to Commit...", + "category": "GitLens", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.resetToTip", + "title": "Reset Current Branch to Tip...", + "category": "GitLens", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.revert", + "title": "Revert Commit...", + "category": "GitLens", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.switchToCommit", + "title": "Switch to Commit...", + "category": "GitLens", + "icon": "$(gitlens-switch)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.undoCommit", + "title": "Undo Commit", + "category": "GitLens", + "icon": "$(discard)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.stash.save", + "title": "Stash All Changes...", + "category": "GitLens", + "icon": "$(gitlens-stash-save)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.stash.apply", + "title": "Apply Stash...", + "category": "GitLens", + "icon": "$(gitlens-stash-pop)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.stash.delete", + "title": "Drop Stash...", + "category": "GitLens", + "icon": "$(trash)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.stash.rename", + "title": "Rename Stash...", + "category": "GitLens", + "icon": "$(edit)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.createTag", + "title": "Create Tag...", + "category": "GitLens", + "icon": "$(add)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.deleteTag", + "title": "Delete Tag...", + "category": "GitLens", + "icon": "$(trash)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.switchToTag", + "title": "Switch to Tag...", + "category": "GitLens", + "icon": "$(gitlens-switch)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.createWorktree", + "title": "Create Worktree...", + "category": "GitLens", + "icon": "$(add)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.graph.createPullRequest", + "title": "Create Pull Request...", + "category": "GitLens", + "icon": "$(git-pull-request-create)" + }, + { + "command": "gitlens.graph.openPullRequest", + "title": "Open Pull Request", + "category": "GitLens", + "icon": "$(git-pull-request)" + }, + { + "command": "gitlens.graph.openPullRequestChanges", + "title": "Open Pull Request Changes", + "category": "GitLens", + "icon": "$(diff-multiple)" + }, + { + "command": "gitlens.graph.openPullRequestComparison", + "title": "Compare Pull Request", + "category": "GitLens", + "icon": "$(compare-changes)" + }, + { + "command": "gitlens.graph.openPullRequestOnRemote", + "title": "Open Pull Request on Remote", + "category": "GitLens", + "icon": "$(globe)" + }, + { + "command": "gitlens.graph.compareAncestryWithWorking", + "title": "Compare Working Tree to Common Base", + "category": "GitLens" + }, + { + "command": "gitlens.graph.compareWithMergeBase", + "title": "Compare with Common Base", + "category": "GitLens" + }, + { + "command": "gitlens.graph.openChangedFileDiffsWithMergeBase", + "title": "Open All Changes with Common Base", + "icon": "$(diff-multiple)", + "category": "GitLens" + }, + { + "command": "gitlens.graph.compareWithHead", + "title": "Compare to/from HEAD", + "category": "GitLens", + "icon": "$(compare-changes)" + }, + { + "command": "gitlens.graph.compareBranchWithHead", + "title": "Compare with HEAD", + "category": "GitLens", + "icon": "$(compare-changes)" + }, + { + "command": "gitlens.graph.compareWithUpstream", + "title": "Compare with Upstream", + "category": "GitLens" + }, + { + "command": "gitlens.graph.compareWithWorking", + "title": "Compare Working Tree to Here", + "category": "GitLens", + "icon": "$(gitlens-compare-ref-working)" + }, + { + "command": "gitlens.graph.openChangedFiles", + "title": "Open Files", + "category": "GitLens" + }, + { + "command": "gitlens.graph.openChangedFileDiffs", + "title": "Open All Changes", + "icon": "$(diff-multiple)", + "category": "GitLens" + }, + { + "command": "gitlens.graph.openChangedFileDiffsWithWorking", + "title": "Open All Changes with Working Tree", + "icon": "$(diff-multiple)", + "category": "GitLens" + }, + { + "command": "gitlens.graph.openChangedFileDiffsIndividually", + "title": "Open All Changes Individually", + "category": "GitLens" + }, + { + "command": "gitlens.graph.openChangedFileDiffsWithWorkingIndividually", + "title": "Open All Changes with Working Tree Individually", + "category": "GitLens" + }, + { + "command": "gitlens.graph.openChangedFileRevisions", + "title": "Open Files at Revision", + "category": "GitLens" + }, + { + "command": "gitlens.graph.openOnlyChangedFiles", + "title": "Open Changed & Close Unchanged Files", + "category": "GitLens" + }, + { + "command": "gitlens.graph.addAuthor", + "title": "Add as Co-author", + "category": "GitLens", + "icon": "$(person-add)" + }, + { + "command": "gitlens.graph.copy", + "title": "Copy", + "category": "GitLens", + "icon": "$(copy)" + }, + { + "command": "gitlens.graph.columnAuthorOn", + "title": "Show Author Column", + "category": "GitLens" + }, + { + "command": "gitlens.graph.columnAuthorOff", + "title": "Hide Author Column", "category": "GitLens", - "enablement": "!operationInProgress" + "enablement": "webviewItemValue =~ /\\bcolumns:canHide\\b/" }, { - "command": "gitlens.graph.resetCommit", - "title": "Reset Current Branch to Previous Commit...", - "category": "GitLens", - "enablement": "!operationInProgress" + "command": "gitlens.graph.columnDateTimeOn", + "title": "Show Date Column", + "category": "GitLens" }, { - "command": "gitlens.graph.resetToCommit", - "title": "Reset Current Branch to Commit...", + "command": "gitlens.graph.columnDateTimeOff", + "title": "Hide Date Column", "category": "GitLens", - "enablement": "!operationInProgress" + "enablement": "webviewItemValue =~ /\\bcolumns:canHide\\b/" }, { - "command": "gitlens.graph.revert", - "title": "Revert Commit...", - "category": "GitLens", - "enablement": "!operationInProgress" + "command": "gitlens.graph.columnShaOn", + "title": "Show SHA Column", + "category": "GitLens" }, { - "command": "gitlens.graph.switchToCommit", - "title": "Switch to Commit...", + "command": "gitlens.graph.columnShaOff", + "title": "Hide SHA Column", "category": "GitLens", - "icon": "$(gitlens-switch)", - "enablement": "!operationInProgress" + "enablement": "webviewItemValue =~ /\\bcolumns:canHide\\b/" }, { - "command": "gitlens.graph.undoCommit", - "title": "Undo Commit", - "category": "GitLens", - "icon": "$(discard)", - "enablement": "!operationInProgress" + "command": "gitlens.graph.columnChangesOn", + "title": "Show Changes Column", + "category": "GitLens" }, { - "command": "gitlens.graph.saveStash", - "title": "Stash All Changes", + "command": "gitlens.graph.columnChangesOff", + "title": "Hide Changes Column", "category": "GitLens", - "icon": "$(gitlens-stash-save)", - "enablement": "!operationInProgress" + "enablement": "webviewItemValue =~ /\\bcolumns:canHide\\b/" }, { - "command": "gitlens.graph.applyStash", - "title": "Apply Stash", - "category": "GitLens", - "icon": "$(gitlens-stash-pop)", - "enablement": "!operationInProgress" + "command": "gitlens.graph.columnGraphOn", + "title": "Show Graph Column", + "category": "GitLens" }, { - "command": "gitlens.graph.deleteStash", - "title": "Delete Stash...", + "command": "gitlens.graph.columnGraphOff", + "title": "Hide Graph Column", "category": "GitLens", - "icon": "$(trash)", - "enablement": "!operationInProgress" + "enablement": "webviewItemValue =~ /\\bcolumns:canHide\\b/" }, { - "command": "gitlens.graph.createTag", - "title": "Create Tag...", - "category": "GitLens", - "icon": "$(add)", - "enablement": "!operationInProgress" + "command": "gitlens.graph.columnMessageOn", + "title": "Show Commit Message Column", + "category": "GitLens" }, { - "command": "gitlens.graph.deleteTag", - "title": "Delete Tag...", + "command": "gitlens.graph.columnMessageOff", + "title": "Hide Commit Message Column", "category": "GitLens", - "icon": "$(trash)", - "enablement": "!operationInProgress" + "enablement": "webviewItemValue =~ /\\bcolumns:canHide\\b/" }, { - "command": "gitlens.graph.switchToTag", - "title": "Switch to Tag...", - "category": "GitLens", - "icon": "$(gitlens-switch)", - "enablement": "!operationInProgress" + "command": "gitlens.graph.columnRefOn", + "title": "Show Branch / Tag Column", + "category": "GitLens" }, { - "command": "gitlens.graph.createWorktree", - "title": "Create Worktree...", + "command": "gitlens.graph.columnRefOff", + "title": "Hide Branch / Tag Column", "category": "GitLens", - "icon": "$(add)", - "enablement": "!operationInProgress" + "enablement": "webviewItemValue =~ /\\bcolumns:canHide\\b/" }, { - "command": "gitlens.graph.createPullRequest", - "title": "Create Pull Request...", - "category": "GitLens", - "icon": "$(git-pull-request-create)" + "command": "gitlens.graph.columnGraphCompact", + "title": "Use Compact Graph Column", + "category": "GitLens" }, { - "command": "gitlens.graph.openPullRequestOnRemote", - "title": "Open Pull Request on Remote", - "category": "GitLens", - "icon": "$(globe)" + "command": "gitlens.graph.columnGraphDefault", + "title": "Use Expanded Graph Column", + "category": "GitLens" }, { - "command": "gitlens.graph.compareAncestryWithWorking", - "title": "Compare Ancestry with Working Tree", + "command": "gitlens.graph.resetColumnsDefault", + "title": "Reset Columns to Default Layout", "category": "GitLens" }, { - "command": "gitlens.graph.compareWithHead", - "title": "Compare with HEAD", - "category": "GitLens", - "icon": "$(compare-changes)" + "command": "gitlens.graph.resetColumnsCompact", + "title": "Reset Columns to Compact Layout", + "category": "GitLens" }, { - "command": "gitlens.graph.compareWithUpstream", - "title": "Compare with Upstream", + "command": "gitlens.graph.scrollMarkerLocalBranchOn", + "title": "Show Local Branch Markers", "category": "GitLens" }, { - "command": "gitlens.graph.compareWithWorking", - "title": "Compare with Working Tree", - "category": "GitLens", - "icon": "$(gitlens-compare-ref-working)" + "command": "gitlens.graph.scrollMarkerLocalBranchOff", + "title": "Hide Local Branch Markers", + "category": "GitLens" }, { - "command": "gitlens.graph.addAuthor", - "title": "Add as Co-author", - "category": "GitLens", - "icon": "$(person-add)" + "command": "gitlens.graph.scrollMarkerRemoteBranchOn", + "title": "Show Remote Branch Markers", + "category": "GitLens" }, { - "command": "gitlens.graph.copy", - "title": "Copy", - "category": "GitLens", - "icon": "$(copy)" + "command": "gitlens.graph.scrollMarkerRemoteBranchOff", + "title": "Hide Remote Branch Markers", + "category": "GitLens" }, { - "command": "gitlens.graph.columnAuthorOn", - "title": "Show Author", + "command": "gitlens.graph.scrollMarkerStashOn", + "title": "Show Stash Markers", "category": "GitLens" }, { - "command": "gitlens.graph.columnAuthorOff", - "title": "Hide Author", + "command": "gitlens.graph.scrollMarkerStashOff", + "title": "Hide Stash Markers", "category": "GitLens" }, { - "command": "gitlens.graph.columnDateTimeOn", - "title": "Show Date", + "command": "gitlens.graph.scrollMarkerTagOn", + "title": "Show Tag Markers", "category": "GitLens" }, { - "command": "gitlens.graph.columnDateTimeOff", - "title": "Hide Date", + "command": "gitlens.graph.scrollMarkerTagOff", + "title": "Hide Tag Markers", "category": "GitLens" }, { - "command": "gitlens.graph.columnShaOn", - "title": "Show SHA", + "command": "gitlens.graph.scrollMarkerPullRequestOn", + "title": "Show Pull Request Markers", "category": "GitLens" }, { - "command": "gitlens.graph.columnShaOff", - "title": "Hide SHA", + "command": "gitlens.graph.scrollMarkerPullRequestOff", + "title": "Hide Pull Request Markers", "category": "GitLens" }, { - "command": "gitlens.graph.columnChangesOn", - "title": "Show Changes", + "command": "gitlens.graph.shareAsCloudPatch", + "title": "Share as Cloud Patch...", "category": "GitLens" }, { - "command": "gitlens.graph.columnChangesOff", - "title": "Hide Changes", - "category": "GitLens" + "command": "gitlens.timeline.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" } ], "icons": { @@ -7234,2001 +9466,2947 @@ "description": "compare-view icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f10a" + "fontCharacter": "\\f10a" + } + }, + "gitlens-contributors-view": { + "description": "contributors-view icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f10b" + } + }, + "gitlens-history-view": { + "description": "history-view icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f10c" + } + }, + "gitlens-history": { + "description": "history icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f10c" + } + }, + "gitlens-remotes-view": { + "description": "remotes-view icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f10d" + } + }, + "gitlens-repositories-view": { + "description": "repositories-view icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f10e" + } + }, + "gitlens-search-view": { + "description": "search-view icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f10f" + } + }, + "gitlens-stashes-view": { + "description": "stashes-view icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f110" + } + }, + "gitlens-stashes": { + "description": "stashes icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f110" + } + }, + "gitlens-tags-view": { + "description": "tags-view icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f111" + } + }, + "gitlens-worktrees-view": { + "description": "worktrees-view icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f112" + } + }, + "gitlens-gitlens": { + "description": "gitlens icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f113" + } + }, + "gitlens-stash-pop": { + "description": "stash-pop icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f114" + } + }, + "gitlens-stash-save": { + "description": "stash-save icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f115" + } + }, + "gitlens-unplug": { + "description": "unplug icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f116" + } + }, + "gitlens-open-revision": { + "description": "open-revision icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f117" + } + }, + "gitlens-switch": { + "description": "switch icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f118" + } + }, + "gitlens-expand": { + "description": "expand icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f119" + } + }, + "gitlens-list-auto": { + "description": "list-auto icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f11a" + } + }, + "gitlens-repo-force-push": { + "description": "repo-force-push icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f11b" + } + }, + "gitlens-pinned-filled": { + "description": "pinned-filled icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f11c" + } + }, + "gitlens-clock": { + "description": "clock icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f11d" + } + }, + "gitlens-provider-azdo": { + "description": "provider-azdo icon", + "default": { + "fontPath": "dist/glicons.woff2", + "fontCharacter": "\\f11e" } }, - "gitlens-contributors-view": { - "description": "contributors-view icon", + "gitlens-provider-bitbucket": { + "description": "provider-bitbucket icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f10b" + "fontCharacter": "\\f11f" } }, - "gitlens-history-view": { - "description": "history-view icon", + "gitlens-provider-gerrit": { + "description": "provider-gerrit icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f10c" + "fontCharacter": "\\f120" } }, - "gitlens-history": { - "description": "history icon", + "gitlens-provider-gitea": { + "description": "provider-gitea icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f10c" + "fontCharacter": "\\f121" } }, - "gitlens-remotes-view": { - "description": "remotes-view icon", + "gitlens-provider-github": { + "description": "provider-github icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f10d" + "fontCharacter": "\\f122" } }, - "gitlens-repositories-view": { - "description": "repositories-view icon", + "gitlens-provider-gitlab": { + "description": "provider-gitlab icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f10e" + "fontCharacter": "\\f123" } }, - "gitlens-search-view": { - "description": "search-view icon", + "gitlens-gitlens-inspect": { + "description": "gitlens-inspect icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f10f" + "fontCharacter": "\\f124" } }, - "gitlens-stashes-view": { - "description": "stashes-view icon", + "gitlens-workspaces-view": { + "description": "workspaces-view icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f110" + "fontCharacter": "\\f125" } }, - "gitlens-stashes": { - "description": "stashes icon", + "gitlens-confirm-checked": { + "description": "confirm-checked icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f110" + "fontCharacter": "\\f126" } }, - "gitlens-tags-view": { - "description": "tags-view icon", + "gitlens-confirm-unchecked": { + "description": "confirm-unchecked icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f111" + "fontCharacter": "\\f127" } }, - "gitlens-worktrees-view": { - "description": "worktrees-view icon", + "gitlens-cloud-patch": { + "description": "cloud-patch icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f112" + "fontCharacter": "\\f128" } }, - "gitlens-gitlens": { - "description": "gitlens icon", + "gitlens-cloud-patch-share": { + "description": "cloud-patch-share icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f113" + "fontCharacter": "\\f129" } }, - "gitlens-stash-pop": { - "description": "stash-pop icon", + "gitlens-inspect": { + "description": "inspect icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f114" + "fontCharacter": "\\f12a" } }, - "gitlens-stash-save": { - "description": "stash-save icon", + "gitlens-repository-filled": { + "description": "repository-filled icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f115" + "fontCharacter": "\\f12b" } }, - "gitlens-unplug": { - "description": "unplug icon", + "gitlens-gitlens-filled": { + "description": "gitlens-filled icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f116" + "fontCharacter": "\\f12c" } }, - "gitlens-open-revision": { - "description": "open-revision icon", + "gitlens-code-suggestion": { + "description": "code-suggestion icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f117" + "fontCharacter": "\\f12d" } }, - "gitlens-switch": { - "description": "switch icon", + "gitlens-diff-multiple": { + "description": "diff-multiple icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f118" + "fontCharacter": "\\f12e" } }, - "gitlens-expand": { - "description": "expand icon", + "gitlens-diff-single": { + "description": "diff-single icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f119" + "fontCharacter": "\\f12f" } }, - "gitlens-list-auto": { - "description": "list-auto icon", + "gitlens-repo-fetch": { + "description": "repo-fetch icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f11a" + "fontCharacter": "\\f130" } }, - "gitlens-arrow-up-force": { - "description": "arrow-up-force icon", + "gitlens-repo-pull": { + "description": "repo-pull icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f11b" + "fontCharacter": "\\f131" } }, - "gitlens-pinned-filled": { - "description": "pinned-filled icon", + "gitlens-repo-push": { + "description": "repo-push icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f11c" + "fontCharacter": "\\f132" } }, - "gitlens-clock": { - "description": "clock icon", + "gitlens-provider-jira": { + "description": "provider-jira icon", "default": { "fontPath": "dist/glicons.woff2", - "fontCharacter": "\\f11d" + "fontCharacter": "\\f133" } } }, "menus": { "commandPalette": [ { - "command": "gitlens.plus.loginOrSignUp", - "when": "!gitlens:plus" + "command": "gitlens.plus.login", + "when": "!gitlens:plus" + }, + { + "command": "gitlens.plus.logout", + "when": "true" + }, + { + "command": "gitlens.plus.signUp", + "when": "!gitlens:plus" + }, + { + "command": "gitlens.plus.startPreviewTrial", + "when": "!gitlens:plus" + }, + { + "command": "gitlens.plus.reactivateProTrial", + "when": "gitlens:plus:state == 5" + }, + { + "command": "gitlens.plus.manage", + "when": "gitlens:plus" + }, + { + "command": "gitlens.plus.cloudIntegrations.connect", + "when": "false" + }, + { + "command": "gitlens.plus.cloudIntegrations.manage", + "when": "gitlens:plus" + }, + { + "command": "gitlens.plus.hide", + "when": "config.gitlens.plusFeatures.enabled" + }, + { + "command": "gitlens.plus.restore", + "when": "!config.gitlens.plusFeatures.enabled" + }, + { + "command": "gitlens.plus.refreshRepositoryAccess", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.gk.switchOrganization", + "when": "gitlens:gk:hasOrganizations" + }, + { + "command": "gitlens.showPatchDetailsPage", + "when": "gitlens:enabled && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled" + }, + { + "command": "gitlens.applyPatchFromClipboard", + "when": "gitlens:enabled && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.pastePatchFromClipboard", + "when": "gitlens:enabled && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.copyWorkingChangesToWorktree", + "when": "gitlens:enabled && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.graph.copyWorkingChangesToWorktree", + "when": "false" + }, + { + "command": "gitlens.createPatch", + "when": "false && gitlens:enabled && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.createCloudPatch", + "when": "gitlens:enabled && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled" + }, + { + "command": "gitlens.shareAsCloudPatch", + "when": "gitlens:enabled && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled" + }, + { + "command": "gitlens.openCloudPatch", + "when": "false" + }, + { + "command": "gitlens.openPatch", + "when": "false && gitlens:enabled" + }, + { + "command": "gitlens.timeline.refresh", + "when": "false" + }, + { + "command": "gitlens.showBranchesView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showCommitDetailsView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showCommitsView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showContributorsView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showDraftsView", + "when": "gitlens:enabled && gitlens:gk:organization:drafts:enabled" + }, + { + "command": "gitlens.showFileHistoryView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showLaunchpad", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showLaunchpadView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showFocusPage", + "when": "false && gitlens:enabled" + }, + { + "command": "gitlens.launchpad.split", + "when": "false && gitlens:enabled && config.gitlens.launchpad.allowMultiple" + }, + { + "command": "gitlens.launchpad.indicator.toggle", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showGraph", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showGraphPage", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.graph.split", + "when": "gitlens:enabled && config.gitlens.graph.allowMultiple" + }, + { + "command": "gitlens.showGraphView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.toggleGraph", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.toggleMaximizedGraph", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showHomeView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showAccountView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showInCommitGraph", + "when": "false" + }, + { + "command": "gitlens.showInCommitGraphView", + "when": "false" + }, + { + "command": "gitlens.showLineHistoryView", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.showRemotesView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showRepositoriesView", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.showSearchAndCompareView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showSettingsPage!views", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!file-annotations", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!branches-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!commits-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!contributors-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!file-history-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!line-history-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!remotes-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!repositories-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!search-compare-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!stashes-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!tags-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!worktrees-view", + "when": "false" + }, + { + "command": "gitlens.showSettingsPage!commit-graph", + "when": "false" + }, + { + "command": "gitlens.showStashesView", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.showTagsView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showTimelinePage", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showInTimeline", + "when": "gitlens:enabled && resource in gitlens:tabs:tracked" + }, + { + "command": "gitlens.timeline.split", + "when": "gitlens:enabled && config.gitlens.visualHistory.allowMultiple" + }, + { + "command": "gitlens.showTimelineView", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showWorktreesView", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.showWorkspacesView", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.compareWith", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.compareHeadWith", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.compareWorkingWith", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.diffDirectory", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.diffDirectoryWithHead", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.diffWithNext", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && !isInDiffEditor" + }, + { + "command": "gitlens.diffWithNextInDiffLeft", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffEditor && !isInDiffRightEditor" + }, + { + "command": "gitlens.diffWithNextInDiffRight", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffRightEditor" + }, + { + "command": "gitlens.diffWithPrevious", + "when": "resource in gitlens:tabs:tracked && !isInDiffEditor" + }, + { + "command": "gitlens.diffWithPreviousInDiffLeft", + "when": "resource in gitlens:tabs:tracked && isInDiffEditor && !isInDiffRightEditor" + }, + { + "command": "gitlens.diffWithPreviousInDiffRight", + "when": "resource in gitlens:tabs:tracked && isInDiffRightEditor" + }, + { + "command": "gitlens.diffLineWithPrevious", + "when": "resource in gitlens:tabs:blameable" + }, + { + "command": "gitlens.diffFolderWithRevision", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.diffFolderWithRevisionFrom", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.diffWithRevision", + "when": "resource in gitlens:tabs:tracked" + }, + { + "command": "gitlens.diffWithRevisionFrom", + "when": "resource in gitlens:tabs:tracked" + }, + { + "command": "gitlens.diffWithWorking", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" + }, + { + "command": "gitlens.diffWithWorkingInDiffLeft", + "when": "false" + }, + { + "command": "gitlens.diffWithWorkingInDiffRight", + "when": "false" + }, + { + "command": "gitlens.diffLineWithWorking", + "when": "resource in gitlens:tabs:blameable" + }, + { + "command": "gitlens.disableRebaseEditor", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.enableRebaseEditor", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.externalDiff", + "when": "!gitlens:hasVirtualFolders && resource in gitlens:tabs:tracked" + }, + { + "command": "gitlens.externalDiffAll", + "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.toggleFileBlame", + "when": "resource in gitlens:tabs:blameable || config.gitlens.blame.toggleMode == window" + }, + { + "command": "gitlens.toggleFileBlameInDiffLeft", + "when": "false" + }, + { + "command": "gitlens.toggleFileBlameInDiffRight", + "when": "false" + }, + { + "command": "gitlens.annotations.nextChange", + "when": "false" + }, + { + "command": "gitlens.annotations.previousChange", + "when": "false" + }, + { + "command": "gitlens.clearFileAnnotations", + "when": "resource in gitlens:tabs:blameable && (gitlens:window:annotated || resource in gitlens:tabs:annotated)" + }, + { + "command": "gitlens.computingFileAnnotations", + "when": "false" + }, + { + "command": "gitlens.toggleFileHeatmap", + "when": "resource in gitlens:tabs:blameable || config.gitlens.heatmap.toggleMode == window" + }, + { + "command": "gitlens.toggleFileHeatmapInDiffLeft", + "when": "false" + }, + { + "command": "gitlens.toggleFileHeatmapInDiffRight", + "when": "false" + }, + { + "command": "gitlens.toggleFileChanges", + "when": "(resource in gitlens:tabs:blameable || config.gitlens.changes.toggleMode == window) && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.toggleFileChangesOnly", + "when": "false" + }, + { + "command": "gitlens.toggleLineBlame", + "when": "!gitlens:disabled" + }, + { + "command": "gitlens.toggleCodeLens", + "when": "!gitlens:disabled && !gitlens:disabledToggleCodeLens" + }, + { + "command": "gitlens.gitCommands", + "when": "!gitlens:disabled" + }, + { + "command": "gitlens.gitCommands.branch", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.branch.create", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.branch.delete", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.branch.prune", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.branch.rename", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.checkout", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.cherryPick", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.history", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.merge", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.rebase", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.remote", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.remote.add", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.remote.prune", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.remote.remove", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.reset", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.revert", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.show", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.stash", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.stash.drop", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.stash.list", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.stash.pop", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.stash.push", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.stash.rename", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.status", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.switch", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.tag", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.tag.create", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.tag.delete", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.worktree", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.gitCommands.worktree.create", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.plus.logout", - "when": "true" + "command": "gitlens.gitCommands.worktree.delete", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.plus.startPreviewTrial", - "when": "!gitlens:plus" + "command": "gitlens.gitCommands.worktree.open", + "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.plus.manage", - "when": "gitlens:plus" + "command": "gitlens.switchAIModel", + "when": "gitlens:enabled && gitlens:gk:organization:ai:enabled" }, { - "command": "gitlens.plus.hide", - "when": "config.gitlens.plusFeatures.enabled" + "command": "gitlens.switchMode", + "when": "gitlens:enabled" }, { - "command": "gitlens.plus.restore", - "when": "!config.gitlens.plusFeatures.enabled" + "command": "gitlens.toggleReviewMode", + "when": "gitlens:enabled" }, { - "command": "gitlens.plus.reset", - "when": "gitlens:debugging" + "command": "gitlens.toggleZenMode", + "when": "gitlens:enabled" }, { - "command": "gitlens.showGraphPage", + "command": "gitlens.showCommitSearch", "when": "gitlens:enabled" }, { - "command": "gitlens.showInCommitGraph", + "command": "gitlens.showLastQuickPick", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.revealCommitInView", + "when": "resource in gitlens:tabs:blameable" + }, + { + "command": "gitlens.showCommitInView", + "when": "resource in gitlens:tabs:blameable" + }, + { + "command": "gitlens.showInDetailsView", "when": "false" }, { - "command": "gitlens.showFocusPage", + "command": "gitlens.showCommitsInView", + "when": "resource in gitlens:tabs:blameable" + }, + { + "command": "gitlens.showFileHistoryInView", + "when": "false" + }, + { + "command": "gitlens.openFileHistory", + "when": "resource in gitlens:tabs:tracked" + }, + { + "command": "gitlens.openFolderHistory", + "when": "false" + }, + { + "command": "gitlens.showQuickCommitDetails", + "when": "false" + }, + { + "command": "gitlens.showLineCommitInView", + "when": "resource in gitlens:tabs:blameable" + }, + { + "command": "gitlens.showQuickCommitFileDetails", + "when": "resource in gitlens:tabs:blameable" + }, + { + "command": "gitlens.showQuickRevisionDetails", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" + }, + { + "command": "gitlens.showQuickRevisionDetailsInDiffLeft", + "when": "false" + }, + { + "command": "gitlens.showQuickRevisionDetailsInDiffRight", + "when": "false" + }, + { + "command": "gitlens.showQuickFileHistory", + "when": "resource in gitlens:tabs:tracked" + }, + { + "command": "gitlens.quickOpenFileHistory", + "when": "resource in gitlens:tabs:tracked" + }, + { + "command": "gitlens.showQuickBranchHistory", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showQuickRepoHistory", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showQuickRepoStatus", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.showQuickStashList", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.addAuthors", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.connectRemoteProvider", + "when": "config.gitlens.integrations.enabled && gitlens:repos:withHostingIntegrations && !gitlens:repos:withHostingIntegrationsConnected" + }, + { + "command": "gitlens.disconnectRemoteProvider", + "when": "config.gitlens.integrations.enabled && gitlens:repos:withHostingIntegrationsConnected" + }, + { + "command": "gitlens.copyCurrentBranch", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.copyMessageToClipboard", + "when": "resource in gitlens:tabs:blameable" + }, + { + "command": "gitlens.copyShaToClipboard", + "when": "resource in gitlens:tabs:blameable" + }, + { + "command": "gitlens.copyRelativePathToClipboard", "when": "gitlens:enabled" }, { - "command": "gitlens.showSettingsPage#views", + "command": "gitlens.closeUnchangedFiles", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.openChangedFiles", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.openOnlyChangedFiles", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.openBranchesOnRemote", + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.copyRemoteBranchesUrl", + "when": "false" + }, + { + "command": "gitlens.openBranchOnRemote", + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.views.openBranchOnRemote", "when": "false" }, { - "command": "gitlens.showSettingsPage#branches-view", + "command": "gitlens.views.openBranchOnRemote.multi", "when": "false" }, { - "command": "gitlens.showSettingsPage#commits-view", + "command": "gitlens.openCurrentBranchOnRemote", + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.copyDeepLinkToBranch", "when": "false" }, { - "command": "gitlens.showSettingsPage#contributors-view", + "command": "gitlens.copyDeepLinkToCommit", "when": "false" }, { - "command": "gitlens.showSettingsPage#file-history-view", + "command": "gitlens.copyDeepLinkToComparison", "when": "false" }, { - "command": "gitlens.showSettingsPage#line-history-view", + "command": "gitlens.copyDeepLinkToFile", "when": "false" }, { - "command": "gitlens.showSettingsPage#remotes-view", + "command": "gitlens.copyDeepLinkToFileAtRevision", "when": "false" }, { - "command": "gitlens.showSettingsPage#repositories-view", + "command": "gitlens.copyDeepLinkToLines", "when": "false" }, { - "command": "gitlens.showSettingsPage#search-compare-view", + "command": "gitlens.copyDeepLinkToRepo", + "when": "gitlens:enabled" + }, + { + "command": "gitlens.copyDeepLinkToTag", "when": "false" }, { - "command": "gitlens.showSettingsPage#stashes-view", + "command": "gitlens.copyDeepLinkToWorkspace", "when": "false" }, { - "command": "gitlens.showSettingsPage#tags-view", + "command": "gitlens.copyRemoteBranchUrl", "when": "false" }, { - "command": "gitlens.showSettingsPage#worktrees-view", + "command": "gitlens.openCommitOnRemote", + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.views.openCommitOnRemote", "when": "false" }, { - "command": "gitlens.showSettingsPage#commit-graph", + "command": "gitlens.views.openCommitOnRemote.multi", "when": "false" }, { - "command": "gitlens.showTimelinePage", - "when": "gitlens:enabled && gitlens:activeFileStatus =~ /tracked/" + "command": "gitlens.copyRemoteCommitUrl", + "when": "gitlens:repos:withRemotes" }, { - "command": "gitlens.refreshTimelinePage", + "command": "gitlens.views.copyRemoteCommitUrl", "when": "false" }, { - "command": "gitlens.showBranchesView", - "when": "gitlens:enabled" + "command": "gitlens.views.copyRemoteCommitUrl.multi", + "when": "false" }, { - "command": "gitlens.showCommitDetailsView", - "when": "gitlens:enabled" + "command": "gitlens.openComparisonOnRemote", + "when": "false" }, { - "command": "gitlens.showCommitsView", - "when": "gitlens:enabled" + "command": "gitlens.copyRemoteComparisonUrl", + "when": "false" }, { - "command": "gitlens.showContributorsView", - "when": "gitlens:enabled" + "command": "gitlens.openPullRequestOnRemote", + "when": "false" }, { - "command": "gitlens.showFileHistoryView", - "when": "gitlens:enabled" + "command": "gitlens.copyRemotePullRequestUrl", + "when": "false" }, { - "command": "gitlens.showHomeView", + "command": "gitlens.createPullRequestOnRemote", + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.openAssociatedPullRequestOnRemote", + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.openFileFromRemote", "when": "gitlens:enabled" }, { - "command": "gitlens.showLineHistoryView", - "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + "command": "gitlens.openFileOnRemote", + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.copyRemoteFileUrlToClipboard", + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.copyRemoteFileUrlWithoutRange", + "when": "false" + }, + { + "command": "gitlens.openFileOnRemoteFrom", + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.copyRemoteFileUrlFrom", + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.openBlamePriorToChange", + "when": "resource in gitlens:tabs:tracked" + }, + { + "command": "gitlens.openFileRevision", + "when": "resource in gitlens:tabs:tracked" + }, + { + "command": "gitlens.openFileRevisionFrom", + "when": "resource in gitlens:tabs:tracked" + }, + { + "command": "gitlens.openRepoOnRemote", + "when": "gitlens:repos:withRemotes" + }, + { + "command": "gitlens.copyRemoteRepositoryUrl", + "when": "false" + }, + { + "command": "gitlens.openRevisionFile", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffEditor" + }, + { + "command": "gitlens.openRevisionFileInDiffLeft", + "when": "false" + }, + { + "command": "gitlens.openRevisionFileInDiffRight", + "when": "false" + }, + { + "command": "gitlens.openWorkingFile", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" + }, + { + "command": "gitlens.openWorkingFileInDiffLeft", + "when": "false" + }, + { + "command": "gitlens.openWorkingFileInDiffRight", + "when": "false" + }, + { + "command": "gitlens.stashApply", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.views.stash.apply", + "when": "false" + }, + { + "command": "gitlens.views.stash.delete", + "when": "false" + }, + { + "command": "gitlens.views.stash.delete.multi", + "when": "false" + }, + { + "command": "gitlens.views.stash.rename", + "when": "false" + }, + { + "command": "gitlens.stashSave", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + }, + { + "command": "gitlens.stashSaveFiles", + "when": "false" + }, + { + "command": "gitlens.inviteToLiveShare", + "when": "false" }, { - "command": "gitlens.showRemotesView", - "when": "gitlens:enabled" + "command": "gitlens.browseRepoAtRevision", + "when": "!gitlens:hasVirtualFolders && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { - "command": "gitlens.showRepositoriesView", - "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + "command": "gitlens.browseRepoAtRevisionInNewWindow", + "when": "!gitlens:hasVirtualFolders && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { - "command": "gitlens.showSearchAndCompareView", - "when": "gitlens:enabled" + "command": "gitlens.browseRepoBeforeRevision", + "when": "!gitlens:hasVirtualFolders && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { - "command": "gitlens.showStashesView", - "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + "command": "gitlens.browseRepoBeforeRevisionInNewWindow", + "when": "!gitlens:hasVirtualFolders && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { - "command": "gitlens.showTagsView", - "when": "gitlens:enabled" + "command": "gitlens.views.browseRepoAtRevision", + "when": "false" }, { - "command": "gitlens.showTimelineView", - "when": "gitlens:enabled" + "command": "gitlens.views.browseRepoAtRevisionInNewWindow", + "when": "false" }, { - "command": "gitlens.showWorktreesView", - "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + "command": "gitlens.views.browseRepoBeforeRevision", + "when": "false" }, { - "command": "gitlens.compareWith", - "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + "command": "gitlens.views.browseRepoBeforeRevisionInNewWindow", + "when": "false" }, { - "command": "gitlens.compareHeadWith", - "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + "command": "gitlens.fetchRepositories", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.compareWorkingWith", - "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + "command": "gitlens.pullRepositories", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.diffDirectory", - "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + "command": "gitlens.pushRepositories", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, { - "command": "gitlens.diffDirectoryWithHead", - "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + "command": "gitlens.views.addRemote", + "when": "false" }, { - "command": "gitlens.diffWithRevisionFrom", - "when": "gitlens:activeFileStatus =~ /tracked/" + "command": "gitlens.views.highlightChanges", + "when": "false" }, { - "command": "gitlens.diffWithNext", - "when": "gitlens:activeFileStatus =~ /revision/ && !isInDiffEditor" + "command": "gitlens.views.highlightRevisionChanges", + "when": "false" }, { - "command": "gitlens.diffWithNextInDiffLeft", - "when": "gitlens:activeFileStatus =~ /revision/ && isInDiffEditor && !isInDiffRightEditor" + "command": "gitlens.views.restore", + "when": "false" }, { - "command": "gitlens.diffWithNextInDiffRight", - "when": "gitlens:activeFileStatus =~ /revision/ && isInDiffRightEditor" + "command": "gitlens.views.switchToAnotherBranch", + "when": "false" }, { - "command": "gitlens.diffWithPrevious", - "when": "gitlens:activeFileStatus =~ /tracked/ && !isInDiffEditor" + "command": "gitlens.views.switchToBranch", + "when": "false" }, { - "command": "gitlens.diffWithPreviousInDiffLeft", - "when": "gitlens:activeFileStatus =~ /tracked/ && isInDiffEditor && !isInDiffRightEditor" + "command": "gitlens.views.switchToCommit", + "when": "false" }, { - "command": "gitlens.diffWithPreviousInDiffRight", - "when": "gitlens:activeFileStatus =~ /tracked/ && isInDiffRightEditor" + "command": "gitlens.views.switchToTag", + "when": "false" }, { - "command": "gitlens.diffLineWithPrevious", - "when": "gitlens:activeFileStatus =~ /blameable/" + "command": "gitlens.views.copy", + "when": "false" }, { - "command": "gitlens.diffWithRevision", - "when": "gitlens:activeFileStatus =~ /tracked/" + "command": "gitlens.views.copyAsMarkdown", + "when": "false" }, { - "command": "gitlens.diffWithWorking", - "when": "gitlens:activeFileStatus =~ /revision/" + "command": "gitlens.views.copyUrl", + "when": "false" }, { - "command": "gitlens.diffWithWorkingInDiffLeft", + "command": "gitlens.views.copyUrl.multi", "when": "false" }, { - "command": "gitlens.diffWithWorkingInDiffRight", + "command": "gitlens.views.openUrl", "when": "false" }, { - "command": "gitlens.diffLineWithWorking", - "when": "gitlens:activeFileStatus =~ /blameable/" + "command": "gitlens.views.openUrl.multi", + "when": "false" }, { - "command": "gitlens.disableRebaseEditor", - "when": "gitlens:enabled" + "command": "gitlens.views.pruneRemote", + "when": "false" }, { - "command": "gitlens.enableRebaseEditor", - "when": "gitlens:enabled" + "command": "gitlens.views.fetch", + "when": "false" }, { - "command": "gitlens.externalDiff", - "when": "!gitlens:hasVirtualFolders && gitlens:activeFileStatus =~ /tracked/" + "command": "gitlens.views.publishBranch", + "when": "false" }, { - "command": "gitlens.externalDiffAll", - "when": "gitlens:enabled && !gitlens:hasVirtualFolders" + "command": "gitlens.views.publishRepository", + "when": "false" }, { - "command": "gitlens.toggleFileBlame", - "when": "gitlens:activeFileStatus =~ /blameable/" + "command": "gitlens.views.pull", + "when": "false" }, { - "command": "gitlens.toggleFileBlameInDiffLeft", + "command": "gitlens.views.push", "when": "false" }, { - "command": "gitlens.toggleFileBlameInDiffRight", + "command": "gitlens.views.pushWithForce", "when": "false" }, { - "command": "gitlens.clearFileAnnotations", - "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computed" + "command": "gitlens.views.openInTerminal", + "when": "false" }, { - "command": "gitlens.computingFileAnnotations", + "command": "gitlens.views.openInIntegratedTerminal", "when": "false" }, { - "command": "gitlens.toggleFileHeatmap", - "when": "gitlens:activeFileStatus =~ /blameable/" + "command": "gitlens.views.setAsDefault", + "when": "false" }, { - "command": "gitlens.toggleFileHeatmapInDiffLeft", + "command": "gitlens.views.unsetAsDefault", "when": "false" }, { - "command": "gitlens.toggleFileHeatmapInDiffRight", + "command": "gitlens.views.stageDirectory", "when": "false" }, { - "command": "gitlens.toggleFileChanges", - "when": "gitlens:activeFileStatus =~ /blameable/ && !gitlens:hasVirtualFolders" + "command": "gitlens.views.stageFile", + "when": "false" }, { - "command": "gitlens.toggleFileChangesOnly", + "command": "gitlens.views.unstageDirectory", "when": "false" }, { - "command": "gitlens.toggleLineBlame", - "when": "!gitlens:disabled" + "command": "gitlens.views.unstageFile", + "when": "false" }, { - "command": "gitlens.toggleCodeLens", - "when": "!gitlens:disabled && !gitlens:disabledToggleCodeLens" + "command": "gitlens.views.star", + "when": "false" }, { - "command": "gitlens.gitCommands", - "when": "!gitlens:disabled" + "command": "gitlens.views.star.multi", + "when": "false" }, { - "command": "gitlens.gitCommands.branch", - "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.unstar", + "when": "false" }, { - "command": "gitlens.gitCommands.cherryPick", - "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.unstar.multi", + "when": "false" }, { - "command": "gitlens.gitCommands.merge", - "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.openChanges", + "when": "false" }, { - "command": "gitlens.gitCommands.rebase", - "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.openDirectoryDiff", + "when": "false" }, { - "command": "gitlens.gitCommands.reset", - "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.openDirectoryDiffWithWorking", + "when": "false" }, { - "command": "gitlens.gitCommands.revert", - "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.openChangesWithWorking", + "when": "false" }, { - "command": "gitlens.gitCommands.switch", - "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.openPreviousChangesWithWorking", + "when": "false" }, { - "command": "gitlens.gitCommands.tag", - "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.openFile", + "when": "false" }, { - "command": "gitlens.gitCommands.worktree", - "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.openFileRevision", + "when": "false" }, { - "command": "gitlens.switchMode", - "when": "gitlens:enabled" + "command": "gitlens.views.openChangedFiles", + "when": "false" }, { - "command": "gitlens.toggleReviewMode", - "when": "gitlens:enabled" + "command": "gitlens.views.openChangedFileDiffs", + "when": "false" }, { - "command": "gitlens.toggleZenMode", - "when": "gitlens:enabled" + "command": "gitlens.views.openChangedFileDiffsWithWorking", + "when": "false" }, { - "command": "gitlens.showCommitSearch", - "when": "gitlens:enabled" + "command": "gitlens.views.openChangedFileDiffsIndividually", + "when": "false" }, { - "command": "gitlens.showLastQuickPick", - "when": "gitlens:enabled" + "command": "gitlens.views.openChangedFileDiffsWithWorkingIndividually", + "when": "false" }, { - "command": "gitlens.revealCommitInView", - "when": "gitlens:activeFileStatus =~ /blameable/" + "command": "gitlens.views.openChangedFileRevisions", + "when": "false" }, { - "command": "gitlens.showCommitInView", - "when": "gitlens:activeFileStatus =~ /blameable/" + "command": "gitlens.views.openOnlyChangedFiles", + "when": "false" }, { - "command": "gitlens.showInDetailsView", + "command": "gitlens.views.applyChanges", "when": "false" }, { - "command": "gitlens.showCommitsInView", - "when": "gitlens:activeFileStatus =~ /blameable/" + "command": "gitlens.views.closeRepository", + "when": "false" }, { - "command": "gitlens.showFileHistoryInView", + "command": "gitlens.views.compareAncestryWithWorking", "when": "false" }, { - "command": "gitlens.openFileHistory", - "when": "gitlens:activeFileStatus =~ /tracked/" + "command": "gitlens.views.compareWithMergeBase", + "when": "false" }, { - "command": "gitlens.openFolderHistory", + "command": "gitlens.views.openChangedFileDiffsWithMergeBase", "when": "false" }, { - "command": "gitlens.showQuickCommitDetails", + "command": "gitlens.views.compareWithHead", "when": "false" }, { - "command": "gitlens.showQuickCommitFileDetails", - "when": "gitlens:activeFileStatus =~ /blameable/" + "command": "gitlens.views.compareBranchWithHead", + "when": "false" }, { - "command": "gitlens.showQuickRevisionDetails", - "when": "gitlens:activeFileStatus =~ /revision/" + "command": "gitlens.views.compareWithUpstream", + "when": "false" }, { - "command": "gitlens.showQuickRevisionDetailsInDiffLeft", + "command": "gitlens.views.compareWithSelected", "when": "false" }, { - "command": "gitlens.showQuickRevisionDetailsInDiffRight", + "command": "gitlens.views.selectForCompare", "when": "false" }, { - "command": "gitlens.showQuickFileHistory", - "when": "gitlens:activeFileStatus =~ /tracked/" + "command": "gitlens.views.compareFileWithSelected", + "when": "false" }, { - "command": "gitlens.quickOpenFileHistory", - "when": "gitlens:activeFileStatus =~ /tracked/" + "command": "gitlens.views.selectFileForCompare", + "when": "false" }, { - "command": "gitlens.showQuickBranchHistory", - "when": "gitlens:enabled" + "command": "gitlens.views.compareWithWorking", + "when": "false" }, { - "command": "gitlens.showQuickRepoHistory", - "when": "gitlens:enabled" + "command": "gitlens.views.addAuthors", + "when": "false" }, { - "command": "gitlens.showQuickRepoStatus", - "when": "gitlens:enabled" + "command": "gitlens.views.addAuthor", + "when": "false" }, { - "command": "gitlens.showQuickStashList", - "when": "gitlens:enabled" + "command": "gitlens.views.addAuthor.multi", + "when": "false" }, { - "command": "gitlens.addAuthors", - "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.createWorktree", + "when": "false" }, { - "command": "gitlens.connectRemoteProvider", - "when": "config.gitlens.integrations.enabled && gitlens:hasRichRemotes && !gitlens:hasConnectedRemotes" + "command": "gitlens.ghpr.views.openOrCreateWorktree", + "when": "false" }, { - "command": "gitlens.disconnectRemoteProvider", - "when": "config.gitlens.integrations.enabled && gitlens:hasRichRemotes && gitlens:hasConnectedRemotes" + "command": "gitlens.views.title.createWorktree", + "when": "false" }, { - "command": "gitlens.copyCurrentBranch", - "when": "gitlens:enabled" + "command": "gitlens.views.deleteWorktree", + "when": "false" }, { - "command": "gitlens.copyMessageToClipboard", - "when": "gitlens:activeFileStatus =~ /blameable/" + "command": "gitlens.views.deleteWorktree.multi", + "when": "false" }, { - "command": "gitlens.copyShaToClipboard", - "when": "gitlens:activeFileStatus =~ /blameable/" + "command": "gitlens.views.openWorktree", + "when": "false" }, { - "command": "gitlens.closeUnchangedFiles", - "when": "gitlens:enabled" + "command": "gitlens.views.openInWorktree", + "when": "false" }, { - "command": "gitlens.openChangedFiles", - "when": "gitlens:enabled" + "command": "gitlens.views.openWorktreeInNewWindow", + "when": "false" }, { - "command": "gitlens.openBranchesOnRemote", - "when": "gitlens:hasRemotes" + "command": "gitlens.views.openWorktreeInNewWindow.multi", + "when": "false" }, { - "command": "gitlens.copyRemoteBranchesUrl", + "command": "gitlens.graph.openInWorktree", "when": "false" }, { - "command": "gitlens.openBranchOnRemote", - "when": "gitlens:hasRemotes" + "command": "gitlens.graph.openWorktree", + "when": "false" }, { - "command": "gitlens.openCurrentBranchOnRemote", - "when": "gitlens:hasRemotes" + "command": "gitlens.graph.openWorktreeInNewWindow", + "when": "false" }, { - "command": "gitlens.copyDeepLinkToBranch", + "command": "gitlens.views.revealRepositoryInExplorer", "when": "false" }, { - "command": "gitlens.copyDeepLinkToCommit", + "command": "gitlens.views.revealWorktreeInExplorer", "when": "false" }, { - "command": "gitlens.copyDeepLinkToRepo", - "when": "gitlens:enabled" + "command": "gitlens.views.createBranch", + "when": "false" }, { - "command": "gitlens.copyDeepLinkToTag", + "command": "gitlens.views.title.createBranch", "when": "false" }, { - "command": "gitlens.copyRemoteBranchUrl", + "command": "gitlens.views.deleteBranch", "when": "false" }, { - "command": "gitlens.openCommitOnRemote", - "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:activeFileStatus =~ /remotes/" + "command": "gitlens.views.deleteBranch.multi", + "when": "false" }, { - "command": "gitlens.copyRemoteCommitUrl", - "when": "gitlens:activeFileStatus =~ /tracked/ && gitlens:activeFileStatus =~ /remotes/" + "command": "gitlens.views.renameBranch", + "when": "false" }, { - "command": "gitlens.openComparisonOnRemote", + "command": "gitlens.views.cherryPick", "when": "false" }, { - "command": "gitlens.copyRemoteComparisonUrl", + "command": "gitlens.views.cherryPick.multi", "when": "false" }, { - "command": "gitlens.openAutolinkUrl", + "command": "gitlens.views.mergeBranchInto", "when": "false" }, { - "command": "gitlens.copyAutolinkUrl", + "command": "gitlens.views.pushToCommit", "when": "false" }, { - "command": "gitlens.openIssueOnRemote", + "command": "gitlens.views.rebaseOntoBranch", "when": "false" }, { - "command": "gitlens.copyRemoteIssueUrl", + "command": "gitlens.views.rebaseOntoCommit", "when": "false" }, { - "command": "gitlens.openPullRequestOnRemote", + "command": "gitlens.views.rebaseOntoUpstream", "when": "false" }, { - "command": "gitlens.copyRemotePullRequestUrl", + "command": "gitlens.views.resetCommit", "when": "false" }, { - "command": "gitlens.openAssociatedPullRequestOnRemote", - "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:activeFileStatus =~ /remotes/" + "command": "gitlens.views.resetToCommit", + "when": "false" }, { - "command": "gitlens.openFileFromRemote", - "when": "gitlens:enabled" + "command": "gitlens.views.resetToTip", + "when": "false" }, { - "command": "gitlens.openFileOnRemote", - "when": "gitlens:activeFileStatus =~ /tracked/ && gitlens:activeFileStatus =~ /remotes/" + "command": "gitlens.views.revert", + "when": "false" }, { - "command": "gitlens.copyRemoteFileUrlToClipboard", - "when": "gitlens:activeFileStatus =~ /tracked/ && gitlens:activeFileStatus =~ /remotes/" + "command": "gitlens.views.undoCommit", + "when": "false" }, { - "command": "gitlens.copyRemoteFileUrlWithoutRange", + "command": "gitlens.views.removeRemote", "when": "false" }, { - "command": "gitlens.openFileOnRemoteFrom", - "when": "gitlens:activeFileStatus =~ /tracked/ && gitlens:activeFileStatus =~ /remotes/" + "command": "gitlens.views.createTag", + "when": "false" }, { - "command": "gitlens.copyRemoteFileUrlFrom", - "when": "gitlens:activeFileStatus =~ /tracked/ && gitlens:activeFileStatus =~ /remotes/" + "command": "gitlens.views.title.createTag", + "when": "false" }, { - "command": "gitlens.openBlamePriorToChange", - "when": "gitlens:activeFileStatus =~ /tracked/" + "command": "gitlens.views.deleteTag", + "when": "false" }, { - "command": "gitlens.openFileRevision", - "when": "gitlens:activeFileStatus =~ /tracked/" + "command": "gitlens.views.deleteTag.multi", + "when": "false" }, { - "command": "gitlens.openFileRevisionFrom", - "when": "gitlens:activeFileStatus =~ /tracked/" + "command": "gitlens.views.setBranchComparisonToWorking", + "when": "false" }, { - "command": "gitlens.openRepoOnRemote", - "when": "gitlens:hasRemotes" + "command": "gitlens.views.setBranchComparisonToBranch", + "when": "false" }, { - "command": "gitlens.copyRemoteRepositoryUrl", + "command": "gitlens.views.createPullRequest", "when": "false" }, { - "command": "gitlens.openRevisionFile", - "when": "gitlens:activeFileStatus =~ /revision/ && isInDiffEditor" + "command": "gitlens.views.openPullRequest", + "when": "false" }, { - "command": "gitlens.openRevisionFileInDiffLeft", + "command": "gitlens.views.openPullRequestChanges", "when": "false" }, { - "command": "gitlens.openRevisionFileInDiffRight", + "command": "gitlens.views.openPullRequestComparison", "when": "false" }, { - "command": "gitlens.openWorkingFile", - "when": "gitlens:activeFileStatus =~ /revision/" + "command": "gitlens.views.clearComparison", + "when": "false" }, { - "command": "gitlens.openWorkingFileInDiffLeft", + "command": "gitlens.views.clearReviewed", "when": "false" }, { - "command": "gitlens.openWorkingFileInDiffRight", + "command": "gitlens.views.collapseNode", "when": "false" }, { - "command": "gitlens.stashApply", - "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.dismissNode", + "when": "false" }, { - "command": "gitlens.views.deleteStash", + "command": "gitlens.views.editNode", "when": "false" }, { - "command": "gitlens.stashSave", - "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.expandNode", + "when": "false" }, { - "command": "gitlens.stashSaveFiles", + "command": "gitlens.views.refreshNode", "when": "false" }, { - "command": "gitlens.resetAvatarCache", - "when": "gitlens:enabled" + "command": "gitlens.views.loadMoreChildren", + "when": "false" }, { - "command": "gitlens.resetSuppressedWarnings", - "when": "gitlens:enabled" + "command": "gitlens.views.loadAllChildren", + "when": "false" }, { - "command": "gitlens.resetTrackedUsage", - "when": "gitlens:enabled" + "command": "gitlens.views.setShowRelativeDateMarkersOn", + "when": "false" }, { - "command": "gitlens.inviteToLiveShare", + "command": "gitlens.views.setShowRelativeDateMarkersOff", "when": "false" }, { - "command": "gitlens.browseRepoAtRevision", - "when": "!gitlens:hasVirtualFolders && gitlens:activeFileStatus =~ /revision/" + "command": "gitlens.views.branches.copy", + "when": "false" }, { - "command": "gitlens.browseRepoAtRevisionInNewWindow", - "when": "!gitlens:hasVirtualFolders && gitlens:activeFileStatus =~ /revision/" + "command": "gitlens.views.branches.refresh", + "when": "false" }, { - "command": "gitlens.browseRepoBeforeRevision", - "when": "!gitlens:hasVirtualFolders && gitlens:activeFileStatus =~ /revision/" + "command": "gitlens.views.branches.setLayoutToList", + "when": "false" }, { - "command": "gitlens.browseRepoBeforeRevisionInNewWindow", - "when": "!gitlens:hasVirtualFolders && gitlens:activeFileStatus =~ /revision/" + "command": "gitlens.views.branches.setLayoutToTree", + "when": "false" }, { - "command": "gitlens.views.browseRepoAtRevision", + "command": "gitlens.views.branches.setFilesLayoutToAuto", "when": "false" }, { - "command": "gitlens.views.browseRepoAtRevisionInNewWindow", + "command": "gitlens.views.branches.setFilesLayoutToList", "when": "false" }, { - "command": "gitlens.views.browseRepoBeforeRevision", + "command": "gitlens.views.branches.setFilesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.browseRepoBeforeRevisionInNewWindow", + "command": "gitlens.views.branches.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.fetchRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.branches.setShowAvatarsOff", + "when": "false" }, { - "command": "gitlens.pullRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.branches.setShowBranchComparisonOn", + "when": "false" }, { - "command": "gitlens.pushRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" + "command": "gitlens.views.branches.setShowBranchComparisonOff", + "when": "false" }, { - "command": "gitlens.views.addRemote", + "command": "gitlens.views.branches.setShowBranchPullRequestOn", "when": "false" }, { - "command": "gitlens.views.highlightChanges", + "command": "gitlens.views.branches.setShowBranchPullRequestOff", "when": "false" }, { - "command": "gitlens.views.highlightRevisionChanges", + "command": "gitlens.views.commitDetails.refresh", "when": "false" }, { - "command": "gitlens.views.restore", + "command": "gitlens.views.patchDetails.close", "when": "false" }, { - "command": "gitlens.views.switchToAnotherBranch", + "command": "gitlens.views.patchDetails.refresh", "when": "false" }, { - "command": "gitlens.views.switchToBranch", + "command": "gitlens.views.commits.copy", "when": "false" }, { - "command": "gitlens.views.switchToCommit", + "command": "gitlens.views.commits.refresh", "when": "false" }, { - "command": "gitlens.views.switchToTag", + "command": "gitlens.views.commits.setFilesLayoutToAuto", "when": "false" }, { - "command": "gitlens.views.copy", + "command": "gitlens.views.commits.setFilesLayoutToList", "when": "false" }, { - "command": "gitlens.views.pruneRemote", + "command": "gitlens.views.commits.setFilesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.fetch", + "command": "gitlens.views.commits.setCommitsFilterAuthors", "when": "false" }, { - "command": "gitlens.views.publishBranch", + "command": "gitlens.views.commits.setCommitsFilterOff", "when": "false" }, { - "command": "gitlens.views.publishRepository", + "command": "gitlens.views.commits.setShowMergeCommitsOff", "when": "false" }, { - "command": "gitlens.views.pull", + "command": "gitlens.views.commits.setShowMergeCommitsOn", "when": "false" }, { - "command": "gitlens.views.push", + "command": "gitlens.views.commits.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.pushWithForce", + "command": "gitlens.views.commits.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.openInTerminal", + "command": "gitlens.views.commits.setShowBranchComparisonOn", "when": "false" }, { - "command": "gitlens.views.setAsDefault", + "command": "gitlens.views.commits.setShowBranchComparisonOff", "when": "false" }, { - "command": "gitlens.views.unsetAsDefault", + "command": "gitlens.views.commits.setShowBranchPullRequestOn", "when": "false" }, { - "command": "gitlens.views.stageDirectory", + "command": "gitlens.views.commits.setShowBranchPullRequestOff", "when": "false" }, { - "command": "gitlens.views.stageFile", + "command": "gitlens.views.contributors.copy", "when": "false" }, { - "command": "gitlens.views.unstageDirectory", + "command": "gitlens.views.contributors.refresh", "when": "false" }, { - "command": "gitlens.views.unstageFile", + "command": "gitlens.views.contributors.setFilesLayoutToAuto", "when": "false" }, { - "command": "gitlens.views.star", + "command": "gitlens.views.contributors.setFilesLayoutToList", "when": "false" }, { - "command": "gitlens.views.unstar", + "command": "gitlens.views.contributors.setFilesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.openChanges", + "command": "gitlens.views.contributors.setShowAllBranchesOn", "when": "false" }, { - "command": "gitlens.views.openDirectoryDiff", + "command": "gitlens.views.contributors.setShowAllBranchesOff", "when": "false" }, { - "command": "gitlens.views.openDirectoryDiffWithWorking", + "command": "gitlens.views.contributors.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.openChangesWithWorking", + "command": "gitlens.views.contributors.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.openPreviousChangesWithWorking", + "command": "gitlens.views.contributors.setShowMergeCommitsOff", "when": "false" }, { - "command": "gitlens.views.openFile", + "command": "gitlens.views.contributors.setShowMergeCommitsOn", "when": "false" }, { - "command": "gitlens.views.openFileRevision", + "command": "gitlens.views.contributors.setShowStatisticsOn", "when": "false" }, { - "command": "gitlens.views.openChangedFiles", + "command": "gitlens.views.contributors.setShowStatisticsOff", "when": "false" }, { - "command": "gitlens.views.openChangedFileDiffs", + "command": "gitlens.views.drafts.copy", "when": "false" }, { - "command": "gitlens.views.openChangedFileDiffsWithWorking", + "command": "gitlens.views.drafts.refresh", "when": "false" }, { - "command": "gitlens.views.openChangedFileRevisions", + "command": "gitlens.views.drafts.info", "when": "false" }, { - "command": "gitlens.views.applyChanges", + "command": "gitlens.views.drafts.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.closeRepository", + "command": "gitlens.views.drafts.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.compareAncestryWithWorking", + "command": "gitlens.views.drafts.create", "when": "false" }, { - "command": "gitlens.views.compareWithHead", + "command": "gitlens.views.drafts.delete", "when": "false" }, { - "command": "gitlens.views.compareWithUpstream", + "command": "gitlens.views.draft.open", "when": "false" }, { - "command": "gitlens.views.compareWithSelected", + "command": "gitlens.views.draft.openOnWeb", "when": "false" }, { - "command": "gitlens.views.selectForCompare", + "command": "gitlens.views.fileHistory.changeBase", "when": "false" }, { - "command": "gitlens.views.compareFileWithSelected", + "command": "gitlens.views.fileHistory.copy", "when": "false" }, { - "command": "gitlens.views.selectFileForCompare", + "command": "gitlens.views.fileHistory.refresh", "when": "false" }, { - "command": "gitlens.views.compareWithWorking", + "command": "gitlens.views.fileHistory.setCursorFollowingOn", "when": "false" }, { - "command": "gitlens.views.addAuthors", + "command": "gitlens.views.fileHistory.setCursorFollowingOff", "when": "false" }, { - "command": "gitlens.views.addAuthor", + "command": "gitlens.views.fileHistory.setEditorFollowingOn", "when": "false" }, { - "command": "gitlens.views.title.applyStash", + "command": "gitlens.views.fileHistory.setEditorFollowingOff", "when": "false" }, { - "command": "gitlens.views.createWorktree", + "command": "gitlens.views.fileHistory.setRenameFollowingOn", "when": "false" }, { - "command": "gitlens.ghpr.views.openOrCreateWorktree", + "command": "gitlens.views.fileHistory.setRenameFollowingOff", "when": "false" }, { - "command": "gitlens.views.title.createWorktree", + "command": "gitlens.views.fileHistory.setShowAllBranchesOn", "when": "false" }, { - "command": "gitlens.views.deleteWorktree", + "command": "gitlens.views.fileHistory.setShowAllBranchesOff", "when": "false" }, { - "command": "gitlens.views.openWorktree", + "command": "gitlens.views.fileHistory.setShowMergeCommitsOn", "when": "false" }, { - "command": "gitlens.views.openWorktreeInNewWindow", + "command": "gitlens.views.fileHistory.setShowMergeCommitsOff", "when": "false" }, { - "command": "gitlens.views.revealWorktreeInExplorer", + "command": "gitlens.views.fileHistory.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.createBranch", + "command": "gitlens.views.fileHistory.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.title.createBranch", + "command": "gitlens.views.graph.openInTab", "when": "false" }, { - "command": "gitlens.views.deleteBranch", + "command": "gitlens.views.graph.refresh", "when": "false" }, { - "command": "gitlens.views.renameBranch", + "command": "gitlens.views.graphDetails.refresh", "when": "false" }, { - "command": "gitlens.views.cherryPick", + "command": "gitlens.views.home.refresh", "when": "false" }, { - "command": "gitlens.views.mergeBranchInto", + "command": "gitlens.views.account.refresh", "when": "false" }, { - "command": "gitlens.views.pushToCommit", + "command": "gitlens.views.launchpad.copy", "when": "false" }, { - "command": "gitlens.views.rebaseOntoBranch", + "command": "gitlens.views.launchpad.info", "when": "false" }, { - "command": "gitlens.views.rebaseOntoCommit", + "command": "gitlens.views.launchpad.refresh", "when": "false" }, { - "command": "gitlens.views.rebaseOntoUpstream", + "command": "gitlens.views.launchpad.setFilesLayoutToAuto", "when": "false" }, { - "command": "gitlens.views.resetCommit", + "command": "gitlens.views.launchpad.setFilesLayoutToList", "when": "false" }, { - "command": "gitlens.views.resetToCommit", + "command": "gitlens.views.launchpad.setFilesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.revert", + "command": "gitlens.views.launchpad.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.undoCommit", + "command": "gitlens.views.launchpad.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.removeRemote", + "command": "gitlens.views.lineHistory.changeBase", "when": "false" }, { - "command": "gitlens.views.createTag", + "command": "gitlens.views.lineHistory.copy", "when": "false" }, { - "command": "gitlens.views.title.createTag", + "command": "gitlens.views.lineHistory.refresh", "when": "false" }, { - "command": "gitlens.views.deleteTag", + "command": "gitlens.views.lineHistory.setEditorFollowingOn", "when": "false" }, { - "command": "gitlens.views.setBranchComparisonToWorking", + "command": "gitlens.views.lineHistory.setEditorFollowingOff", "when": "false" }, { - "command": "gitlens.views.setBranchComparisonToBranch", + "command": "gitlens.views.lineHistory.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.createPullRequest", + "command": "gitlens.views.lineHistory.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.openPullRequest", + "command": "gitlens.views.pullRequest.close", "when": "false" }, { - "command": "gitlens.views.clearNode", + "command": "gitlens.views.pullRequest.copy", "when": "false" }, { - "command": "gitlens.views.dismissNode", + "command": "gitlens.views.pullRequest.refresh", "when": "false" }, { - "command": "gitlens.views.editNode", + "command": "gitlens.views.pullRequest.setFilesLayoutToAuto", "when": "false" }, { - "command": "gitlens.views.expandNode", + "command": "gitlens.views.pullRequest.setFilesLayoutToList", "when": "false" }, { - "command": "gitlens.views.refreshNode", + "command": "gitlens.views.pullRequest.setFilesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.loadMoreChildren", + "command": "gitlens.views.pullRequest.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.loadAllChildren", + "command": "gitlens.views.pullRequest.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.setShowRelativeDateMarkersOn", + "command": "gitlens.views.remotes.copy", "when": "false" }, { - "command": "gitlens.views.setShowRelativeDateMarkersOff", + "command": "gitlens.views.remotes.refresh", "when": "false" }, { - "command": "gitlens.views.branches.copy", + "command": "gitlens.views.remotes.setLayoutToList", "when": "false" }, { - "command": "gitlens.views.branches.refresh", + "command": "gitlens.views.remotes.setLayoutToTree", "when": "false" }, { - "command": "gitlens.views.branches.setLayoutToList", + "command": "gitlens.views.remotes.setFilesLayoutToAuto", "when": "false" }, { - "command": "gitlens.views.branches.setLayoutToTree", + "command": "gitlens.views.remotes.setFilesLayoutToList", "when": "false" }, { - "command": "gitlens.views.branches.setFilesLayoutToAuto", + "command": "gitlens.views.remotes.setFilesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.branches.setFilesLayoutToList", + "command": "gitlens.views.remotes.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.branches.setFilesLayoutToTree", + "command": "gitlens.views.remotes.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.branches.setShowAvatarsOn", + "command": "gitlens.views.remotes.setShowBranchPullRequestOn", "when": "false" }, { - "command": "gitlens.views.branches.setShowAvatarsOff", + "command": "gitlens.views.remotes.setShowBranchPullRequestOff", "when": "false" }, { - "command": "gitlens.views.branches.setShowBranchComparisonOn", + "command": "gitlens.views.repositories.copy", "when": "false" }, { - "command": "gitlens.views.branches.setShowBranchComparisonOff", + "command": "gitlens.views.repositories.refresh", "when": "false" }, { - "command": "gitlens.views.branches.setShowBranchPullRequestOn", + "command": "gitlens.views.repositories.setAutoRefreshToOn", "when": "false" }, { - "command": "gitlens.views.branches.setShowBranchPullRequestOff", + "command": "gitlens.views.repositories.setAutoRefreshToOff", "when": "false" }, { - "command": "gitlens.views.commits.copy", + "command": "gitlens.views.repositories.setBranchesLayoutToList", "when": "false" }, { - "command": "gitlens.views.commits.refresh", + "command": "gitlens.views.repositories.setBranchesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.commits.setFilesLayoutToAuto", + "command": "gitlens.views.repositories.setFilesLayoutToAuto", "when": "false" }, { - "command": "gitlens.views.commits.setFilesLayoutToList", + "command": "gitlens.views.repositories.setFilesLayoutToList", "when": "false" }, { - "command": "gitlens.views.commits.setFilesLayoutToTree", + "command": "gitlens.views.repositories.setFilesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOn", + "command": "gitlens.views.repositories.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", + "command": "gitlens.views.repositories.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.commits.setShowAvatarsOn", + "command": "gitlens.views.repositories.setShowBranchComparisonOn", "when": "false" }, { - "command": "gitlens.views.commits.setShowAvatarsOff", + "command": "gitlens.views.repositories.setShowBranchComparisonOff", "when": "false" }, { - "command": "gitlens.views.commits.setShowBranchComparisonOn", + "command": "gitlens.views.repositories.setBranchesShowBranchComparisonOn", "when": "false" }, { - "command": "gitlens.views.commits.setShowBranchComparisonOff", + "command": "gitlens.views.repositories.setBranchesShowBranchComparisonOff", "when": "false" }, { - "command": "gitlens.views.commits.setShowBranchPullRequestOn", + "command": "gitlens.views.repositories.setShowBranchesOn", "when": "false" }, { - "command": "gitlens.views.commits.setShowBranchPullRequestOff", + "command": "gitlens.views.repositories.setShowBranchesOff", "when": "false" }, { - "command": "gitlens.views.contributors.copy", + "command": "gitlens.views.repositories.setShowCommitsOn", "when": "false" }, { - "command": "gitlens.views.contributors.refresh", + "command": "gitlens.views.repositories.setShowCommitsOff", "when": "false" }, { - "command": "gitlens.views.contributors.setFilesLayoutToAuto", + "command": "gitlens.views.repositories.setShowContributorsOn", "when": "false" }, { - "command": "gitlens.views.contributors.setFilesLayoutToList", + "command": "gitlens.views.repositories.setShowContributorsOff", "when": "false" }, { - "command": "gitlens.views.contributors.setFilesLayoutToTree", + "command": "gitlens.views.repositories.setShowRemotesOn", "when": "false" }, { - "command": "gitlens.views.contributors.setShowAllBranchesOn", + "command": "gitlens.views.repositories.setShowRemotesOff", "when": "false" }, { - "command": "gitlens.views.contributors.setShowAllBranchesOff", + "command": "gitlens.views.repositories.setShowStashesOn", "when": "false" }, { - "command": "gitlens.views.contributors.setShowAvatarsOn", + "command": "gitlens.views.repositories.setShowStashesOff", "when": "false" }, { - "command": "gitlens.views.contributors.setShowAvatarsOff", + "command": "gitlens.views.repositories.setShowTagsOn", "when": "false" }, { - "command": "gitlens.views.contributors.setShowStatisticsOn", + "command": "gitlens.views.repositories.setShowTagsOff", "when": "false" }, { - "command": "gitlens.views.contributors.setShowStatisticsOff", + "command": "gitlens.views.repositories.setShowWorktreesOn", "when": "false" }, { - "command": "gitlens.views.fileHistory.changeBase", + "command": "gitlens.views.repositories.setShowWorktreesOff", "when": "false" }, { - "command": "gitlens.views.fileHistory.copy", + "command": "gitlens.views.repositories.setShowUpstreamStatusOn", "when": "false" }, { - "command": "gitlens.views.fileHistory.refresh", + "command": "gitlens.views.repositories.setShowUpstreamStatusOff", "when": "false" }, { - "command": "gitlens.views.fileHistory.setCursorFollowingOn", + "command": "gitlens.views.repositories.setShowSectionOff", "when": "false" }, { - "command": "gitlens.views.fileHistory.setCursorFollowingOff", + "command": "gitlens.views.searchAndCompare.clear", "when": "false" }, { - "command": "gitlens.views.fileHistory.setEditorFollowingOn", + "command": "gitlens.views.searchAndCompare.copy", "when": "false" }, { - "command": "gitlens.views.fileHistory.setEditorFollowingOff", + "command": "gitlens.views.searchAndCompare.refresh", "when": "false" }, { - "command": "gitlens.views.fileHistory.setRenameFollowingOn", + "command": "gitlens.views.searchAndCompare.searchCommits", "when": "false" }, { - "command": "gitlens.views.fileHistory.setRenameFollowingOff", + "command": "gitlens.views.searchAndCompare.selectForCompare", "when": "false" }, { - "command": "gitlens.views.fileHistory.setShowAllBranchesOn", + "command": "gitlens.views.searchAndCompare.setFilesLayoutToAuto", "when": "false" }, { - "command": "gitlens.views.fileHistory.setShowAllBranchesOff", + "command": "gitlens.views.searchAndCompare.setFilesLayoutToList", "when": "false" }, { - "command": "gitlens.views.fileHistory.setShowAvatarsOn", + "command": "gitlens.views.searchAndCompare.setFilesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.fileHistory.setShowAvatarsOff", + "command": "gitlens.views.setResultsCommitsFilterAuthors", "when": "false" }, { - "command": "gitlens.views.home.refresh", + "command": "gitlens.views.setResultsCommitsFilterOff", "when": "false" }, { - "command": "gitlens.views.lineHistory.changeBase", + "command": "gitlens.views.searchAndCompare.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.lineHistory.copy", + "command": "gitlens.views.searchAndCompare.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.lineHistory.refresh", + "command": "gitlens.views.searchAndCompare.swapComparison", "when": "false" }, { - "command": "gitlens.views.lineHistory.setEditorFollowingOn", + "command": "gitlens.views.searchAndCompare.setFilesFilterOnLeft", "when": "false" }, { - "command": "gitlens.views.lineHistory.setEditorFollowingOff", + "command": "gitlens.views.searchAndCompare.setFilesFilterOnRight", "when": "false" }, { - "command": "gitlens.views.lineHistory.setShowAvatarsOn", + "command": "gitlens.views.searchAndCompare.setFilesFilterOff", "when": "false" }, { - "command": "gitlens.views.lineHistory.setShowAvatarsOff", + "command": "gitlens.views.stashes.copy", "when": "false" }, { - "command": "gitlens.views.remotes.copy", + "command": "gitlens.views.stashes.refresh", "when": "false" }, { - "command": "gitlens.views.remotes.refresh", + "command": "gitlens.views.stashes.setFilesLayoutToAuto", "when": "false" }, { - "command": "gitlens.views.remotes.setLayoutToList", + "command": "gitlens.views.stashes.setFilesLayoutToList", "when": "false" }, { - "command": "gitlens.views.remotes.setLayoutToTree", + "command": "gitlens.views.stashes.setFilesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.remotes.setFilesLayoutToAuto", + "command": "gitlens.views.tags.copy", "when": "false" }, { - "command": "gitlens.views.remotes.setFilesLayoutToList", + "command": "gitlens.views.tags.refresh", "when": "false" }, { - "command": "gitlens.views.remotes.setFilesLayoutToTree", + "command": "gitlens.views.tags.setLayoutToList", "when": "false" }, { - "command": "gitlens.views.remotes.setShowAvatarsOn", + "command": "gitlens.views.tags.setLayoutToTree", "when": "false" }, { - "command": "gitlens.views.remotes.setShowAvatarsOff", + "command": "gitlens.views.tags.setFilesLayoutToAuto", "when": "false" }, { - "command": "gitlens.views.remotes.setShowBranchPullRequestOn", + "command": "gitlens.views.tags.setFilesLayoutToList", "when": "false" }, { - "command": "gitlens.views.remotes.setShowBranchPullRequestOff", + "command": "gitlens.views.tags.setFilesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.repositories.copy", + "command": "gitlens.views.tags.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.repositories.refresh", + "command": "gitlens.views.tags.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.repositories.setAutoRefreshToOn", + "command": "gitlens.views.timeline.refresh", "when": "false" }, { - "command": "gitlens.views.repositories.setAutoRefreshToOff", + "command": "gitlens.views.workspaces.info", "when": "false" }, { - "command": "gitlens.views.repositories.setBranchesLayoutToList", + "command": "gitlens.views.workspaces.convert", "when": "false" }, { - "command": "gitlens.views.repositories.setBranchesLayoutToTree", - "when": "false" + "command": "gitlens.views.workspaces.create", + "when": "gitlens:plus" }, { - "command": "gitlens.views.repositories.setFilesLayoutToAuto", + "command": "gitlens.views.workspaces.delete", "when": "false" }, { - "command": "gitlens.views.repositories.setFilesLayoutToList", + "command": "gitlens.views.workspaces.addRepos", "when": "false" }, { - "command": "gitlens.views.repositories.setFilesLayoutToTree", + "command": "gitlens.views.workspaces.addReposFromLinked", "when": "false" }, { - "command": "gitlens.views.repositories.setShowAvatarsOn", + "command": "gitlens.views.workspaces.repo.locate", "when": "false" }, { - "command": "gitlens.views.repositories.setShowAvatarsOff", + "command": "gitlens.views.workspaces.locateAllRepos", "when": "false" }, { - "command": "gitlens.views.repositories.setShowBranchComparisonOn", + "command": "gitlens.views.workspaces.createLocal", "when": "false" }, { - "command": "gitlens.views.repositories.setShowBranchComparisonOff", + "command": "gitlens.views.workspaces.openLocal", "when": "false" }, { - "command": "gitlens.views.repositories.setBranchesShowBranchComparisonOn", + "command": "gitlens.views.workspaces.openLocalNewWindow", "when": "false" }, { - "command": "gitlens.views.repositories.setBranchesShowBranchComparisonOff", + "command": "gitlens.views.workspaces.changeAutoAddSetting", "when": "false" }, { - "command": "gitlens.views.repositories.setShowBranchesOn", + "command": "gitlens.views.workspaces.repo.openInNewWindow", "when": "false" }, { - "command": "gitlens.views.repositories.setShowBranchesOff", + "command": "gitlens.views.workspaces.repo.open", "when": "false" }, { - "command": "gitlens.views.repositories.setShowCommitsOn", + "command": "gitlens.views.workspaces.repo.addToWindow", "when": "false" }, { - "command": "gitlens.views.repositories.setShowCommitsOff", + "command": "gitlens.views.workspaces.repo.remove", "when": "false" }, { - "command": "gitlens.views.repositories.setShowContributorsOn", + "command": "gitlens.views.workspaces.copy", "when": "false" }, { - "command": "gitlens.views.repositories.setShowContributorsOff", + "command": "gitlens.views.workspaces.refresh", "when": "false" }, { - "command": "gitlens.views.repositories.setShowRemotesOn", + "command": "gitlens.views.worktrees.copy", "when": "false" }, { - "command": "gitlens.views.repositories.setShowRemotesOff", + "command": "gitlens.views.worktrees.refresh", "when": "false" }, { - "command": "gitlens.views.repositories.setShowStashesOn", + "command": "gitlens.views.worktrees.setFilesLayoutToAuto", "when": "false" }, { - "command": "gitlens.views.repositories.setShowStashesOff", + "command": "gitlens.views.worktrees.setFilesLayoutToList", "when": "false" }, { - "command": "gitlens.views.repositories.setShowTagsOn", + "command": "gitlens.views.worktrees.setFilesLayoutToTree", "when": "false" }, { - "command": "gitlens.views.repositories.setShowTagsOff", + "command": "gitlens.views.worktrees.setShowAvatarsOn", "when": "false" }, { - "command": "gitlens.views.repositories.setShowWorktreesOn", + "command": "gitlens.views.worktrees.setShowAvatarsOff", "when": "false" }, { - "command": "gitlens.views.repositories.setShowWorktreesOff", + "command": "gitlens.views.worktrees.setShowBranchComparisonOn", "when": "false" }, { - "command": "gitlens.views.repositories.setShowUpstreamStatusOn", + "command": "gitlens.views.worktrees.setShowBranchComparisonOff", "when": "false" }, { - "command": "gitlens.views.repositories.setShowUpstreamStatusOff", + "command": "gitlens.views.worktrees.setShowBranchPullRequestOn", "when": "false" }, { - "command": "gitlens.views.repositories.setShowSectionOff", + "command": "gitlens.views.worktrees.setShowBranchPullRequestOff", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.clear", - "when": "false" + "command": "gitlens.graph.switchToEditorLayout", + "when": "gitlens:enabled && config.gitlens.graph.layout != editor" }, { - "command": "gitlens.views.searchAndCompare.copy", - "when": "false" + "command": "gitlens.graph.switchToPanelLayout", + "when": "gitlens:enabled && config.gitlens.graph.layout != panel" }, { - "command": "gitlens.views.searchAndCompare.pin", + "command": "gitlens.graph.push", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.unpin", + "command": "gitlens.graph.pull", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.refresh", + "command": "gitlens.graph.fetch", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.searchCommits", + "command": "gitlens.graph.openSCM", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.selectForCompare", + "command": "gitlens.graph.switchToAnotherBranch", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.setFilesLayoutToAuto", + "command": "gitlens.graph.refresh", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.setFilesLayoutToList", + "command": "gitlens.graph.copyDeepLinkToBranch", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.setFilesLayoutToTree", + "command": "gitlens.graph.copyDeepLinkToCommit", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.setKeepResultsToOn", + "command": "gitlens.graph.copyDeepLinkToRepo", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.setKeepResultsToOff", + "command": "gitlens.graph.copyDeepLinkToTag", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.setShowAvatarsOn", + "command": "gitlens.launchpad.refresh", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.setShowAvatarsOff", + "command": "gitlens.graph.copyRemoteBranchUrl", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.swapComparison", + "command": "gitlens.graph.createBranch", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.setFilesFilterOnLeft", + "command": "gitlens.graph.deleteBranch", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.setFilesFilterOnRight", + "command": "gitlens.graph.openBranchOnRemote", "when": "false" }, { - "command": "gitlens.views.searchAndCompare.setFilesFilterOff", + "command": "gitlens.graph.mergeBranchInto", "when": "false" }, { - "command": "gitlens.views.stashes.copy", + "command": "gitlens.graph.publishBranch", "when": "false" }, { - "command": "gitlens.views.stashes.refresh", + "command": "gitlens.graph.rebaseOntoBranch", "when": "false" }, { - "command": "gitlens.views.stashes.setFilesLayoutToAuto", + "command": "gitlens.graph.rebaseOntoUpstream", "when": "false" }, { - "command": "gitlens.views.stashes.setFilesLayoutToList", + "command": "gitlens.graph.renameBranch", "when": "false" }, { - "command": "gitlens.views.stashes.setFilesLayoutToTree", + "command": "gitlens.graph.switchToBranch", "when": "false" }, { - "command": "gitlens.views.tags.copy", + "command": "gitlens.graph.hideLocalBranch", "when": "false" }, { - "command": "gitlens.views.tags.refresh", + "command": "gitlens.graph.hideRemoteBranch", "when": "false" }, { - "command": "gitlens.views.tags.setLayoutToList", + "command": "gitlens.graph.hideRemote", "when": "false" }, { - "command": "gitlens.views.tags.setLayoutToTree", + "command": "gitlens.graph.hideTag", "when": "false" }, { - "command": "gitlens.views.tags.setFilesLayoutToAuto", + "command": "gitlens.graph.hideRefGroup", "when": "false" }, { - "command": "gitlens.views.tags.setFilesLayoutToList", + "command": "gitlens.graph.cherryPick", "when": "false" }, { - "command": "gitlens.views.tags.setFilesLayoutToTree", + "command": "gitlens.graph.copyMessage", "when": "false" }, { - "command": "gitlens.views.tags.setShowAvatarsOn", + "command": "gitlens.graph.copySha", "when": "false" }, { - "command": "gitlens.views.tags.setShowAvatarsOff", + "command": "gitlens.graph.copyRemoteCommitUrl", "when": "false" }, { - "command": "gitlens.views.timeline.openInTab", + "command": "gitlens.graph.copyRemoteCommitUrl.multi", "when": "false" }, { - "command": "gitlens.views.timeline.refresh", + "command": "gitlens.graph.showInDetailsView", "when": "false" }, { - "command": "gitlens.views.worktrees.copy", + "command": "gitlens.graph.openCommitOnRemote", "when": "false" }, { - "command": "gitlens.views.worktrees.refresh", + "command": "gitlens.graph.openCommitOnRemote.multi", "when": "false" }, { - "command": "gitlens.views.worktrees.setFilesLayoutToAuto", + "command": "gitlens.graph.rebaseOntoCommit", "when": "false" }, { - "command": "gitlens.views.worktrees.setFilesLayoutToList", + "command": "gitlens.graph.resetCommit", "when": "false" }, { - "command": "gitlens.views.worktrees.setFilesLayoutToTree", + "command": "gitlens.graph.resetToCommit", "when": "false" }, { - "command": "gitlens.views.worktrees.setShowAvatarsOn", + "command": "gitlens.graph.resetToTip", "when": "false" }, { - "command": "gitlens.views.worktrees.setShowAvatarsOff", + "command": "gitlens.graph.revert", "when": "false" }, { - "command": "gitlens.views.worktrees.setShowBranchComparisonOn", + "command": "gitlens.graph.switchToCommit", "when": "false" }, { - "command": "gitlens.views.worktrees.setShowBranchComparisonOff", + "command": "gitlens.graph.undoCommit", "when": "false" }, { - "command": "gitlens.views.worktrees.setShowBranchPullRequestOn", + "command": "gitlens.graph.stash.save", "when": "false" }, { - "command": "gitlens.views.worktrees.setShowBranchPullRequestOff", + "command": "gitlens.graph.stash.apply", "when": "false" }, { - "command": "gitlens.graph.push", + "command": "gitlens.graph.stash.delete", "when": "false" }, { - "command": "gitlens.graph.pull", + "command": "gitlens.graph.stash.rename", "when": "false" }, { - "command": "gitlens.graph.fetch", + "command": "gitlens.graph.createTag", "when": "false" }, { - "command": "gitlens.graph.switchToAnotherBranch", + "command": "gitlens.graph.deleteTag", "when": "false" }, { - "command": "gitlens.graph.refresh", + "command": "gitlens.graph.switchToTag", "when": "false" }, { - "command": "gitlens.graph.copyDeepLinkToBranch", + "command": "gitlens.graph.createWorktree", "when": "false" }, { - "command": "gitlens.graph.copyDeepLinkToCommit", + "command": "gitlens.graph.createPullRequest", "when": "false" }, { - "command": "gitlens.graph.copyDeepLinkToRepo", + "command": "gitlens.graph.openPullRequest", "when": "false" }, { - "command": "gitlens.graph.copyDeepLinkToTag", + "command": "gitlens.graph.openPullRequestChanges", "when": "false" }, { - "command": "gitlens.focus.refresh", + "command": "gitlens.graph.openPullRequestComparison", "when": "false" }, { - "command": "gitlens.graph.copyRemoteBranchUrl", + "command": "gitlens.graph.openPullRequestOnRemote", "when": "false" }, { - "command": "gitlens.graph.createBranch", + "command": "gitlens.graph.compareAncestryWithWorking", "when": "false" }, { - "command": "gitlens.graph.deleteBranch", + "command": "gitlens.graph.compareWithMergeBase", "when": "false" }, { - "command": "gitlens.graph.openBranchOnRemote", + "command": "gitlens.graph.openChangedFileDiffsWithMergeBase", "when": "false" }, { - "command": "gitlens.graph.mergeBranchInto", + "command": "gitlens.graph.compareWithHead", "when": "false" }, { - "command": "gitlens.graph.rebaseOntoBranch", + "command": "gitlens.graph.compareBranchWithHead", "when": "false" }, { - "command": "gitlens.graph.rebaseOntoUpstream", + "command": "gitlens.graph.compareWithUpstream", "when": "false" }, { - "command": "gitlens.graph.renameBranch", + "command": "gitlens.graph.compareWithWorking", "when": "false" }, { - "command": "gitlens.graph.switchToBranch", + "command": "gitlens.graph.openChangedFiles", "when": "false" }, { - "command": "gitlens.graph.hideLocalBranch", + "command": "gitlens.graph.openChangedFileDiffs", "when": "false" }, { - "command": "gitlens.graph.hideRemoteBranch", + "command": "gitlens.graph.openChangedFileDiffsWithWorking", "when": "false" }, { - "command": "gitlens.graph.hideRemote", + "command": "gitlens.graph.openChangedFileDiffsIndividually", "when": "false" }, { - "command": "gitlens.graph.hideTag", + "command": "gitlens.graph.openChangedFileDiffsWithWorkingIndividually", "when": "false" }, { - "command": "gitlens.graph.hideRefGroup", + "command": "gitlens.graph.openChangedFileRevisions", "when": "false" }, { - "command": "gitlens.graph.cherryPick", + "command": "gitlens.graph.openOnlyChangedFiles", "when": "false" }, { - "command": "gitlens.graph.copyMessage", + "command": "gitlens.graph.addAuthor", "when": "false" }, { - "command": "gitlens.graph.copySha", + "command": "gitlens.graph.copy", "when": "false" }, { - "command": "gitlens.graph.copyRemoteCommitUrl", + "command": "gitlens.graph.columnAuthorOn", "when": "false" }, { - "command": "gitlens.graph.showInDetailsView", + "command": "gitlens.graph.columnAuthorOff", "when": "false" }, { - "command": "gitlens.graph.openCommitOnRemote", + "command": "gitlens.graph.columnDateTimeOn", "when": "false" }, { - "command": "gitlens.graph.rebaseOntoCommit", + "command": "gitlens.graph.columnDateTimeOff", "when": "false" }, { - "command": "gitlens.graph.resetCommit", + "command": "gitlens.graph.columnShaOn", "when": "false" }, { - "command": "gitlens.graph.resetToCommit", + "command": "gitlens.graph.columnShaOff", "when": "false" }, { - "command": "gitlens.graph.revert", + "command": "gitlens.graph.columnGraphCompact", "when": "false" }, { - "command": "gitlens.graph.switchToCommit", + "command": "gitlens.graph.columnGraphDefault", "when": "false" }, { - "command": "gitlens.graph.undoCommit", + "command": "gitlens.graph.columnChangesOn", "when": "false" }, { - "command": "gitlens.graph.applyStash", + "command": "gitlens.graph.columnChangesOff", "when": "false" }, { - "command": "gitlens.graph.deleteStash", + "command": "gitlens.graph.columnGraphOn", "when": "false" }, { - "command": "gitlens.graph.createTag", + "command": "gitlens.graph.columnGraphOff", "when": "false" }, { - "command": "gitlens.graph.deleteTag", + "command": "gitlens.graph.columnMessageOn", "when": "false" }, { - "command": "gitlens.graph.switchToTag", + "command": "gitlens.graph.columnMessageOff", "when": "false" }, { - "command": "gitlens.graph.createWorktree", + "command": "gitlens.graph.columnRefOn", "when": "false" }, { - "command": "gitlens.graph.createPullRequest", + "command": "gitlens.graph.columnRefOff", "when": "false" }, { - "command": "gitlens.graph.openPullRequestOnRemote", + "command": "gitlens.graph.resetColumnsDefault", "when": "false" }, { - "command": "gitlens.graph.compareAncestryWithWorking", + "command": "gitlens.graph.resetColumnsCompact", "when": "false" }, { - "command": "gitlens.graph.compareWithHead", + "command": "gitlens.graph.scrollMarkerLocalBranchOn", "when": "false" }, { - "command": "gitlens.graph.compareWithUpstream", + "command": "gitlens.graph.scrollMarkerLocalBranchOff", "when": "false" }, { - "command": "gitlens.graph.compareWithWorking", + "command": "gitlens.graph.scrollMarkerRemoteBranchOn", "when": "false" }, { - "command": "gitlens.graph.addAuthor", + "command": "gitlens.graph.scrollMarkerRemoteBranchOff", "when": "false" }, { - "command": "gitlens.graph.copy", + "command": "gitlens.graph.scrollMarkerStashOn", "when": "false" }, { - "command": "gitlens.graph.columnAuthorOn", + "command": "gitlens.graph.scrollMarkerStashOff", "when": "false" }, { - "command": "gitlens.graph.columnAuthorOff", + "command": "gitlens.graph.scrollMarkerTagOn", "when": "false" }, { - "command": "gitlens.graph.columnDateTimeOn", + "command": "gitlens.graph.scrollMarkerTagOff", "when": "false" }, { - "command": "gitlens.graph.columnDateTimeOff", + "command": "gitlens.graph.scrollMarkerPullRequestOn", "when": "false" }, { - "command": "gitlens.graph.columnShaOn", + "command": "gitlens.graph.scrollMarkerPullRequestOff", "when": "false" }, { - "command": "gitlens.graph.columnShaOff", + "command": "gitlens.graph.shareAsCloudPatch", "when": "false" }, { - "command": "gitlens.graph.columnChangesOn", + "command": "gitlens.graph.createPatch", "when": "false" }, { - "command": "gitlens.graph.columnChangesOff", + "command": "gitlens.graph.createCloudPatch", "when": "false" }, { @@ -9237,336 +12415,363 @@ }, { "command": "gitlens.disableDebugLogging", - "when": "config.gitlens.outputLevel != errors" - } - ], - "editor/context": [ - { - "submenu": "gitlens/editor/context/changes", - "when": "editorTextFocus && config.gitlens.menus.editor.compare", - "group": "2_gitlens@1" + "when": "config.gitlens.outputLevel == debug" }, { - "command": "gitlens.openCommitOnRemote", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /remotes/ && config.gitlens.menus.editor.remote", - "group": "2_gitlens@2", - "alt": "gitlens.copyRemoteCommitUrl" + "command": "gitlens.generateCommitMessage", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:ai:enabled && config.gitlens.ai.experimental.generateCommitMessage.enabled" }, { - "command": "gitlens.openFileOnRemote", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /remotes/ && config.gitlens.menus.editor.remote", - "group": "2_gitlens@3", - "alt": "gitlens.copyRemoteFileUrlToClipboard" - }, + "command": "gitlens.resetAIKey", + "when": "gitlens:enabled && gitlens:gk:organization:ai:enabled" + } + ], + "editor/context": [ { - "command": "gitlens.openFileOnRemoteFrom", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /remotes/ && config.gitlens.menus.editor.remote", - "group": "2_gitlens@4", - "alt": "gitlens.copyRemoteFileUrlFrom" + "command": "gitlens.openWorkingFile", + "when": "editorTextFocus && config.gitlens.menus.editor.compare && resourceScheme == gitlens", + "group": "1_z_gitlens@0" }, { - "command": "gitlens.openFileHistory", - "when": "gitlens:activeFileStatus =~ /tracked/ && config.gitlens.menus.editor.history", - "group": "2_gitlens_1@1" + "submenu": "gitlens/editor/context/changes", + "when": "editorTextFocus && config.gitlens.menus.editor.compare && resourceScheme in gitlens:schemes:trackable", + "group": "1_z_gitlens_open@1" }, { - "command": "gitlens.quickOpenFileHistory", - "when": "gitlens:activeFileStatus =~ /tracked/ && config.gitlens.menus.editor.history", - "group": "2_gitlens_1@2" + "submenu": "gitlens/editor/context/openOn", + "when": "editorTextFocus && gitlens:repos:withRemotes && config.gitlens.menus.editor.remote && resourceScheme in gitlens:schemes:trackable", + "group": "1_z_gitlens_open@2" }, { "submenu": "gitlens/editor/annotations", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /blameable/ && config.gitlens.menus.editor.blame", - "group": "2_gitlens_2@1" + "when": "editorTextFocus && resource in gitlens:tabs:blameable && config.gitlens.menus.editor.blame && resourceScheme in gitlens:schemes:trackable", + "group": "1_z_gitlens_open_file@1" + }, + { + "submenu": "gitlens/editor/history", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editor.history && resourceScheme in gitlens:schemes:trackable", + "group": "1_z_gitlens_open_file@2" } ], "editor/context/copy": [ { - "command": "gitlens.copyRemoteFileUrlToClipboard", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /blameable/ && config.gitlens.menus.editor.clipboard", + "command": "gitlens.copyRelativePathToClipboard", + "when": "editorTextFocus && config.gitlens.menus.editor.clipboard && resourceScheme in gitlens:schemes:trackable", "group": "1_gitlens@1" }, + { + "command": "gitlens.copyRemoteFileUrlToClipboard", + "when": "editorTextFocus && resource in gitlens:tabs:tracked && config.gitlens.menus.editor.clipboard && resourceScheme in gitlens:schemes:trackable", + "group": "1_gitlens_remote@1" + }, { "command": "gitlens.copyRemoteFileUrlFrom", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /blameable/ && config.gitlens.menus.editor.clipboard", - "group": "1_gitlens@2" + "when": "editorTextFocus && resource in gitlens:tabs:tracked && config.gitlens.menus.editor.clipboard && resourceScheme in gitlens:schemes:trackable", + "group": "1_gitlens_remote@2" }, { "command": "gitlens.copyRemoteCommitUrl", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /blameable/ && config.gitlens.menus.editor.clipboard", - "group": "1_gitlens@3" + "when": "editorTextFocus && resource in gitlens:tabs:tracked && config.gitlens.menus.editor.clipboard && resourceScheme in gitlens:schemes:trackable", + "group": "1_gitlens_remote@3" }, { "command": "gitlens.copyShaToClipboard", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /blameable/ && config.gitlens.menus.editor.clipboard", - "group": "2_gitlens@1" + "when": "editorTextFocus && resource in gitlens:tabs:tracked && config.gitlens.menus.editor.clipboard && resourceScheme in gitlens:schemes:trackable", + "group": "3_gitlens@1" }, { "command": "gitlens.copyMessageToClipboard", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /blameable/ && config.gitlens.menus.editor.clipboard", + "when": "editorTextFocus && resource in gitlens:tabs:tracked && config.gitlens.menus.editor.clipboard && resourceScheme in gitlens:schemes:trackable", + "group": "3_gitlens@2" + }, + { + "command": "gitlens.copyDeepLinkToLines", + "when": "editorTextFocus && editorHasSelection && config.gitlens.menus.editor.clipboard && resourceScheme in gitlens:schemes:trackable", + "group": "1_gitlens@2" + }, + { + "command": "gitlens.copyDeepLinkToFile", + "when": "editorTextFocus && config.gitlens.menus.editor.clipboard && resourceScheme in gitlens:schemes:trackable", + "group": "1_gitlens@3" + }, + { + "command": "gitlens.copyDeepLinkToFileAtRevision", + "when": "editorTextFocus && resource in gitlens:tabs:tracked && config.gitlens.menus.editor.clipboard && resourceScheme in gitlens:schemes:trackable", + "group": "1_gitlens@4" + } + ], + "editor/lineNumber/context": [ + { + "submenu": "gitlens/editor/lineNumber/context/share", + "when": "gitlens:repos:withRemotes && config.gitlens.menus.editorGutter.share && resourceScheme in gitlens:schemes:trackable", "group": "2_gitlens@2" + }, + { + "submenu": "gitlens/editor/lineNumber/context/changes", + "when": "config.gitlens.menus.editorGutter.compare && resourceScheme in gitlens:schemes:trackable", + "group": "3_gitlens@1" + }, + { + "submenu": "gitlens/editor/lineNumber/context/openOn", + "when": "gitlens:repos:withRemotes && config.gitlens.menus.editorGutter.remote && resourceScheme in gitlens:schemes:trackable", + "group": "3_gitlens@2" } ], "editor/title": [ + { + "command": "gitlens.openPatch", + "when": "false && editorLangId == diff" + }, { "command": "gitlens.diffWithWorking", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && !isInDiffEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && !isInDiffEditor", "group": "navigation@-99" }, { "command": "gitlens.diffWithWorkingInDiffLeft", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffEditor && !isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffEditor && !isInDiffRightEditor", "group": "navigation@-99" }, { "command": "gitlens.diffWithWorkingInDiffRight", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffRightEditor", "group": "navigation@-99" }, { "command": "gitlens.openWorkingFile", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme == git && !isInDiffEditor", + "when": "gitlens:enabled && resourceScheme == git && !isInDiffEditor", "group": "navigation@-98" }, { "command": "gitlens.openWorkingFile", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && !isInDiffEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && !isInDiffEditor", "group": "navigation@-98" }, { "command": "gitlens.openWorkingFileInDiffLeft", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffEditor && !isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffEditor && !isInDiffRightEditor", "group": "navigation@-98" }, { "command": "gitlens.openWorkingFileInDiffRight", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffRightEditor", "group": "navigation@-98" }, { "command": "gitlens.openRevisionFileInDiffLeft", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffEditor && !isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffEditor && !isInDiffRightEditor", "group": "navigation@-97" }, { "command": "gitlens.openRevisionFileInDiffRight", - "when": "gitlens:activeFileStatus =~ /revision/ && resourceScheme =~ /^(?!(file|git)$).*$/ && isInDiffRightEditor", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|pr)$/ && isInDiffRightEditor", "group": "navigation@-97" }, { "command": "gitlens.diffWithPrevious", "alt": "gitlens.diffWithRevision", - "when": "gitlens:activeFileStatus =~ /tracked/ && config.gitlens.menus.editorGroup.compare && !isInDiffEditor", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editorGroup.compare && !isInDiffEditor", "group": "navigation@97" }, { "command": "gitlens.diffWithPreviousInDiffLeft", "alt": "gitlens.diffWithRevision", - "when": "gitlens:activeFileStatus =~ /tracked/ && config.gitlens.menus.editorGroup.compare && isInDiffEditor && !isInDiffRightEditor", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editorGroup.compare && isInDiffEditor && !isInDiffRightEditor", "group": "navigation@97" }, { "command": "gitlens.diffWithPreviousInDiffRight", "alt": "gitlens.diffWithRevision", - "when": "gitlens:activeFileStatus =~ /tracked/ && config.gitlens.menus.editorGroup.compare && isInDiffRightEditor", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editorGroup.compare && isInDiffRightEditor", "group": "navigation@97" }, { "command": "gitlens.showQuickRevisionDetails", - "when": "gitlens:activeFileStatus =~ /tracked/ && config.gitlens.menus.editorGroup.compare && !isInDiffEditor", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editorGroup.compare && !isInDiffEditor", "group": "navigation@98" }, { "command": "gitlens.showQuickRevisionDetailsInDiffLeft", - "when": "gitlens:activeFileStatus =~ /tracked/ && config.gitlens.menus.editorGroup.compare && isInDiffEditor && !isInDiffRightEditor", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editorGroup.compare && isInDiffEditor && !isInDiffRightEditor", "group": "navigation@98" }, { "command": "gitlens.showQuickRevisionDetailsInDiffRight", - "when": "gitlens:activeFileStatus =~ /tracked/ && config.gitlens.menus.editorGroup.compare && isInDiffRightEditor", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editorGroup.compare && isInDiffRightEditor", "group": "navigation@98" }, { "command": "gitlens.diffWithNext", - "when": "gitlens:activeFileStatus =~ /tracked/ && config.gitlens.menus.editorGroup.compare && !isInDiffEditor", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editorGroup.compare && !isInDiffEditor", "group": "navigation@99" }, { "command": "gitlens.diffWithNextInDiffLeft", - "when": "gitlens:activeFileStatus =~ /tracked/ && config.gitlens.menus.editorGroup.compare && isInDiffEditor && !isInDiffRightEditor", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editorGroup.compare && isInDiffEditor && !isInDiffRightEditor", "group": "navigation@99" }, { "command": "gitlens.diffWithNextInDiffRight", - "when": "gitlens:activeFileStatus =~ /tracked/ && config.gitlens.menus.editorGroup.compare && isInDiffRightEditor", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editorGroup.compare && isInDiffRightEditor", "group": "navigation@99" }, { "command": "gitlens.toggleFileBlame", - "when": "config.gitlens.fileAnnotations.command == blame && gitlens:activeFileStatus =~ /blameable/ && !gitlens:annotationStatus && config.gitlens.menus.editorGroup.blame", + "when": "config.gitlens.fileAnnotations.command == blame && resource in gitlens:tabs:blameable && resource not in gitlens:tabs:annotated && config.gitlens.menus.editorGroup.blame", "group": "navigation@100", "alt": "gitlens.toggleFileHeatmap" }, { "command": "gitlens.toggleFileHeatmap", - "when": "config.gitlens.fileAnnotations.command == heatmap && gitlens:activeFileStatus =~ /blameable/ && !gitlens:annotationStatus && config.gitlens.menus.editorGroup.blame", + "when": "config.gitlens.fileAnnotations.command == heatmap && resource in gitlens:tabs:blameable && resource not in gitlens:tabs:annotated && config.gitlens.menus.editorGroup.blame", "group": "navigation@100", "alt": "gitlens.toggleFileBlame" }, { "command": "gitlens.toggleFileChanges", - "when": "config.gitlens.fileAnnotations.command == changes && gitlens:activeFileStatus =~ /blameable/ && !gitlens:hasVirtualFolders && !gitlens:annotationStatus && config.gitlens.menus.editorGroup.blame", + "when": "config.gitlens.fileAnnotations.command == changes && resource in gitlens:tabs:blameable && !gitlens:hasVirtualFolders && resource not in gitlens:tabs:annotated && config.gitlens.menus.editorGroup.blame", "group": "navigation@100", "alt": "gitlens.toggleFileBlame" }, { "submenu": "gitlens/editor/annotations", - "when": "!config.gitlens.fileAnnotations.command && gitlens:activeFileStatus =~ /blameable/ && !gitlens:annotationStatus && config.gitlens.menus.editorGroup.blame", + "when": "!config.gitlens.fileAnnotations.command && resource in gitlens:tabs:blameable && !gitlens:window:annotated && resource not in gitlens:tabs:annotated && config.gitlens.menus.editorGroup.blame", "group": "navigation@100" }, { "command": "gitlens.computingFileAnnotations", - "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computing && config.gitlens.menus.editorGroup.blame", + "when": "resource in gitlens:tabs:blameable && (gitlens:window:annotated == computing || resource in gitlens:tabs:annotated:computing) && config.gitlens.menus.editorGroup.blame", "group": "navigation@100" }, { "command": "gitlens.clearFileAnnotations", - "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computed && config.gitlens.menus.editorGroup.blame", + "when": "resource in gitlens:tabs:blameable && (gitlens:window:annotated == computed || (resource in gitlens:tabs:annotated && resource not in gitlens:tabs:annotated:computing)) && config.gitlens.menus.editorGroup.blame", "group": "navigation@100" }, { - "command": "gitlens.refreshTimelinePage", - "when": "gitlens:webview:timeline:active", + "command": "gitlens.timeline.refresh", + "when": "activeWebviewPanelId === gitlens.timeline", "group": "navigation@-99" }, { - "command": "gitlens.graph.push", - "when": "gitlens:webview:graph:active && gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", - "group": "navigation@-103" - }, - { - "command": "gitlens.graph.pull", - "when": "gitlens:webview:graph:active && gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", - "group": "navigation@-102" + "command": "gitlens.graph.refresh", + "when": "activeWebviewPanelId === gitlens.graph", + "group": "navigation@-99" }, { - "command": "gitlens.graph.fetch", - "when": "gitlens:webview:graph:active && gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", - "group": "navigation@-101" + "submenu": "gitlens/graph/configuration", + "when": "activeWebviewPanelId === gitlens.graph", + "group": "navigation@-98" }, { - "command": "gitlens.graph.switchToAnotherBranch", - "when": "gitlens:webview:graph:active && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", - "group": "navigation@-100" + "command": "gitlens.launchpad.refresh", + "when": "activeWebviewPanelId === gitlens.focus", + "group": "navigation@-98" }, { - "command": "gitlens.graph.refresh", - "when": "gitlens:webview:graph:active", - "group": "navigation@-99" + "command": "gitlens.launchpad.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.focus && config.gitlens.launchpad.allowMultiple", + "group": "navigation@-97" }, { - "command": "gitlens.showSettingsPage#commit-graph", - "when": "gitlens:webview:graph:active", - "group": "navigation@-98" + "command": "gitlens.graph.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.graph && config.gitlens.graph.allowMultiple", + "group": "navigation@-97" }, { - "command": "gitlens.focus.refresh", - "when": "gitlens:focus:focused", - "group": "navigation@-98" + "command": "gitlens.timeline.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.timeline && config.gitlens.visualHistory.allowMultiple", + "group": "navigation@-97" } ], "editor/title/context": [ { "command": "gitlens.copyRemoteFileUrlWithoutRange", - "when": "gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.editorTab.clipboard && isFileSystemResource", + "when": "gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.editorTab.clipboard && resourceScheme in gitlens:schemes:trackable", "group": "1_cutcopypaste@100" }, { - "submenu": "gitlens/editor/changes", - "when": "gitlens:enabled && config.gitlens.menus.editorTab.compare && isFileSystemResource", - "group": "2_gitlens@0" + "command": "gitlens.copyRemoteFileUrlFrom", + "when": "gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.editorTab.clipboard && resourceScheme in gitlens:schemes:trackable", + "group": "1_cutcopypaste@101" }, { "command": "gitlens.openWorkingFile", - "when": "resourceScheme == gitlens && isFileSystemResource", - "group": "2_gitlens@1" + "when": "resourceScheme == gitlens && resourceScheme in gitlens:schemes:trackable", + "group": "2_a_gitlens@0" }, { - "command": "gitlens.openFileOnRemote", - "when": "gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.editorTab.remote && isFileSystemResource", - "group": "2_gitlens@2", - "alt": "gitlens.copyRemoteFileUrlWithoutRange" + "submenu": "gitlens/editor/changes", + "when": "gitlens:enabled && config.gitlens.menus.editorTab.compare && resourceScheme in gitlens:schemes:trackable", + "group": "2_a_gitlens_open@1" }, { - "command": "gitlens.openFileOnRemoteFrom", - "when": "gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.editorTab.remote && isFileSystemResource", - "group": "2_gitlens@3", - "alt": "gitlens.copyRemoteFileUrlFrom" + "submenu": "gitlens/editor/openOn", + "when": "gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.editorTab.remote && resourceScheme in gitlens:schemes:trackable", + "group": "2_a_gitlens_open@2" }, { - "command": "gitlens.openFileHistory", - "when": "gitlens:enabled && config.gitlens.menus.editorTab.history && isFileSystemResource", - "group": "2_gitlens_1@1" + "submenu": "gitlens/editor/history", + "when": "gitlens:enabled && config.gitlens.menus.editorTab.history && resourceScheme in gitlens:schemes:trackable", + "group": "2_a_gitlens_open_file@1" }, { - "command": "gitlens.quickOpenFileHistory", - "when": "gitlens:enabled && config.gitlens.menus.editorTab.history && isFileSystemResource", - "group": "2_gitlens_1@2" - } - ], - "explorer/context": [ - { - "submenu": "gitlens/explorer/changes", - "when": "!explorerResourceIsRoot && !explorerResourceIsFolder && gitlens:enabled && config.gitlens.menus.explorer.compare", - "group": "4_gitlens@0" + "command": "gitlens.launchpad.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.focus && config.gitlens.launchpad.allowMultiple", + "group": "6_split_in_group_gitlens@2" }, { - "command": "gitlens.openFileOnRemote", - "when": "!explorerResourceIsRoot && !explorerResourceIsFolder && gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.explorer.remote", - "group": "4_gitlens@1", - "alt": "gitlens.copyRemoteFileUrlWithoutRange" + "command": "gitlens.graph.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.graph && config.gitlens.graph.allowMultiple", + "group": "6_split_in_group_gitlens@2" }, { - "command": "gitlens.openFileOnRemoteFrom", - "when": "!explorerResourceIsRoot && !explorerResourceIsFolder && gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.explorer.remote", - "group": "4_gitlens@2", - "alt": "gitlens.copyRemoteFileUrlFrom" - }, + "command": "gitlens.timeline.split", + "when": "resourceScheme == webview-panel && activeWebviewPanelId === gitlens.timeline && config.gitlens.visualHistory.allowMultiple", + "group": "6_split_in_group_gitlens@2" + } + ], + "explorer/context": [ { - "command": "gitlens.openFolderHistory", - "when": "explorerResourceIsFolder && gitlens:enabled && config.gitlens.menus.explorer.history", - "group": "4_timeline@2" + "submenu": "gitlens/explorer/changes", + "when": "!explorerResourceIsRoot && gitlens:enabled && config.gitlens.menus.explorer.compare", + "group": "4_t_gitlens@0" }, { - "command": "gitlens.openFileHistory", - "when": "!explorerResourceIsRoot && !explorerResourceIsFolder && gitlens:enabled && config.gitlens.menus.explorer.history", - "group": "4_timeline@2" + "submenu": "gitlens/explorer/openOn", + "when": "!explorerResourceIsRoot && gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.explorer.remote", + "group": "4_t_gitlens@1" }, { - "command": "gitlens.quickOpenFileHistory", - "when": "!explorerResourceIsRoot && !explorerResourceIsFolder && gitlens:enabled && config.gitlens.menus.explorer.history", - "group": "4_timeline@3" + "submenu": "gitlens/explorer/history", + "when": "gitlens:enabled && config.gitlens.menus.explorer.history", + "group": "4_timeline@0" }, { "command": "gitlens.copyRemoteFileUrlWithoutRange", - "when": "!explorerResourceIsRoot && !explorerResourceIsFolder && gitlens:enabled && gitlens:hasRemotes && config.gitlens.menus.explorer.clipboard", + "when": "!explorerResourceIsRoot && gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.explorer.clipboard", "group": "6_copypath@100" + }, + { + "command": "gitlens.copyRemoteFileUrlFrom", + "when": "!explorerResourceIsRoot && gitlens:enabled && gitlens:repos:withRemotes && config.gitlens.menus.explorer.clipboard", + "group": "6_copypath@101" } ], "extension/context": [ { "command": "gitlens.getStarted", - "when": "extension =~ /^eamodio.gitlens(-insiders)?$/ && extensionStatus == installed", + "when": "extension =~ /^eamodio.gitlens?$/ && extensionStatus == installed", "group": "9_gitlens@1" }, { "command": "gitlens.showWelcomePage", - "when": "extension =~ /^eamodio.gitlens(-insiders)?$/ && extensionStatus == installed", + "when": "extension =~ /^eamodio.gitlens?$/ && extensionStatus == installed", "group": "9_gitlens@2" }, { "command": "gitlens.showSettingsPage", - "when": "extension =~ /^eamodio.gitlens(-insiders)?$/ && extensionStatus == installed", + "when": "extension =~ /^eamodio.gitlens?$/ && extensionStatus == installed", "group": "9_gitlens@3" } ], @@ -9575,42 +12780,77 @@ "command": "gitlens.addAuthors", "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && config.gitlens.menus.scmRepository.authors", "group": "4_gitlens@1" + }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && scmProvider == git && config.gitlens.menus.scmRepository.patch", + "group": "4_gitlens@2" + }, + { + "command": "gitlens.shareAsCloudPatch", + "when": "gitlens:enabled && scmProvider == git && config.gitlens.menus.scmRepository.patch", + "group": "4_gitlens@3" + }, + { + "command": "gitlens.generateCommitMessage", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:ai:enabled && config.gitlens.ai.experimental.generateCommitMessage.enabled && config.gitlens.menus.scmRepository.generateCommitMessage", + "group": "4_gitlens@4" } ], "menuBar/edit/copy": [ { "command": "gitlens.copyRemoteFileUrlToClipboard", - "when": "gitlens:activeFileStatus =~ /blameable/ && config.gitlens.menus.editor.clipboard", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editor.clipboard", "group": "1_gitlens@1" }, { "command": "gitlens.copyRemoteFileUrlFrom", - "when": "gitlens:activeFileStatus =~ /blameable/ && config.gitlens.menus.editor.clipboard", + "when": "resource in gitlens:tabs:tracked && config.gitlens.menus.editor.clipboard", "group": "1_gitlens@2" } ], "scm/sourceControl": [ { - "command": "gitlens.showGraphPage", + "command": "gitlens.showGraph", "when": "gitlens:enabled && config.gitlens.menus.scm.graph && gitlens:plus:enabled && scmProvider == git && scmProviderRootUri not in gitlens:plus:disallowedRepos", "group": "6_gitlens@1" } ], "scm/title": [ { - "command": "gitlens.showGraphPage", + "command": "gitlens.stashSave", + "when": "gitlens:enabled && config.gitlens.menus.scmRepositoryInline.stash && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && scmProvider == git", + "group": "navigation@-1001" + }, + { + "command": "gitlens.showGraph", "when": "gitlens:enabled && config.gitlens.menus.scmRepositoryInline.graph && gitlens:plus:enabled && scmProvider == git && scmProviderRootUri not in gitlens:plus:disallowedRepos", "group": "navigation@-1000" }, { "command": "gitlens.addAuthors", - "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && config.gitlens.menus.scmRepository.authors && scmProvider == git", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && scmProvider == git && config.gitlens.menus.scmRepository.authors", "group": "2_z_gitlens@1" }, { - "command": "gitlens.showGraphPage", - "when": "gitlens:enabled && config.gitlens.menus.scmRepository.graph && gitlens:plus:enabled && scmProvider == git && scmProviderRootUri not in gitlens:plus:disallowedRepos", + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && scmProvider == git && config.gitlens.menus.scmRepository.patch", "group": "2_z_gitlens@2" + }, + { + "command": "gitlens.shareAsCloudPatch", + "when": "gitlens:enabled && scmProvider == git && config.gitlens.menus.scmRepository.patch", + "group": "2_z_gitlens@3" + }, + { + "command": "gitlens.generateCommitMessage", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:ai:enabled && config.gitlens.ai.experimental.generateCommitMessage.enabled && scmProvider == git && config.gitlens.menus.scmRepository.generateCommitMessage", + "group": "2_z_gitlens@4" + }, + { + "command": "gitlens.showGraph", + "when": "gitlens:enabled && config.gitlens.menus.scmRepository.graph && gitlens:plus:enabled && scmProvider == git && scmProviderRootUri not in gitlens:plus:disallowedRepos", + "group": "2_z_gitlens@5" } ], "scm/resourceGroup/context": [ @@ -9626,7 +12866,7 @@ }, { "submenu": "gitlens/scm/resourceGroup/changes", - "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmGroup.compare", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmGroup.compare", "group": "2_gitlens@1" }, { @@ -9638,6 +12878,33 @@ "command": "gitlens.closeUnchangedFiles", "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmGroup.openClose", "group": "3_gitlens@2" + }, + { + "command": "gitlens.openOnlyChangedFiles", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmGroup.openClose", + "group": "3_gitlens@3" + }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmGroup.patch", + "group": "7_cutcopypaste@97" + }, + { + "command": "gitlens.shareAsCloudPatch", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmGroup.patch", + "group": "7_cutcopypaste@98" + } + ], + "scm/resourceFolder/context": [ + { + "submenu": "gitlens/scm/resourceFolder/changes", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.compare", + "group": "2_gitlens@1" + }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmGroup.patch", + "group": "7_cutcopypaste@97" } ], "scm/resourceState/context": [ @@ -9652,16 +12919,14 @@ "group": "navigation" }, { - "command": "gitlens.openFileOnRemote", - "when": "gitlens:enabled && gitlens:hasRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.remote", - "group": "navigation@96", - "alt": "gitlens.copyRemoteFileUrlWithoutRange" + "submenu": "gitlens/scm/resourceState/openOn", + "when": "gitlens:enabled && gitlens:repos:withRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.remote", + "group": "navigation" }, { - "command": "gitlens.openFileOnRemoteFrom", - "when": "gitlens:enabled && gitlens:hasRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.remote", - "group": "navigation@97", - "alt": "gitlens.copyRemoteFileUrlFrom" + "submenu": "gitlens/scm/resourceState/history", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.history", + "group": "1_a_gitlens@2" }, { "command": "gitlens.stashSaveFiles", @@ -9669,25 +12934,25 @@ "group": "1_modification@2" }, { - "command": "gitlens.copyRemoteFileUrlWithoutRange", - "when": "gitlens:enabled && gitlens:hasRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.clipboard", - "group": "2_gitlens@1" + "submenu": "gitlens/share", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.share", + "group": "7_a_gitlens_share@1" }, { - "command": "gitlens.openFileHistory", - "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.history", - "group": "4_timeline@2" + "command": "gitlens.copyPatchToClipboard", + "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index)$/ && config.gitlens.menus.scmItem.patch", + "group": "7_cutcopypaste@97" }, { - "command": "gitlens.quickOpenFileHistory", - "when": "gitlens:enabled && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.history", - "group": "4_timeline@3" + "command": "gitlens.copyRelativePathToClipboard", + "when": "gitlens:enabled && gitlens:repos:withRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.clipboard", + "group": "7_cutcopypaste@98" } ], "timeline/item/context": [ { "command": "gitlens.openCommitOnRemote", - "when": "gitlens:enabled && gitlens:hasRemotes && timelineItem =~ /git:file:commit\\b/", + "when": "false && gitlens:enabled && gitlens:repos:withRemotes && timelineItem =~ /git:file:commit\\b/", "group": "inline@99", "alt": "gitlens.copyRemoteCommitUrl" } @@ -9765,17 +13030,17 @@ }, { "command": "gitlens.pushRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", "group": "navigation@1" }, { "command": "gitlens.pullRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", "group": "navigation@2" }, { "command": "gitlens.fetchRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.commits/", "group": "navigation@3" }, { @@ -9784,13 +13049,28 @@ "group": "navigation@10" }, { - "command": "gitlens.showGraphPage", + "command": "gitlens.showGraph", "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:plus:enabled", "group": "navigation@11" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", - "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:myCommitsOnly", + "command": "gitlens.views.commitDetails.refresh", + "when": "view =~ /^gitlens\\.views\\.commitDetails/", + "group": "navigation@99" + }, + { + "command": "gitlens.views.patchDetails.refresh", + "when": "view =~ /^gitlens\\.views\\.patchDetails/", + "group": "navigation@98" + }, + { + "command": "gitlens.views.patchDetails.close", + "when": "view =~ /^gitlens\\.views\\.patchDetails/", + "group": "navigation@99" + }, + { + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:filtered", "group": "navigation@50" }, { @@ -9799,29 +13079,39 @@ "group": "navigation@99" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOn", - "when": "view =~ /^gitlens\\.views\\.commits/ && !gitlens:views:commits:myCommitsOnly", + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:filtered", "group": "3_gitlens@0" }, { - "command": "gitlens.views.commits.setMyCommitsOnlyOff", - "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:myCommitsOnly", - "group": "3_gitlens@0" + "command": "gitlens.views.commits.setCommitsFilterAuthors", + "when": "view =~ /^gitlens\\.views\\.commits/", + "group": "3_gitlens@1" + }, + { + "command": "gitlens.views.commits.setShowMergeCommitsOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && !gitlens:views:commits:hideMergeCommits", + "group": "3_gitlens@2" + }, + { + "command": "gitlens.views.commits.setShowMergeCommitsOn", + "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:views:commits:hideMergeCommits", + "group": "3_gitlens@2" }, { "command": "gitlens.views.commits.setFilesLayoutToAuto", "when": "view =~ /^gitlens\\.views\\.commits/ && config.gitlens.views.commits.files.layout == tree", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.commits.setFilesLayoutToList", "when": "view =~ /^gitlens\\.views\\.commits/ && config.gitlens.views.commits.files.layout == auto", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.commits.setFilesLayoutToTree", "when": "view =~ /^gitlens\\.views\\.commits/ && config.gitlens.views.commits.files.layout == list", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.commits.setShowAvatarsOn", @@ -9854,7 +13144,7 @@ "group": "5_gitlens@2" }, { - "command": "gitlens.showGraphPage", + "command": "gitlens.showGraph", "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:plus:enabled", "group": "8_gitlens_toggles@0" }, @@ -9869,34 +13159,44 @@ "group": "navigation@10" }, { - "command": "gitlens.views.contributors.refresh", - "when": "view =~ /^gitlens\\.views\\.contributors/", - "group": "navigation@99" + "command": "gitlens.views.contributors.refresh", + "when": "view =~ /^gitlens\\.views\\.contributors/", + "group": "navigation@99" + }, + { + "command": "gitlens.views.contributors.setShowMergeCommitsOff", + "when": "view =~ /^gitlens\\.views\\.contributors/ && !gitlens:views:contributors:hideMergeCommits", + "group": "3_gitlens@0" + }, + { + "command": "gitlens.views.contributors.setShowMergeCommitsOn", + "when": "view =~ /^gitlens\\.views\\.contributors/ && gitlens:views:contributors:hideMergeCommits", + "group": "3_gitlens@0" }, { "command": "gitlens.views.contributors.setShowAllBranchesOn", "when": "view =~ /^gitlens\\.views\\.contributors/ && !config.gitlens.views.contributors.showAllBranches", - "group": "3_gitlens@0" + "group": "3_gitlens@1" }, { "command": "gitlens.views.contributors.setShowAllBranchesOff", "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.showAllBranches", - "group": "3_gitlens@0" + "group": "3_gitlens@1" }, { "command": "gitlens.views.contributors.setFilesLayoutToAuto", "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.files.layout == tree", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.contributors.setFilesLayoutToList", "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.files.layout == auto", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.contributors.setFilesLayoutToTree", "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.files.layout == list", - "group": "3_gitlens@1" + "group": "3_gitlens@2" }, { "command": "gitlens.views.contributors.setShowAvatarsOn", @@ -9918,6 +13218,31 @@ "when": "view =~ /^gitlens\\.views\\.contributors/ && config.gitlens.views.contributors.showStatistics", "group": "5_gitlens@1" }, + { + "command": "gitlens.views.drafts.refresh", + "when": "view =~ /^gitlens\\.views\\.drafts/", + "group": "navigation@99" + }, + { + "command": "gitlens.views.drafts.create", + "when": "view =~ /^gitlens\\.views\\.drafts/ && gitlens:plus", + "group": "navigation@1" + }, + { + "command": "gitlens.views.drafts.setShowAvatarsOn", + "when": "view =~ /^gitlens\\.views\\.drafts/ && !config.gitlens.views.drafts.avatars", + "group": "5_gitlens@0" + }, + { + "command": "gitlens.views.drafts.setShowAvatarsOff", + "when": "view =~ /^gitlens\\.views\\.drafts/ && config.gitlens.views.drafts.avatars", + "group": "5_gitlens@0" + }, + { + "command": "gitlens.views.drafts.info", + "when": "view =~ /^gitlens\\.views\\.drafts/", + "group": "8_info@1" + }, { "command": "gitlens.views.fileHistory.setEditorFollowingOn", "when": "view =~ /^gitlens\\.views\\.fileHistory/ && gitlens:views:fileHistory:canPin && !gitlens:views:fileHistory:editorFollowing", @@ -9950,28 +13275,33 @@ }, { "command": "gitlens.views.fileHistory.setRenameFollowingOn", - "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && config.gitlens.advanced.fileHistoryShowAllBranches", + "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && !config.gitlens.advanced.fileHistoryFollowsRenames", "group": "3_gitlens@1" }, { - "command": "gitlens.views.fileHistory.setRenameFollowingOn", - "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && !config.gitlens.advanced.fileHistoryShowAllBranches && !config.gitlens.advanced.fileHistoryFollowsRenames", + "command": "gitlens.views.fileHistory.setRenameFollowingOff", + "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && config.gitlens.advanced.fileHistoryFollowsRenames", "group": "3_gitlens@1" }, { - "command": "gitlens.views.fileHistory.setRenameFollowingOff", - "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && !config.gitlens.advanced.fileHistoryShowAllBranches && config.gitlens.advanced.fileHistoryFollowsRenames", - "group": "3_gitlens@1" + "command": "gitlens.views.fileHistory.setShowMergeCommitsOn", + "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && !config.gitlens.advanced.fileHistoryShowMergeCommits", + "group": "3_gitlens@2" + }, + { + "command": "gitlens.views.fileHistory.setShowMergeCommitsOff", + "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && config.gitlens.advanced.fileHistoryShowMergeCommits", + "group": "3_gitlens@2" }, { "command": "gitlens.views.fileHistory.setShowAllBranchesOn", "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && !config.gitlens.advanced.fileHistoryShowAllBranches", - "group": "3_gitlens@2" + "group": "3_gitlens@3" }, { "command": "gitlens.views.fileHistory.setShowAllBranchesOff", "when": "view =~ /^gitlens\\.views\\.fileHistory/ && !gitlens:views:fileHistory:cursorFollowing && config.gitlens.advanced.fileHistoryShowAllBranches", - "group": "3_gitlens@2" + "group": "3_gitlens@3" }, { "command": "gitlens.views.fileHistory.setShowAvatarsOn", @@ -9983,11 +13313,66 @@ "when": "view =~ /^gitlens\\.views\\.fileHistory/ && config.gitlens.views.fileHistory.avatars", "group": "5_gitlens@0" }, + { + "command": "gitlens.views.graph.openInTab", + "when": "view =~ /^gitlens\\.views\\.graph\\b/", + "group": "navigation@-100" + }, + { + "command": "gitlens.views.graph.refresh", + "when": "view =~ /^gitlens\\.views\\.graph\\b/", + "group": "navigation@-99" + }, + { + "command": "gitlens.views.graphDetails.refresh", + "when": "view =~ /^gitlens\\.views\\.graphDetails/", + "group": "navigation@99" + }, { "command": "gitlens.views.home.refresh", "when": "view =~ /^gitlens\\.views\\.home/", "group": "navigation@99" }, + { + "command": "gitlens.views.account.refresh", + "when": "view =~ /^gitlens\\.views\\.account/", + "group": "navigation@99" + }, + { + "command": "gitlens.views.launchpad.refresh", + "when": "view =~ /^gitlens\\.views\\.launchpad/", + "group": "navigation@99" + }, + { + "command": "gitlens.views.launchpad.setFilesLayoutToAuto", + "when": "view =~ /^gitlens\\.views\\.launchpad/ && config.gitlens.views.launchpad.files.layout == tree", + "group": "navigation@50" + }, + { + "command": "gitlens.views.launchpad.setFilesLayoutToList", + "when": "view =~ /^gitlens\\.views\\.launchpad/ && config.gitlens.views.launchpad.files.layout == auto", + "group": "navigation@50" + }, + { + "command": "gitlens.views.launchpad.setFilesLayoutToTree", + "when": "view =~ /^gitlens\\.views\\.launchpad/ && config.gitlens.views.launchpad.files.layout == list", + "group": "navigation@50" + }, + { + "command": "gitlens.views.launchpad.setShowAvatarsOn", + "when": "view =~ /^gitlens\\.views\\.launchpad/ && !config.gitlens.views.launchpad.avatars", + "group": "5_gitlens@0" + }, + { + "command": "gitlens.views.launchpad.setShowAvatarsOff", + "when": "view =~ /^gitlens\\.views\\.launchpad/ && config.gitlens.views.launchpad.avatars", + "group": "5_gitlens@0" + }, + { + "command": "gitlens.views.launchpad.info", + "when": "view =~ /^gitlens\\.views\\.launchpad/", + "group": "8_info@1" + }, { "command": "gitlens.showLineHistoryView", "when": "!gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.fileHistory/", @@ -10028,6 +13413,41 @@ "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /gitlens\\.views\\.remotes/", "group": "navigation@1" }, + { + "command": "gitlens.views.pullRequest.refresh", + "when": "view =~ /^gitlens\\.views\\.pullRequest/", + "group": "navigation@98" + }, + { + "command": "gitlens.views.pullRequest.close", + "when": "view =~ /gitlens\\.views\\.pullRequest/", + "group": "navigation@99" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToAuto", + "when": "view =~ /^gitlens\\.views\\.pullRequest/ && config.gitlens.views.pullRequest.files.layout == tree", + "group": "navigation@50" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToList", + "when": "view =~ /^gitlens\\.views\\.pullRequest/ && config.gitlens.views.pullRequest.files.layout == auto", + "group": "navigation@50" + }, + { + "command": "gitlens.views.pullRequest.setFilesLayoutToTree", + "when": "view =~ /^gitlens\\.views\\.pullRequest/ && config.gitlens.views.pullRequest.files.layout == list", + "group": "navigation@50" + }, + { + "command": "gitlens.views.pullRequest.setShowAvatarsOn", + "when": "view =~ /^gitlens\\.views\\.pullRequest/ && !config.gitlens.views.pullRequest.avatars", + "group": "5_gitlens@0" + }, + { + "command": "gitlens.views.pullRequest.setShowAvatarsOff", + "when": "view =~ /^gitlens\\.views\\.pullRequest/ && config.gitlens.views.pullRequest.avatars", + "group": "5_gitlens@0" + }, { "command": "gitlens.views.remotes.setLayoutToList", "when": "view =~ /gitlens\\.views\\.remotes/ && config.gitlens.views.remotes.branches.layout == tree", @@ -10080,17 +13500,17 @@ }, { "command": "gitlens.pushRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", "group": "navigation@1" }, { "command": "gitlens.pullRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", "group": "navigation@2" }, { "command": "gitlens.fetchRepositories", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.repositories/", "group": "navigation@3" }, { @@ -10143,16 +13563,6 @@ "when": "view =~ /^gitlens\\.views\\.searchAndCompare\\b/", "group": "navigation@10" }, - { - "command": "gitlens.views.searchAndCompare.setKeepResultsToOn", - "when": "view =~ /^gitlens\\.views\\.searchAndCompare\\b/ && !gitlens:views:searchAndCompare:keepResults", - "group": "navigation@12" - }, - { - "command": "gitlens.views.searchAndCompare.setKeepResultsToOff", - "when": "view =~ /^gitlens\\.views\\.searchAndCompare\\b/ && gitlens:views:searchAndCompare:keepResults", - "group": "navigation@13" - }, { "command": "gitlens.views.searchAndCompare.clear", "when": "view =~ /^gitlens\\.views\\.searchAndCompare\\b/", @@ -10194,7 +13604,7 @@ "group": "navigation@10" }, { - "command": "gitlens.views.title.applyStash", + "command": "gitlens.stashApply", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.stashes/", "group": "navigation@11" }, @@ -10264,13 +13674,23 @@ "group": "5_gitlens@0" }, { - "command": "gitlens.views.timeline.openInTab", + "command": "gitlens.views.timeline.refresh", "when": "view =~ /^gitlens\\.views\\.timeline/", - "group": "navigation@98" + "group": "navigation@99" }, { - "command": "gitlens.views.timeline.refresh", - "when": "view =~ /^gitlens\\.views\\.timeline/", + "command": "gitlens.views.workspaces.info", + "when": "view =~ /^gitlens\\.views\\.workspaces/", + "group": "8_info@1" + }, + { + "command": "gitlens.views.workspaces.create", + "when": "view =~ /^gitlens\\.views\\.workspaces/ && gitlens:plus", + "group": "navigation@1" + }, + { + "command": "gitlens.views.workspaces.refresh", + "when": "view =~ /^gitlens\\.views\\.workspaces/", "group": "navigation@99" }, { @@ -10344,70 +13764,171 @@ "group": "5_gitlens@3" }, { - "command": "gitlens.showSettingsPage#branches-view", + "submenu": "gitlens/graph/configuration", + "when": "view =~ /^gitlens\\.views\\.graph\\b/", + "group": "navigation@-98" + }, + { + "command": "gitlens.showSettingsPage!branches-view", "when": "view =~ /^gitlens\\.views\\.branches/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#commits-view", + "command": "gitlens.showSettingsPage!commits-view", "when": "view =~ /^gitlens\\.views\\.commits/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#contributors-view", + "command": "gitlens.showSettingsPage!contributors-view", "when": "view =~ /^gitlens\\.views\\.contributors/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#file-history-view", + "command": "gitlens.showSettingsPage!file-history-view", "when": "view =~ /^gitlens\\.views\\.fileHistory/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#line-history-view", + "command": "gitlens.showSettingsPage!line-history-view", "when": "view =~ /^gitlens\\.views\\.lineHistory/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#remotes-view", + "command": "gitlens.showSettingsPage!remotes-view", "when": "view =~ /^gitlens\\.views\\.remotes/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#repositories-view", + "command": "gitlens.showSettingsPage!repositories-view", "when": "view =~ /^gitlens\\.views\\.repositories/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#search-compare-view", + "command": "gitlens.showSettingsPage!search-compare-view", "when": "view =~ /^gitlens\\.views\\.searchAndCompare\\b/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#stashes-view", + "command": "gitlens.showSettingsPage!stashes-view", "when": "view =~ /^gitlens\\.views\\.stashes/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#tags-view", + "command": "gitlens.showSettingsPage!tags-view", "when": "view =~ /^gitlens\\.views\\.tags/", "group": "9_gitlens@1" }, { - "command": "gitlens.showSettingsPage#worktrees-view", + "command": "gitlens.showSettingsPage!worktrees-view", "when": "view =~ /^gitlens\\.views\\.worktrees/", "group": "9_gitlens@1" } ], "view/item/context": [ + { + "command": "gitlens.plus.login", + "when": "viewItem == gitlens:message:signin", + "group": "inline@1" + }, + { + "command": "gitlens.views.draft.openOnWeb", + "when": "viewItem =~ /gitlens:draft\\b/ && gitlens:plus", + "group": "inline@99" + }, + { + "command": "gitlens.views.draft.open", + "when": "viewItem =~ /gitlens:draft\\b/ && gitlens:plus", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.views.draft.openOnWeb", + "when": "viewItem =~ /gitlens:draft\\b/ && gitlens:plus", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.views.drafts.delete", + "when": "viewItem =~ /gitlens:draft\\b(?=.*?\\b\\+mine\\b)/ && gitlens:plus", + "group": "6_gitlens_actions@1" + }, + { + "command": "gitlens.views.workspaces.convert", + "when": "viewItem =~ /gitlens:repositories\\b(?=.*?\\b\\+workspaces\\b)/ && gitlens:plus", + "group": "inline@1" + }, + { + "command": "gitlens.views.workspaces.convert", + "when": "viewItem =~ /gitlens:repositories\\b(?=.*?\\b\\+workspaces\\b)/ && gitlens:plus", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.views.workspaces.addRepos", + "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", + "group": "inline@1" + }, + { + "command": "gitlens.views.workspaces.locateAllRepos", + "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)(?!.*?\\b\\+empty\\b)/", + "group": "inline@2" + }, + { + "command": "gitlens.views.workspaces.createLocal", + "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?!.*?\\b\\+hasPath\\b)(?!.*?\\b\\+empty\\b)/", + "group": "inline@3" + }, + { + "command": "gitlens.views.workspaces.openLocalNewWindow", + "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)/", + "group": "inline@3", + "alt": "gitlens.views.workspaces.openLocal" + }, + { + "command": "gitlens.views.workspaces.addRepos", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.views.workspaces.locateAllRepos", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)(?!.*?\\b\\+empty\\b)/", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.views.workspaces.addReposFromLinked", + "when": "!listMultiSelection && viewItem =~ /gitlens:repositories\\b(?=.*?\\b\\+linked\\b)(?=.*?\\b\\+current\\b)/", + "group": "1_gitlens_actions@3" + }, + { + "command": "gitlens.views.workspaces.createLocal", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+empty\\b)/", + "group": "2_gitlens_quickopen@3" + }, + { + "command": "gitlens.views.workspaces.openLocal", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)/", + "group": "2_gitlens_quickopen@4" + }, + { + "command": "gitlens.views.workspaces.openLocalNewWindow", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)/", + "group": "2_gitlens_quickopen@5" + }, + { + "command": "gitlens.views.workspaces.changeAutoAddSetting", + "when": "!listMultiSelection && viewItem =~ /(gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)|gitlens:repositories\\b(?=.*?\\b\\+linked\\b))/", + "group": "2_gitlens_quickopen@6" + }, + { + "command": "gitlens.views.workspaces.delete", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", + "group": "6_gitlens_actions@1" + }, { "command": "gitlens.views.switchToAnotherBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b/", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b(?!.*?\\b\\+closed\\b)/", "group": "inline@10" }, { "command": "gitlens.views.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b/", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b(?!.*?\\b\\+closed\\b)/", "group": "inline@11" }, { @@ -10428,222 +13949,298 @@ }, { "command": "gitlens.views.switchToAnotherBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.openBranchesOnRemote", - "when": "viewItem =~ /gitlens:branches\\b(?=.*?\\b\\+remotes\\b)/", - "group": "2_gitlens_quickopen@1", - "alt": "gitlens.copyRemoteBranchesUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:branches\\b(?=.*?\\b\\+remotes\\b)/", + "group": "2_gitlens_quickopen@1" }, { "command": "gitlens.views.repositories.setBranchesShowBranchComparisonOn", - "when": "view =~ /^gitlens\\.views\\.repositories/ && viewItem =~ /gitlens:branches\\b/ && !config.gitlens.views.repositories.branches.showBranchComparison", + "when": "!listMultiSelection && view =~ /^gitlens\\.views\\.repositories/ && viewItem =~ /gitlens:branches\\b/ && !config.gitlens.views.repositories.branches.showBranchComparison", "group": "8_gitlens_toggles@1" }, { "command": "gitlens.views.repositories.setBranchesShowBranchComparisonOff", - "when": "view =~ /^gitlens\\.views\\.repositories/ && viewItem =~ /gitlens:branches\\b/ && config.gitlens.views.repositories.branches.showBranchComparison", + "when": "!listMultiSelection && view =~ /^gitlens\\.views\\.repositories/ && viewItem =~ /gitlens:branches\\b/ && config.gitlens.views.repositories.branches.showBranchComparison", "group": "8_gitlens_toggles@1" }, { - "command": "gitlens.views.switchToBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "command": "gitlens.views.switchToAnotherBranch", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(current|checkedout)\\b)(?!.*?\\b\\+closed\\b)/", "group": "inline@7" }, { - "command": "gitlens.views.switchToAnotherBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)/", + "command": "gitlens.views.switchToBranch", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(current|closed|checkedout|worktree)\\b)/", "group": "inline@7" }, + { + "command": "gitlens.views.openWorktree", + "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+worktree\\b)(?!.*?\\b\\+closed\\b)/", + "group": "inline@7", + "alt": "gitlens.views.openWorktreeInNewWindow" + }, { "command": "gitlens.views.publishBranch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)(?!.*?\\b\\+tracking\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(closed|remote|tracking)\\b)/", "group": "inline@8" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+(behind|closed)\\b)/", "group": "inline@8" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+behind\\b)/", - "group": "inline@8" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", + "group": "inline@8", + "alt": "gitlens.views.fetch" + }, + { + "command": "gitlens.views.fetch", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+tracking\\b)(?!.*?\\b\\+(ahead|behind|closed)\\b)/", + "group": "inline@8", + "alt": "gitlens.views.pull" }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+remote\\b)(?!.*?\\b\\+(ahead|behind|closed)\\b)/", "group": "inline@8" }, { "command": "gitlens.views.createPullRequest", - "when": "gitlens:hasRemotes && gitlens:action:createPullRequest && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "when": "gitlens:repos:withRemotes && gitlens:action:createPullRequest && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)(?!.*?\\b\\+closed\\b)/", "group": "inline@9" }, { "command": "gitlens.views.undoCommit", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+HEAD\\b)/", - "group": "inline@95" + "group": "inline@78" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", - "group": "inline@96", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", + "group": "inline@79", "alt": "gitlens.views.pushWithForce" }, { "command": "gitlens.views.pushToCommit", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", - "group": "inline@96" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", + "group": "inline@80" }, { "command": "gitlens.views.compareWithHead", - "when": "viewItem =~ /gitlens:(branch\\b(?!.*?\\b\\+current\\b)|commit\\b|stash\\b|tag\\b)/", + "when": "viewItem =~ /gitlens:(commit|stash|tag)\\b/", + "group": "inline@97", + "alt": "gitlens.views.compareWithWorking" + }, + { + "command": "gitlens.views.compareBranchWithHead", + "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", "group": "inline@97", "alt": "gitlens.views.compareWithWorking" }, + { + "command": "gitlens.views.compareBranchWithHead", + "when": "gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "group": "inline@97" + }, { "command": "gitlens.views.compareWithWorking", - "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)/", + "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)/", "group": "inline@97" }, { - "command": "gitlens.views.star", - "when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+starred\\b)/", - "group": "inline@98" + "command": "gitlens.views.openWorktree", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+worktree\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_action@1" }, { - "command": "gitlens.views.unstar", - "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+starred\\b)/", - "group": "inline@98" + "command": "gitlens.views.openWorktreeInNewWindow", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+worktree\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_action@2" }, { - "command": "gitlens.openBranchOnRemote", - "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", - "group": "inline@99", - "alt": "gitlens.copyRemoteBranchUrl" + "command": "gitlens.views.openInWorktree", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(current|closed|checkedout|worktree)\\b)/", + "group": "1_gitlens_action@3" }, { "command": "gitlens.views.switchToAnotherBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)/", - "group": "1_gitlens_actions@1" + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(current|checkedout)\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_action@1" }, { "command": "gitlens.views.switchToBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", - "group": "1_gitlens_actions@1" + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(current|closed|checkedout|worktree)\\b)/", + "group": "1_gitlens_action@1" }, { - "command": "gitlens.views.mergeBranchInto", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "command": "gitlens.views.publishBranch", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(closed|remote|tracking)\\b)/", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.views.push", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+(behind|closed)\\b)/", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.views.pull", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(behind|tracking)\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.views.fetch", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@3" }, { - "command": "gitlens.views.rebaseOntoBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "command": "gitlens.views.mergeBranchInto", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@4" }, + { + "command": "gitlens.views.rebaseOntoBranch", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_actions@5" + }, { "command": "gitlens.views.rebaseOntoUpstream", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+tracking\\b)/", - "group": "1_gitlens_actions@4" + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+tracking\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_actions@5" }, { "command": "gitlens.views.renameBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b/", - "group": "1_gitlens_actions@5" + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_actions@6" }, { "command": "gitlens.views.deleteBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", - "group": "1_gitlens_actions@6" + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(current|checkedout)\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_actions@7" + }, + { + "command": "gitlens.views.deleteBranch.multi", + "when": "listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(current|checkedout)\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_actions@7" }, { "command": "gitlens.views.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions_@7" }, { "command": "gitlens.views.createTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions_@8" }, { "command": "gitlens.views.createWorktree", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions_@9" }, { "command": "gitlens.views.createPullRequest", - "when": "gitlens:hasRemotes && gitlens:action:createPullRequest && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && gitlens:action:createPullRequest && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions_@10" }, { - "command": "gitlens.openBranchOnRemote", - "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", - "group": "2_gitlens_quickopen@1", - "alt": "gitlens.copyRemoteBranchUrl" + "command": "gitlens.views.openBranchOnRemote", + "when": "!listMultiSelection && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "group": "2_gitlens_quickopen@1" }, { - "command": "gitlens.views.openDirectoryDiffWithWorking", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|tag)\\b/", + "command": "gitlens.views.openBranchOnRemote.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "group": "2_gitlens_quickopen@1" + }, + { + "command": "gitlens.views.openChangedFileDiffsWithMergeBase", + "when": "!listMultiSelection && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", "group": "3_gitlens_explore@11" }, + { + "command": "gitlens.views.openDirectoryDiffWithWorking", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|tag)\\b/", + "group": "3_gitlens_explore@12" + }, { "command": "gitlens.views.compareWithUpstream", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+tracking\\b)/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+tracking\\b)/", "group": "4_gitlens_compare@1" }, { "command": "gitlens.views.compareWithHead", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch\\b(?!.*?\\b\\+current\\b)|commit\\b|stash\\b|tag\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(commit|stash|tag)\\b/", "group": "4_gitlens_compare@2" }, { - "command": "gitlens.views.compareWithWorking", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "command": "gitlens.views.compareBranchWithHead", + "when": "!listMultiSelection && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "group": "4_gitlens_compare@2" + }, + { + "command": "gitlens.views.compareWithMergeBase", + "when": "!listMultiSelection && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", "group": "4_gitlens_compare@3" }, { - "command": "gitlens.views.compareAncestryWithWorking", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "command": "gitlens.views.compareWithWorking", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", "group": "4_gitlens_compare@4" }, + { + "command": "gitlens.views.compareAncestryWithWorking", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "group": "4_gitlens_compare@5" + }, { "command": "gitlens.views.compareWithSelected", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/ && gitlens:views:canCompare", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/ && gitlens:views:canCompare", "group": "4_gitlens_compare@98" }, { "command": "gitlens.views.selectForCompare", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", "group": "4_gitlens_compare@99" }, { "command": "gitlens.views.compareFileWithSelected", - "when": "viewItem =~ /gitlens:file\\b/ && gitlens:views:canCompare:file", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b/ && gitlens:views:canCompare:file", "group": "4_gitlens_compare@98" }, { "command": "gitlens.views.selectFileForCompare", - "when": "viewItem =~ /gitlens:file\\b(?!.*?\\b\\+conflicted\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b(?!.*?\\b\\+conflicted\\b)/", "group": "4_gitlens_compare@99" }, { - "command": "gitlens.views.star", - "when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+starred\\b)/", + "command": "gitlens.views.star", + "when": "!listMultiSelection && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+starred\\b)/", + "group": "8_gitlens_actions@1" + }, + { + "command": "gitlens.views.star.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+starred\\b)/", "group": "8_gitlens_actions@1" }, { "command": "gitlens.views.unstar", - "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+starred\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+starred\\b)/", + "group": "8_gitlens_actions@1" + }, + { + "command": "gitlens.views.unstar.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+starred\\b)/", "group": "8_gitlens_actions@1" }, { @@ -10653,7 +14250,7 @@ }, { "command": "gitlens.views.addAuthors", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:contributors\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:contributors\\b/", "group": "1_gitlens_actions@1" }, { @@ -10673,14 +14270,24 @@ }, { "command": "gitlens.inviteToLiveShare", - "when": "gitlens:vsls && gitlens:vsls != guest && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && gitlens:vsls && gitlens:vsls != guest && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.addAuthor", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/", "group": "1_gitlens_actions@2" }, + { + "command": "gitlens.views.addAuthor.multi", + "when": "listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.views.copyAsMarkdown", + "when": "viewItem =~ /gitlens:contributor\\b/", + "group": "7_gitlens_cutcopypaste@2" + }, { "command": "gitlens.copyShaToClipboard", "when": "viewItem =~ /gitlens:commit\\b/", @@ -10688,131 +14295,185 @@ "alt": "gitlens.copyMessageToClipboard" }, { - "command": "gitlens.openCommitOnRemote", - "when": "viewItem =~ /gitlens:commit\\b/ && gitlens:hasRemotes", + "command": "gitlens.views.openCommitOnRemote", + "when": "viewItem =~ /gitlens:commit\\b/ && gitlens:repos:withRemotes", "group": "inline@99", - "alt": "gitlens.copyRemoteCommitUrl" + "alt": "gitlens.views.copyRemoteCommitUrl" }, { "command": "gitlens.views.cherryPick", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+(current|rebase)\\b)/", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.views.cherryPick.multi", + "when": "listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+(current|rebase)\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.undoCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+HEAD\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+HEAD\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.pushToCommit", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.revert", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)/", "group": "1_gitlens_actions@3" }, { "command": "gitlens.views.resetToCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+rebase\\b)/", + "group": "1_gitlens_actions@4" + }, + { + "command": "gitlens.views.resetToTip", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+rebase\\b)/", "group": "1_gitlens_actions@4" }, { "command": "gitlens.views.resetCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+rebase\\b)/", "group": "1_gitlens_actions@5" }, { "command": "gitlens.views.rebaseOntoCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+rebase\\b)/", "group": "1_gitlens_actions@6" }, { "command": "gitlens.views.switchToCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b(?!.*?\\b\\+rebase\\b)/", "group": "1_gitlens_actions@7" }, { "command": "gitlens.views.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions_1@1" }, { - "command": "gitlens.views.createTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "command": "gitlens.createPatch", + "when": "!listMultiSelection && false && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(commit|stash)\\b/", "group": "1_gitlens_actions_1@2" }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(commit|stash)\\b/", + "group": "7_gitlens_cutcopypaste@97" + }, + { + "command": "gitlens.createCloudPatch", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && viewItem =~ /gitlens:(commit|stash)\\b/", + "group": "1_gitlens_actions_1@3" + }, + { + "command": "gitlens.views.createTag", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "group": "1_gitlens_actions_1@4" + }, + { + "command": "gitlens.views.openChangedFileDiffs", + "when": "!listMultiSelection && viewItem =~ /gitlens:(compare:results(?!:)\\b(?!.*?\\b\\+filtered\\b)|commit|stash|results:files|status-branch:files|status:upstream:(ahead|behind))\\b/ && config.gitlens.views.openChangesInMultiDiffEditor && config.multiDiffEditor.experimental.enabled", + "group": "inline@90", + "alt": "gitlens.views.openChangedFileDiffsWithWorking" + }, { "submenu": "gitlens/commit/changes", - "when": "viewItem =~ /gitlens:(commit|stash|results:files)\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(compare:results(?!:)\\b(?!.*?\\b\\+filtered\\b)|commit|stash|results:files|status-branch:files|status:upstream:(ahead|behind))\\b/", "group": "2_gitlens_quickopen@1" }, { "command": "gitlens.showInDetailsView", - "when": "viewItem =~ /gitlens:(commit|stash)\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(commit|stash)\\b/", "group": "3_gitlens_explore@0" }, { "command": "gitlens.showInCommitGraph", - "when": "viewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch|commit|stash|tag)\\b(?!.*?\\b\\+closed\\b)/", "group": "3_gitlens_explore@1" }, { "command": "gitlens.revealCommitInView", - "when": "view =~ /gitlens\\.views\\.(?!commits|branches\\b)/ && viewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.(?!commits|branches\\b)/ && viewItem =~ /gitlens:commit\\b/", "group": "3_gitlens_explore@2" }, { - "command": "gitlens.openCommitOnRemote", - "when": "gitlens:hasRemotes && viewItem =~ /gitlens:commit\\b/", - "group": "3_gitlens_explore@2", - "alt": "gitlens.copyRemoteCommitUrl" + "command": "gitlens.views.openCommitOnRemote", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:commit\\b/", + "group": "3_gitlens_explore@2" + }, + { + "command": "gitlens.views.openCommitOnRemote.multi", + "when": "listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:commit\\b/", + "group": "3_gitlens_explore@2" }, { "submenu": "gitlens/share", - "when": "viewItem =~ /gitlens:(branch|commit|remote|repo-folder|repository|stash|tag|file\\b(?=.*?\\b\\+committed\\b))\\b/", - "group": "6_gitlens_share@1" + "when": "viewItem =~ /gitlens:(branch|commit|compare:(branch(?=.*?\\b\\+comparing\\b)|results(:commits(?!:)|(?!:)))|remote|repo-folder|repository|stash|status:upstream|tag|workspace|file\\b(?=.*?\\b\\+committed\\b))\\b/", + "group": "7_gitlens_a_share@1" }, { - "submenu": "gitlens/commit/copy", - "when": "viewItem =~ /gitlens:(branch|commit|remote|repo-folder|repository|stash|tag|file\\b(?=.*?\\b\\+committed\\b))\\b/", + "command": "gitlens.copyRelativePathToClipboard", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b/", "group": "7_gitlens_cutcopypaste@2" }, + { + "command": "gitlens.copyShaToClipboard", + "when": "!listMultiSelection && viewItem =~ /gitlens:(commit|stash)\\b/", + "group": "7_gitlens_cutcopypaste@3" + }, + { + "command": "gitlens.copyMessageToClipboard", + "when": "!listMultiSelection && viewItem =~ /gitlens:(commit|stash)\\b/", + "group": "7_gitlens_cutcopypaste@4" + }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "group": "7_gitlens_cutcopypaste@3" + }, + { + "command": "gitlens.copyShaToClipboard", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/ && view =~ /gitlens\\.views\\.(file|line)History/", + "group": "7_gitlens_cutcopypaste@97" + }, + { + "command": "gitlens.copyMessageToClipboard", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/ && view =~ /gitlens\\.views\\.(file|line)History/", + "group": "7_gitlens_cutcopypaste@98" + }, + { + "submenu": "gitlens/commit/copy", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch|commit|remote|repo-folder|repository|stash|tag|file\\b(?=.*?\\b\\+committed\\b))\\b/", + "group": "7_gitlens_cutcopypaste@10" + }, { "command": "gitlens.views.openFile", "when": "viewItem =~ /gitlens:(history:(file|line)|status:file)\\b/", "group": "inline@1" }, - { - "command": "gitlens.views.undoCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+HEAD\\b)/", - "group": "inline@-2" - }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", "group": "inline@-1" }, { "command": "gitlens.views.pushToCommit", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", "group": "inline@-1" }, - { - "command": "gitlens.views.openFile", - "when": "view =~ /gitlens\\.views\\.(?!(fileHistory|lineHistory)\\b)/ && viewItem =~ /gitlens:file(:results|\\b(?=.*?\\b\\+(committed|stashed)\\b))/", - "group": "inline@1", - "alt": "gitlens.views.openFileRevision" - }, { "command": "gitlens.views.openFileRevision", - "when": "view =~ /gitlens\\.views\\.(fileHistory|lineHistory)\\b/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+(committed|stashed)\\b)/", + "when": "viewItem =~ /gitlens:file(:results|\\b(?=.*?\\b\\+(committed|stashed)\\b))/", "group": "inline@1", "alt": "gitlens.views.openFile" }, @@ -10849,167 +14510,203 @@ }, { "command": "gitlens.openFileOnRemote", - "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:hasRemotes", + "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:repos:withRemotes", "group": "inline@99", "alt": "gitlens.copyRemoteFileUrlWithoutRange" }, { "command": "gitlens.views.stageFile", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+unstaged\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+unstaged\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.unstageFile", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+staged\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+staged\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.stashSaveFiles", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+(un)?staged\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+(un)?staged\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.openChanges", - "when": "viewItem =~ /gitlens:file\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b/", "group": "2_gitlens_quickopen@1" }, { "submenu": "gitlens/commit/file/changes", - "when": "viewItem =~ /gitlens:file\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b/", "group": "2_gitlens_quickopen@2" }, { - "command": "gitlens.views.openFile", - "when": "viewItem =~ /gitlens:(file|history:(file|line)|status:file)\\b/", + "command": "gitlens.showInDetailsView", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.(fileHistory|lineHistory\\b)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", "group": "2_gitlens_quickopen@3" }, + { + "command": "gitlens.views.openFile", + "when": "!listMultiSelection && viewItem =~ /gitlens:(file|history:(file|line)|status:file)\\b/", + "group": "2_gitlens_quickopen_file@3" + }, { "command": "gitlens.views.openFileRevision", - "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+(committed|stashed)\\b)|:results)/", - "group": "2_gitlens_quickopen@4" + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+(committed|stashed)\\b)|:results)/", + "group": "2_gitlens_quickopen_file@4" }, { "command": "gitlens.openFileOnRemote", - "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:hasRemotes", - "group": "2_gitlens_quickopen@5", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:repos:withRemotes", + "group": "2_gitlens_quickopen_file@5", "alt": "gitlens.copyRemoteFileUrlWithoutRange" }, - { - "command": "gitlens.openFileHistory", - "when": "view != gitlens.views.fileHistory && viewItem =~ /gitlens:file\\b/", - "group": "2_gitlens_quickopen@6" - }, { "submenu": "gitlens/commit/file/commit", - "when": "view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "when": "!listMultiSelection && view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", "group": "3_gitlens_explore@1" }, + { + "submenu": "gitlens/commit/file/history", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b/", + "group": "3_gitlens_explore@2" + }, { "command": "gitlens.views.compareWithHead", - "when": "!gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "when": "!listMultiSelection && view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", "group": "4_gitlens_compare@2" }, { "command": "gitlens.views.compareWithWorking", - "when": "!gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", "group": "4_gitlens_compare@3" }, { "command": "gitlens.views.applyChanges", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+stashed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+stashed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.restore", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+stashed\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+stashed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.applyChanges", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results\\b)/", "group": "8_gitlens_actions@1" }, { "command": "gitlens.views.restore", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results\\b)/", "group": "8_gitlens_actions@2" }, { "command": "gitlens.openFileOnRemote", - "when": "viewItem =~ /gitlens:(history:(file|line)|status:file)\\b/ && gitlens:hasRemotes", + "when": "!listMultiSelection && viewItem =~ /gitlens:(history:(file|line)|status:file)\\b/ && gitlens:repos:withRemotes", "group": "5_gitlens_open@2", "alt": "gitlens.copyRemoteFileUrlWithoutRange" }, { - "command": "gitlens.showSettingsPage#autolinks", + "command": "gitlens.connectRemoteProvider", + "when": "config.gitlens.integrations.enabled && viewItem =~ /gitlens:autolinked:items\\b/ && gitlens:repos:withHostingIntegrations && !gitlens:repos:withHostingIntegrationsConnected", + "group": "inline@98" + }, + { + "command": "gitlens.showSettingsPage!autolinks", "when": "viewItem =~ /gitlens:autolinked:items\\b/", "group": "inline@99" }, { - "command": "gitlens.showSettingsPage#autolinks", - "when": "viewItem =~ /gitlens:autolinked:items\\b/", - "group": "1_gitlens_actions@99" + "command": "gitlens.connectRemoteProvider", + "when": "config.gitlens.integrations.enabled && viewItem =~ /gitlens:autolinked:items\\b/ && gitlens:repos:withHostingIntegrations && !gitlens:repos:withHostingIntegrationsConnected", + "group": "6_gitlens_actions@1" + }, + { + "command": "gitlens.showSettingsPage!autolinks", + "when": "!listMultiSelection && viewItem =~ /gitlens:autolinked:items\\b/", + "group": "8_gitlens_actions@99" }, { - "command": "gitlens.openIssueOnRemote", - "when": "viewItem =~ /gitlens:autolinked:issue\\b/", + "command": "gitlens.views.openUrl", + "when": "viewItem =~ /gitlens:autolinked:item\\b/", "group": "inline@99", - "alt": "gitlens.copyRemoteIssueUrl" + "alt": "gitlens.views.copyUrl" }, { - "command": "gitlens.openIssueOnRemote", - "when": "viewItem =~ /gitlens:autolinked:issue\\b/", - "group": "1_gitlens_actions@99", - "alt": "gitlens.copyRemoteIssueUrl" + "command": "gitlens.views.openUrl", + "when": "!listMultiSelection && viewItem =~ /gitlens:autolinked:item\\b/", + "group": "1_gitlens_actions@99" }, { - "command": "gitlens.copyRemoteIssueUrl", - "when": "viewItem =~ /gitlens:autolinked:issue\\b/", - "group": "7_gitlens_cutcopypaste@1" + "command": "gitlens.views.openUrl.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:autolinked:item\\b/", + "group": "1_gitlens_actions@99" }, { - "command": "gitlens.openAutolinkUrl", - "when": "viewItem =~ /gitlens:autolinked:item\\b/", - "group": "inline@99", - "alt": "gitlens.copyAutolinkUrl" + "command": "gitlens.views.copyUrl", + "when": "!listMultiSelection && viewItem =~ /gitlens:autolinked:item\\b/", + "group": "7_gitlens_cutcopypaste@1" }, { - "command": "gitlens.openAutolinkUrl", - "when": "viewItem =~ /gitlens:autolinked:item\\b/", - "group": "1_gitlens_actions@99", - "alt": "gitlens.copyAutolinkUrl" + "command": "gitlens.views.copyUrl.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:autolinked:item\\b/", + "group": "7_gitlens_cutcopypaste@1" }, { - "command": "gitlens.copyAutolinkUrl", + "command": "gitlens.views.copyAsMarkdown", "when": "viewItem =~ /gitlens:autolinked:item\\b/", - "group": "7_gitlens_cutcopypaste@1" + "group": "7_gitlens_cutcopypaste@2" }, { "command": "gitlens.views.openPullRequest", "when": "gitlens:action:openPullRequest > 1 && viewItem =~ /gitlens:pullrequest\\b/", "group": "inline@1" }, + { + "command": "gitlens.views.openPullRequestChanges", + "when": "viewItem =~ /gitlens:(pullrequest\\b(?=.*?\\b\\+refs\\b)|launchpad:item\\b(?=.*?\\b\\+pr\\b))/ && config.multiDiffEditor.experimental.enabled", + "group": "inline@2" + }, + { + "command": "gitlens.views.openPullRequestComparison", + "when": "viewItem =~ /gitlens:(pullrequest\\b(?=.*?\\b\\+refs\\b)|launchpad:item\\b(?=.*?\\b\\+pr\\b))/", + "group": "inline@3" + }, { "command": "gitlens.openPullRequestOnRemote", - "when": "viewItem =~ /gitlens:pullrequest\\b/", + "when": "viewItem =~ /gitlens:(pullrequest\\b|launchpad:item\\b(?=.*?\\b\\+pr\\b))/", "group": "inline@99", "alt": "gitlens.copyRemotePullRequestUrl" }, { - "command": "gitlens.views.openPullRequest", - "when": "gitlens:action:openPullRequest > 1 && viewItem =~ /gitlens:pullrequest\\b/", + "command": "gitlens.views.openPullRequestChanges", + "when": "!listMultiSelection && viewItem =~ /gitlens:(pullrequest\\b(?=.*?\\b\\+refs\\b)|launchpad:item\\b(?=.*?\\b\\+pr\\b))/ && config.multiDiffEditor.experimental.enabled", "group": "1_gitlens_actions@1" }, + { + "command": "gitlens.views.openPullRequest", + "when": "!listMultiSelection && gitlens:action:openPullRequest > 1 && viewItem =~ /gitlens:pullrequest\\b/", + "group": "1_gitlens_actions@98" + }, { "command": "gitlens.openPullRequestOnRemote", - "when": "viewItem =~ /gitlens:pullrequest\\b/", - "group": "1_gitlens_actions@99", - "alt": "gitlens.copyRemotePullRequestUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:(pullrequest\\b|launchpad:item\\b(?=.*?\\b\\+pr\\b))/", + "group": "1_gitlens_actions@99" }, { - "command": "gitlens.copyRemotePullRequestUrl", - "when": "viewItem =~ /gitlens:pullrequest\\b/", - "group": "7_gitlens_cutcopypaste@1" + "command": "gitlens.views.openInWorktree", + "when": "!listMultiSelection && viewItem =~ /gitlens:(pullrequest\\b|launchpad:item\\b(?=.*?\\b\\+pr\\b))/", + "group": "1_gitlens_actions@100" + }, + { + "command": "gitlens.showInCommitGraph", + "when": "!listMultiSelection && viewItem =~ /gitlens:pullrequest\\b(?=.*?\\b\\+refs\\b)/", + "group": "3_gitlens_explore@1" + }, + { + "command": "gitlens.views.openPullRequestComparison", + "when": "!listMultiSelection && viewItem =~ /gitlens:(pullrequest\\b(?=.*?\\b\\+refs\\b)|launchpad:item\\b(?=.*?\\b\\+pr\\b))/", + "group": "4_gitlens_compare@1" }, { "command": "gitlens.views.addRemote", @@ -11028,7 +14725,7 @@ }, { "command": "gitlens.views.addRemote", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remotes\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remotes\\b/", "group": "1_gitlens_actions@1" }, { @@ -11054,75 +14751,124 @@ }, { "command": "gitlens.views.fetch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pruneRemote", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.openRepoOnRemote", - "when": "viewItem =~ /gitlens:remote\\b/", - "group": "5_gitlens_open@1", - "alt": "gitlens.copyRemoteRepositoryUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:remote\\b/", + "group": "5_gitlens_open@1" }, { "command": "gitlens.openBranchesOnRemote", - "when": "viewItem =~ /gitlens:remote\\b/", - "group": "5_gitlens_open@2", - "alt": "gitlens.copyRemoteBranchesUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:remote\\b/", + "group": "5_gitlens_open@2" }, { "command": "gitlens.views.removeRemote", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:remote\\b/", "group": "6_gitlens_terminal@1" }, { "command": "gitlens.views.setAsDefault", - "when": "viewItem =~ /gitlens:remote\\b(?!.*?\\b\\+default\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:remote\\b(?!.*?\\b\\+default\\b)/", "group": "8_gitlens_actions@1" }, { "command": "gitlens.views.unsetAsDefault", - "when": "viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+default\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+default\\b)/", "group": "8_gitlens_actions@1" }, { "command": "gitlens.connectRemoteProvider", - "when": "config.gitlens.integrations.enabled && viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+disconnected\\b)/", + "when": "!listMultiSelection && config.gitlens.integrations.enabled && viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+disconnected\\b)/", "group": "8_gitlens_actions@2" }, { "command": "gitlens.disconnectRemoteProvider", - "when": "config.gitlens.integrations.enabled && viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+connected\\b)/", + "when": "!listMultiSelection && config.gitlens.integrations.enabled && viewItem =~ /gitlens:remote\\b(?=.*?\\b\\+connected\\b)/", "group": "8_gitlens_actions@2" }, { "submenu": "gitlens/commit/browse", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|file\\b(?=.*?\\b\\+committed\\b)|stash|tag)\\b/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:(branch|commit|file\\b(?=.*?\\b\\+committed\\b)|stash|tag)\\b/", "group": "3_gitlens_explore@10" }, + { + "command": "gitlens.views.workspaces.repo.locate", + "when": "viewItem =~ /gitlens:workspaceMissingRepository\\b/", + "group": "inline@1" + }, + { + "command": "gitlens.views.workspaces.repo.locate", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspaceMissingRepository\\b/", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.views.workspaces.repo.remove", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspaceMissingRepository\\b/", + "group": "6_gitlens_actions@1" + }, + { + "command": "gitlens.views.workspaces.repo.openInNewWindow", + "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "group": "inline@100", + "alt": "gitlens.views.workspaces.repo.open" + }, + { + "command": "gitlens.views.workspaces.repo.open", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "group": "0_1gitlens_actions@1" + }, + { + "command": "gitlens.views.workspaces.repo.openInNewWindow", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "group": "0_1gitlens_actions@2" + }, + { + "command": "gitlens.views.workspaces.repo.addToWindow", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "group": "0_1gitlens_actions@3" + }, + { + "command": "gitlens.views.revealRepositoryInExplorer", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "group": "0_2gitlens_actions@1" + }, + { + "command": "gitlens.views.workspaces.repo.locate", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)(?!.*?\\b\\+local\\b)/", + "group": "0_2gitlens_actions@2" + }, + { + "command": "gitlens.views.workspaces.repo.remove", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)(?!.*?\\b\\+local\\b)/", + "group": "0_3gitlens_actions@1" + }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "inline@96", "alt": "gitlens.views.pushWithForce" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "inline@97" }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "inline@98" }, { "command": "gitlens.views.star", - "when": "viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+starred\\b)/", + "when": "viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+(starred|workspace)\\b)/", "group": "inline@99" }, { @@ -11132,79 +14878,98 @@ }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pushWithForce", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.openInTerminal", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", "group": "2_gitlens_quickopen@1" }, + { + "command": "gitlens.views.openInIntegratedTerminal", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "group": "2_gitlens_quickopen@2" + }, + { + "command": "gitlens.views.revealRepositoryInExplorer", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+workspace\\b)/", + "group": "2_gitlens_quickopen@3" + }, { "command": "gitlens.openRepoOnRemote", - "when": "viewItem =~ /gitlens:repository\\b/ && gitlens:hasRemotes", - "group": "2_gitlens_quickopen@2", - "alt": "gitlens.copyRemoteRepositoryUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b/ && gitlens:repos:withRemotes", + "group": "2_gitlens_quickopen@4" }, { "command": "gitlens.showCommitSearch", - "when": "viewItem =~ /gitlens:repository\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b/", "group": "3_gitlens_explore@1" }, { "command": "gitlens.stashSave", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+workspace\\b)/", "group": "1_gitlens_actions_1@1" }, { "command": "gitlens.stashApply", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+workspace\\b)/", "group": "1_gitlens_actions_1@2" }, { "command": "gitlens.views.star", - "when": "viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+starred\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+(starred|workspace)\\b)/", + "group": "8_gitlens_actions_@1" + }, + { + "command": "gitlens.views.star.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+(starred|workspace)\\b)/", "group": "8_gitlens_actions_@1" }, { "command": "gitlens.views.unstar", - "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+starred\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+starred\\b)/", + "group": "8_gitlens_actions_@1" + }, + { + "command": "gitlens.views.unstar.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+starred\\b)/", "group": "8_gitlens_actions_@1" }, { "command": "gitlens.views.closeRepository", - "when": "viewItem =~ /gitlens:repository\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+closed\\b)/", "group": "8_gitlens_actions_@2" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)/", "group": "inline@96", "alt": "gitlens.views.pushWithForce" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+behind\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+behind\\b)/", "group": "inline@97" }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?!.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)/", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b(?!.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)/", "group": "inline@98" }, { @@ -11218,173 +14983,216 @@ "group": "inline@99" }, { - "command": "gitlens.showGraphPage", + "command": "gitlens.showGraph", "when": "viewItem =~ /gitlens:repo-folder\\b/ && gitlens:plus:enabled", "group": "inline@100" }, + { + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "view =~ /^gitlens\\.views\\.commits/ && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+filtered\\b)/ && gitlens:views:commits:filtered", + "group": "inline@101" + }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pushWithForce", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.openInTerminal", - "when": "!gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "2_gitlens_quickopen@1" }, + { + "command": "gitlens.views.openInIntegratedTerminal", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "group": "2_gitlens_quickopen@2" + }, { "command": "gitlens.openRepoOnRemote", - "when": "viewItem =~ /gitlens:repo-folder\\b/ && gitlens:hasRemotes", - "group": "2_gitlens_quickopen@2", - "alt": "gitlens.copyRemoteRepositoryUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b/ && gitlens:repos:withRemotes", + "group": "2_gitlens_quickopen@3" }, { - "command": "gitlens.showGraphPage", - "when": "viewItem =~ /gitlens:repo-folder\\b/ && gitlens:plus:enabled", + "command": "gitlens.showGraph", + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b/ && gitlens:plus:enabled", "group": "3_gitlens_explore@1" }, { "command": "gitlens.showCommitSearch", - "when": "viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b/", "group": "3_gitlens_explore@2" }, { "command": "gitlens.stashSave", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions_1@1" }, { "command": "gitlens.stashApply", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:repo-folder\\b/", "group": "1_gitlens_actions_1@2" }, { "command": "gitlens.views.star", - "when": "viewItem =~ /gitlens:repo-folder\\b(?!.*?\\b\\+starred\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b(?!.*?\\b\\+starred\\b)/", + "group": "8_gitlens_actions_@1" + }, + { + "command": "gitlens.views.star.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:repo-folder\\b(?!.*?\\b\\+starred\\b)/", "group": "8_gitlens_actions_@1" }, { "command": "gitlens.views.unstar", - "when": "viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+starred\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+starred\\b)/", + "group": "8_gitlens_actions_@1" + }, + { + "command": "gitlens.views.unstar.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+starred\\b)/", "group": "8_gitlens_actions_@1" }, { "command": "gitlens.views.closeRepository", - "when": "viewItem =~ /gitlens:repo-folder\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:repo-folder\\b/", "group": "8_gitlens_actions_@2" }, + { + "command": "gitlens.views.commits.setCommitsFilterOff", + "when": "!listMultiSelection && view =~ /^gitlens\\.views\\.commits/ && viewItem =~ /gitlens:repo-folder\\b(?=.*?\\b\\+filtered\\b)/ && gitlens:views:commits:filtered", + "group": "8_gitlens_filter_@1" + }, + { + "command": "gitlens.views.commits.setCommitsFilterAuthors", + "when": "!listMultiSelection && view =~ /^gitlens\\.views\\.commits/ && viewItem =~ /gitlens:repo-folder\\b/", + "group": "8_gitlens_filter_@2" + }, { "command": "gitlens.views.publishRepository", - "when": "!gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:none/", - "group": "inline@1" + "when": "!gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:(missing|none)/", + "group": "inline@99" }, { "command": "gitlens.views.addRemote", - "when": "!gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:none/", + "when": "!gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:none/", "group": "inline@2" }, { "command": "gitlens.views.publishBranch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:none/", - "group": "inline@1" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:(missing|none)/", + "group": "inline@99" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:ahead/", - "group": "inline@1", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:ahead/", + "group": "inline@99", "alt": "gitlens.views.pushWithForce" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:behind/", - "group": "inline@1" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:behind/", + "group": "inline@99" }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:(?!none)/", - "group": "inline@2" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status(\\-branch)?:upstream:(?!(missing|none))/", + "group": "inline@98" }, { "command": "gitlens.views.createPullRequest", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:action:createPullRequest && viewItem =~ /gitlens:status:upstream:(?!none)/", - "group": "inline@3" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:action:createPullRequest && viewItem =~ /gitlens:status:upstream:same/", + "group": "inline@1" }, { - "command": "gitlens.openBranchOnRemote", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream:(?!none)/", - "group": "inline@99", + "command": "gitlens.views.openBranchOnRemote", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream:same/", + "group": "inline@97", "alt": "gitlens.copyRemoteBranchUrl" }, { "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:ahead", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:ahead", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.pushWithForce", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:ahead", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:ahead", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.pull", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:behind", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:status:upstream:behind", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.fetch", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream:(?!none)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream:(?!(missing|none))/", "group": "1_gitlens_actions@3" }, { "command": "gitlens.views.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream/", "group": "1_gitlens_secondary_actions@1" }, { "command": "gitlens.views.createTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream/", "group": "1_gitlens_secondary_actions@2" }, { "command": "gitlens.views.createPullRequest", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:action:createPullRequest && viewItem =~ /gitlens:status:upstream:(?!none)/", + "when": "!listMultiSelection && gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:action:createPullRequest && viewItem =~ /gitlens:status:upstream:(?!(missing|none))/", "group": "1_gitlens_secondary_actions@3" }, { - "command": "gitlens.openBranchOnRemote", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream:(?!none)/", - "group": "2_gitlens_quickopen@1", - "alt": "gitlens.copyRemoteBranchUrl" + "command": "gitlens.views.openBranchOnRemote", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:status:upstream:(?!(missing|none))/", + "group": "2_gitlens_quickopen_remote@1" + }, + { + "command": "gitlens.views.openInTerminal", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream\\b/", + "group": "2_gitlens_quickopen_terminal@1" + }, + { + "command": "gitlens.views.openInIntegratedTerminal", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:status:upstream\\b/", + "group": "2_gitlens_quickopen_terminal@2" + }, + { + "command": "gitlens.copyRemoteBranchUrl", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:status:upstream:(?!(missing|none))/", + "group": "7_gitlens_cutcopypaste@1" }, { "command": "gitlens.views.dismissNode", - "when": "viewItem =~ /gitlens:(compare:picker|(compare|search):results(?!:)\\b(?!.*?\\b\\+pinned\\b))\\b(?!:(commits|files))/", + "when": "viewItem =~ /gitlens:(compare:picker|(compare|search):results(?!:)\\b)\\b(?!:(commits|files))/", "group": "inline@99" }, { - "command": "gitlens.views.clearNode", + "command": "gitlens.views.clearComparison", "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", "group": "inline@99" }, { "command": "gitlens.views.editNode", "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", - "group": "inline@98" + "group": "inline@96" }, { "command": "gitlens.views.setBranchComparisonToWorking", @@ -11397,59 +15205,79 @@ "group": "inline@2" }, { - "command": "gitlens.views.editNode", - "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", + "command": "gitlens.views.setBranchComparisonToWorking", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+root\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+branch\\b)/", "group": "1_gitlens@1" }, { - "command": "gitlens.views.setBranchComparisonToWorking", - "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+root\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+branch\\b)/", - "group": "1_gitlens@2" + "command": "gitlens.views.setBranchComparisonToBranch", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+root\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+working\\b)/", + "group": "1_gitlens@1" }, { - "command": "gitlens.views.setBranchComparisonToBranch", - "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+root\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+working\\b)/", + "command": "gitlens.views.editNode", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", "group": "1_gitlens@2" }, + { + "command": "gitlens.views.clearReviewed", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", + "group": "1_gitlens@3" + }, + { + "command": "gitlens.views.clearComparison", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", + "group": "1_gitlens@4" + }, { "command": "gitlens.views.branches.setShowBranchComparisonOff", - "when": "view =~ /gitlens\\.views\\.branches\\b/ && viewItem =~ /gitlens:compare:branch\\b/", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.branches\\b/ && viewItem =~ /gitlens:compare:branch\\b/", "group": "8_gitlens_toggles@1" }, { "command": "gitlens.views.commits.setShowBranchComparisonOff", - "when": "view =~ /gitlens\\.views\\.commits\\b/ && viewItem =~ /gitlens:compare:branch\\b/", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.commits\\b/ && viewItem =~ /gitlens:compare:branch\\b/", "group": "8_gitlens_toggles@1" }, { "command": "gitlens.views.repositories.setBranchesShowBranchComparisonOff", - "when": "view =~ /gitlens\\.views\\.repositories\\b/ && viewItem =~ /gitlens:compare:branch(?!.*?\\b\\+root\\b)\\b/", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.repositories\\b/ && viewItem =~ /gitlens:compare:branch(?!.*?\\b\\+root\\b)\\b/", "group": "8_gitlens_toggles@1" }, { "command": "gitlens.views.repositories.setShowSectionOff", - "when": "view =~ /gitlens\\.views\\.repositories\\b/ && viewItem =~ /gitlens:(compare:branch(?=.*?\\b\\+root\\b)|branches|branch(?=.*?\\b\\+commits\\b)|reflog|remotes|stashes|status:upstream|tags)\\b/", + "when": "!listMultiSelection && view =~ /gitlens\\.views\\.repositories\\b/ && viewItem =~ /gitlens:(compare:branch(?=.*?\\b\\+root\\b)|branches|branch(?=.*?\\b\\+commits\\b)|reflog|remotes|stashes|status:upstream|tags)\\b/", "group": "8_gitlens_toggles@99" }, - { - "command": "gitlens.views.clearNode", - "when": "viewItem =~ /gitlens:compare:branch\\b(?=.*?\\b\\+comparing\\b)/", - "group": "9_gitlens@1" - }, { "command": "gitlens.views.searchAndCompare.swapComparison", "when": "viewItem =~ /gitlens:compare:results(?!:)\\b/", - "group": "inline@1" + "group": "inline@96" + }, + { + "submenu": "gitlens/comparison/results/files/filter/inline", + "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filterable\\b)(?!.*?\\b\\+filtered\\b)/", + "group": "inline@99" + }, + { + "submenu": "gitlens/comparison/results/files/filtered/inline", + "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filterable\\b)(?=.*?\\b\\+filtered\\b)/", + "group": "inline@99" }, { - "submenu": "gitlens/view/searchAndCompare/comparison/filter", - "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filterable\\b)(?!.*?\\b\\+filtered\\b)/", - "group": "inline@1" + "command": "gitlens.views.clearReviewed", + "when": "!listMultiSelection && viewItem =~ /gitlens:results:files\\b/", + "group": "1_gitlens@1" }, { - "submenu": "gitlens/view/searchAndCompare/comparison/filtered", - "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filterable\\b)(?=.*?\\b\\+filtered\\b)/", - "group": "inline@1" + "submenu": "gitlens/comparison/results/files/filter", + "when": "!listMultiSelection && viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filterable\\b)(?!.*?\\b\\+filtered\\b)/", + "group": "1_gitlens@2" + }, + { + "submenu": "gitlens/comparison/results/files/filtered", + "when": "!listMultiSelection && viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filterable\\b)(?=.*?\\b\\+filtered\\b)/", + "group": "1_gitlens@2" }, { "command": "gitlens.views.refreshNode", @@ -11462,34 +15290,54 @@ "group": "inline@97" }, { - "command": "gitlens.views.searchAndCompare.pin", - "when": "viewItem =~ /gitlens:(compare|search):results(?!:)\\b(?!.*?\\b\\+pinned\\b)/", - "group": "inline@98" - }, - { - "command": "gitlens.views.searchAndCompare.unpin", - "when": "viewItem =~ /gitlens:(compare|search):results(?!:)\\b(?=.*?\\b\\+pinned\\b)/", - "group": "inline@98" + "command": "gitlens.views.setResultsCommitsFilterOff", + "when": "viewItem =~ /gitlens:compare:(results(?!:)|branch)\\b(?=.*?\\b\\+filtered\\b)/", + "group": "inline@96" }, { "command": "gitlens.views.searchAndCompare.swapComparison", - "when": "viewItem =~ /gitlens:compare:results(?!:)\\b(?!.*?\\b\\+working\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:results(?!:)\\b(?!.*?\\b\\+working\\b)/", "group": "1_gitlens_actions@2" }, + { + "command": "gitlens.views.clearReviewed", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:results(?!:)/", + "group": "1_gitlens_actions@3" + }, + { + "command": "gitlens.createPatch", + "when": "!listMultiSelection && false && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:compare:results(?!:)\\b/", + "group": "1_gitlens_secondary_actions@1" + }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:compare:results(?!:)\\b/", + "group": "7_gitlens_cutcopypaste@97" + }, + { + "command": "gitlens.createCloudPatch", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && viewItem =~ /gitlens:compare:results(?!:)\\b/", + "group": "1_gitlens_secondary_actions@2" + }, + { + "command": "gitlens.openComparisonOnRemote", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:(branch\\b(?=.*?\\b\\+comparing\\b)|results(?!:))/", + "group": "2_gitlens_quickopen@1 && gitlens:repos:withRemotes" + }, { "command": "gitlens.views.openDirectoryDiff", - "when": "viewItem =~ /gitlens:compare:results(?!:)\\b/", - "group": "2_gitlens_quickopen@1" + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:results(?!:)\\b/", + "group": "2_gitlens_quickopen@2" }, { - "command": "gitlens.views.searchAndCompare.pin", - "when": "viewItem =~ /gitlens:(compare|search):results(?!:)\\b(?!.*?\\b\\+pinned\\b)/", - "group": "8_gitlens_actions@1" + "command": "gitlens.views.setResultsCommitsFilterOff", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:(results(?!:)|branch)\\b(?=.*?\\b\\+filtered\\b)/", + "group": "7_gitlens_filter@1" }, { - "command": "gitlens.views.searchAndCompare.unpin", - "when": "viewItem =~ /gitlens:(compare|search):results(?!:)\\b(?=.*?\\b\\+pinned\\b)/", - "group": "8_gitlens_actions@1" + "command": "gitlens.views.setResultsCommitsFilterAuthors", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:(results(?!:)|branch)\\b/", + "group": "7_gitlens_filter@2" }, { "command": "gitlens.views.editNode", @@ -11498,20 +15346,19 @@ }, { "command": "gitlens.views.editNode", - "when": "viewItem =~ /gitlens:search:results(?!:)\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:search:results(?!:)\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.openComparisonOnRemote", - "when": "viewItem =~ /gitlens:compare:results:commits\\b/", + "when": "viewItem =~ /gitlens:compare:results:commits\\b/ && gitlens:repos:withRemotes", "group": "inline@99", "alt": "gitlens.copyRemoteComparisonUrl" }, { "command": "gitlens.openComparisonOnRemote", - "when": "viewItem =~ /gitlens:compare:results:commits\\b/", - "group": "3_gitlens_explore@0", - "alt": "gitlens.copyRemoteComparisonUrl" + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:results:commits\\b/ && gitlens:repos:withRemotes", + "group": "3_gitlens_explore@0" }, { "command": "gitlens.stashSave", @@ -11525,34 +15372,49 @@ }, { "command": "gitlens.stashSave", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /^gitlens:(stashes|status:files)$/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /^gitlens:(stashes|status:files)$/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.stashApply", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stashes", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stashes", "group": "1_gitlens_actions@2" }, { - "command": "gitlens.stashApply", + "command": "gitlens.views.stash.apply", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", "group": "inline@1" }, { - "command": "gitlens.views.deleteStash", + "command": "gitlens.views.stash.rename", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", - "group": "inline@99" + "group": "inline@98" }, { - "command": "gitlens.stashApply", + "command": "gitlens.views.stash.delete", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", + "group": "inline@99" + }, + { + "command": "gitlens.views.stash.apply", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", "group": "1_gitlens_actions@1" }, { - "command": "gitlens.views.deleteStash", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", + "command": "gitlens.views.stash.rename", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", "group": "1_gitlens_actions@2" }, + { + "command": "gitlens.views.stash.delete", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", + "group": "1_gitlens_actions@3" + }, + { + "command": "gitlens.views.stash.delete.multi", + "when": "listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", + "group": "1_gitlens_actions@3" + }, { "command": "gitlens.views.createTag", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tags\\b/", @@ -11560,7 +15422,7 @@ }, { "command": "gitlens.views.createTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tags\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tags\\b/", "group": "1_gitlens_actions@1" }, { @@ -11570,17 +15432,22 @@ }, { "command": "gitlens.views.switchToTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tag\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tag\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.deleteTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:tag", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:tag", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.views.deleteTag.multi", + "when": "listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:tag", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tag\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:tag\\b/", "group": "1_gitlens_actions@3" }, { @@ -11590,7 +15457,7 @@ }, { "command": "gitlens.views.createWorktree", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:worktrees\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:worktrees\\b/", "group": "1_gitlens_actions@1" }, { @@ -11607,78 +15474,88 @@ }, { "command": "gitlens.views.openWorktree", - "when": "viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/", "group": "2_gitlens_quickopen@1" }, { "command": "gitlens.views.openWorktree", - "when": "viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1", + "when": "!listMultiSelection && viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1", "group": "2_gitlens_quickopen@1" }, { "command": "gitlens.views.openWorktreeInNewWindow", - "when": "viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/", + "when": "!listMultiSelection && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/", "group": "2_gitlens_quickopen@2" }, { "command": "gitlens.views.openWorktreeInNewWindow", - "when": "viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1", + "when": "!listMultiSelection && viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1", + "group": "2_gitlens_quickopen@2" + }, + { + "command": "gitlens.views.openWorktreeInNewWindow.multi", + "when": "listMultiSelection && viewItem =~ /gitlens:worktree\\b/", "group": "2_gitlens_quickopen@2" }, { "command": "gitlens.views.revealWorktreeInExplorer", - "when": "viewItem =~ /gitlens:worktree\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:worktree\\b/", "group": "3_gitlens@1" }, { "command": "gitlens.views.deleteWorktree", - "when": "!gitlens:readonly && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+(active|main)\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+(active|default)\\b)/", + "group": "6_gitlens_actions@1" + }, + { + "command": "gitlens.views.deleteWorktree.multi", + "when": "listMultiSelection && !gitlens:readonly && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+(active|default)\\b)/", "group": "6_gitlens_actions@1" }, { "command": "gitlens.views.stageDirectory", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", "group": "inline@1" }, { "command": "gitlens.views.unstageDirectory", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", "group": "inline@2" }, { "command": "gitlens.views.stageDirectory", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.unstageDirectory", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/", "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.copy", - "when": "viewItem =~ /gitlens:(?=(autolinked:issue|branch|commit|contributor|folder|history:line|pullrequest|remote|repository|repo-folder|stash|tag|worktree)\\b)/", + "when": "viewItem =~ /gitlens:(?=(autolinked:item\\b|branch|commit|contributor|file(?!.*?\\b\\+(staged|unstaged))\\b|folder|history:line|launchpad:item|pullrequest|remote|repository|repo-folder|search:results|stash|tag|workspace|worktree)\\b)/", "group": "7_gitlens_cutcopypaste@1" }, { - "command": "gitlens.views.copy", - "when": "viewItem =~ /gitlens:file(?!.*?\\b\\+(staged|unstaged))\\b/", - "group": "7_gitlens_cutcopypaste@1" + "command": "gitlens.views.dismissNode", + "when": "!listMultiSelection && viewItem =~ /gitlens:(compare:picker:ref|(compare|search):results(?!:)\\b)\\b(?!:(commits|files))/", + "group": "1_gitlens_actions@98" }, { - "command": "gitlens.views.dismissNode", - "when": "viewItem =~ /gitlens:(compare:picker:ref|(compare|search):results(?!:)\\b(?!.*?\\b\\+pinned\\b))\\b(?!:(commits|files))/", - "group": "8_gitlens_actions@98" + "command": "gitlens.views.collapseNode", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch|compare|folder|grouping|results|search|status|tag)\\b/", + "group": "9_gitlens@1" }, { "command": "gitlens.views.expandNode", - "when": "viewItem =~ /gitlens:(branch|compare|folder|results|search|status)\\b/", - "group": "9_gitlens@1" + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch|compare|folder|grouping|results|search|status|tag)\\b/", + "group": "9_gitlens@2" }, { "command": "gitlens.views.refreshNode", "when": "viewItem =~ /gitlens:(?!(file|message|date-marker)\\b)/", - "group": "9_gitlens@99" + "group": "9_gitlens_z@99" }, { "command": "gitlens.views.loadAllChildren", @@ -11687,55 +15564,90 @@ }, { "command": "gitlens.views.loadAllChildren", - "when": "viewItem =~ /gitlens:pager\\b/", + "when": "!listMultiSelection && viewItem =~ /gitlens:pager\\b/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.views.setShowRelativeDateMarkersOff", - "when": "viewItem == gitlens:date-marker && config.gitlens.views.showRelativeDateMarkers", + "when": "!listMultiSelection && viewItem == gitlens:date-marker && config.gitlens.views.showRelativeDateMarkers", "group": "1_gitlens@0" }, { "command": "gitlens.ghpr.views.openOrCreateWorktree", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive|description/", - "group": "pullrequest@2" + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && view == pr:github && viewItem =~ /pullrequest(:local)?:nonactive|description/ && config.gitlens.menus.ghpr.worktree", + "group": "2_gitlens@1" } ], "webview/context": [ + { + "command": "gitlens.graph.openWorktree", + "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+worktree\\b)/", + "group": "1_gitlens_action@1" + }, + { + "command": "gitlens.graph.openWorktreeInNewWindow", + "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+worktree\\b)/", + "group": "1_gitlens_action@2" + }, + { + "command": "gitlens.graph.openInWorktree", + "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(current|checkedout|worktree)\\b)/", + "group": "1_gitlens_action@3" + }, { "command": "gitlens.graph.switchToAnotherBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)/", - "group": "1_gitlens_actions@1" + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(current|checkedout)\\b)/", + "group": "1_gitlens_action@1" }, { "command": "gitlens.graph.switchToBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", - "group": "1_gitlens_actions@1" + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(current|checkedout|worktree)\\b)/", + "group": "1_gitlens_action@1" + }, + { + "command": "gitlens.graph.publishBranch", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+remote\\b)(?!.*?\\b\\+tracking\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.graph.push", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+ahead\\b)(?!.*?\\b\\+behind\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.graph.pull", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(behind|tracking)\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.graph.fetch", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)(?!.*?\\b\\+closed\\b)/", + "group": "1_gitlens_actions@3" }, { "command": "gitlens.graph.mergeBranchInto", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", - "group": "1_gitlens_actions@3" + "group": "1_gitlens_actions@4" }, { "command": "gitlens.graph.rebaseOntoBranch", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", - "group": "1_gitlens_actions@4" + "group": "1_gitlens_actions@5" }, { "command": "gitlens.graph.rebaseOntoUpstream", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)(?=.*?\\b\\+tracking\\b)/", - "group": "1_gitlens_actions@4" + "group": "1_gitlens_actions@5" }, { "command": "gitlens.graph.renameBranch", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b/", - "group": "1_gitlens_actions@5" + "group": "1_gitlens_actions@6" }, { "command": "gitlens.graph.deleteBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", - "group": "1_gitlens_actions@6" + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+(current|checkedout)\\b)/", + "group": "1_gitlens_actions@7" }, { "command": "gitlens.graph.createBranch", @@ -11754,7 +15666,7 @@ }, { "command": "gitlens.graph.createPullRequest", - "when": "gitlens:hasRemotes && gitlens:action:createPullRequest && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "when": "gitlens:repos:withRemotes && gitlens:action:createPullRequest && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", "group": "1_gitlens_actions_@10" }, { @@ -11779,96 +15691,139 @@ }, { "command": "gitlens.graph.openBranchOnRemote", - "when": "gitlens:hasRemotes && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", - "group": "2_gitlens_quickopen@1", - "alt": "gitlens.copyRemoteBranchUrl" + "when": "gitlens:repos:withRemotes && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "group": "2_gitlens_quickopen@1" }, { "command": "gitlens.graph.cherryPick", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?!.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?!.*?\\b\\+current\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.graph.undoCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+HEAD\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+HEAD\\b)/", "group": "1_gitlens_actions@1" }, { "command": "gitlens.graph.revert", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)/", "group": "1_gitlens_actions@3" }, { "command": "gitlens.graph.resetToCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "group": "1_gitlens_actions@4" + }, + { + "command": "gitlens.graph.resetToTip", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b/", "group": "1_gitlens_actions@4" }, { "command": "gitlens.graph.resetCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions@5" }, { "command": "gitlens.graph.rebaseOntoCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions@6" }, { "command": "gitlens.graph.switchToCommit", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions@7" }, { "command": "gitlens.graph.createBranch", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions_1@1" }, { - "command": "gitlens.graph.createTag", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "command": "gitlens.graph.createPatch", + "when": "!listMultiSelection && false && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(commit|stash)\\b/", "group": "1_gitlens_actions_1@2" }, + { + "command": "gitlens.copyPatchToClipboard", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(commit|stash)\\b/", + "group": "7_cutcopypaste@97" + }, + { + "command": "gitlens.graph.createCloudPatch", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && webviewItem =~ /gitlens:(commit|stash)\\b/", + "group": "1_gitlens_actions_1@3" + }, + { + "command": "gitlens.graph.createTag", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "group": "1_gitlens_actions_1@4" + }, + { + "submenu": "gitlens/graph/commit/changes", + "when": "!listMultiSelection && webviewItem =~ /gitlens:(commit|stash|wip)\\b/", + "group": "2_gitlens_quickopen@1" + }, { "command": "gitlens.graph.showInDetailsView", - "when": "webviewItem =~ /gitlens:(commit|stash|wip)\\b/", + "when": "!listMultiSelection && webviewItem =~ /gitlens:(commit|stash|wip)\\b/", "group": "3_gitlens_explore@0" }, { "command": "gitlens.graph.openSCM", - "when": "webviewItem == gitlens:wip", + "when": "!listMultiSelection && webviewItem == gitlens:wip", "group": "3_gitlens_explore@1" }, { "command": "gitlens.graph.openCommitOnRemote", - "when": "gitlens:hasRemotes && webviewItem =~ /gitlens:commit\\b/", - "group": "3_gitlens_explore@2", - "alt": "gitlens.copyRemoteCommitUrl" + "when": "!listMultiSelection && gitlens:repos:withRemotes && webviewItem =~ /gitlens:commit\\b/", + "group": "3_gitlens_explore@2" + }, + { + "command": "gitlens.graph.openCommitOnRemote.multi", + "when": "listMultiSelection && gitlens:repos:withRemotes && webviewItems =~ /gitlens:commit\\b/", + "group": "3_gitlens_explore@2" }, { "submenu": "gitlens/share", "when": "webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", - "group": "6_gitlens_share@1" + "group": "7_gitlens_a_share@1" }, { - "submenu": "gitlens/commit/copy", - "when": "webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "command": "gitlens.graph.copySha", + "when": "!listMultiSelection && webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", "group": "7_gitlens_cutcopypaste@2" }, { - "command": "gitlens.graph.applyStash", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:stash", + "command": "gitlens.graph.copyMessage", + "when": "!listMultiSelection && webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "group": "7_gitlens_cutcopypaste@3" + }, + { + "command": "gitlens.graph.stash.apply", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:stash", "group": "1_gitlens_actions@1" }, { - "command": "gitlens.graph.deleteStash", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:stash", + "command": "gitlens.graph.stash.rename", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:stash", "group": "1_gitlens_actions@2" }, { - "command": "gitlens.graph.saveStash", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:wip", + "command": "gitlens.graph.stash.delete", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:stash", + "group": "1_gitlens_actions@3" + }, + { + "command": "gitlens.graph.stash.save", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:wip", "group": "1_gitlens_actions@3" }, + { + "command": "gitlens.graph.copyWorkingChangesToWorktree", + "when": "!listMultiSelection && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem == gitlens:wip", + "group": "1_gitlens_actions@4" + }, { "command": "gitlens.graph.switchToTag", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:tag\\b/", @@ -11889,10 +15844,25 @@ "when": "webviewItem =~ /gitlens:tag\\b/", "group": "8_gitlens_actions@10" }, + { + "command": "gitlens.graph.openPullRequestChanges", + "when": "webviewItem =~ /gitlens:pullrequest\\b(?=.*?\\b\\+refs\\b)/ && config.multiDiffEditor.experimental.enabled", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.graph.openPullRequest", + "when": "gitlens:action:openPullRequest > 1 && webviewItem =~ /gitlens:pullrequest\\b/", + "group": "1_gitlens_actions@98" + }, { "command": "gitlens.graph.openPullRequestOnRemote", "when": "webviewItem =~ /gitlens:pullrequest\\b/", - "group": "1_gitlens_actions@1" + "group": "1_gitlens_actions@99" + }, + { + "command": "gitlens.graph.openPullRequestComparison", + "when": "webviewItem =~ /gitlens:pullrequest\\b(?=.*?\\b\\+refs\\b)/", + "group": "4_gitlens_compare@1" }, { "command": "gitlens.graph.push", @@ -11909,6 +15879,11 @@ "when": "webviewItem =~ /gitlens:upstreamStatus\\b/", "group": "1_gitlens_actions@3" }, + { + "command": "gitlens.graph.openChangedFileDiffsWithMergeBase", + "when": "webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "group": "3_gitlens_explore@11" + }, { "command": "gitlens.graph.compareWithUpstream", "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+tracking\\b)/", @@ -11916,18 +15891,28 @@ }, { "command": "gitlens.graph.compareWithHead", - "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(branch\\b(?!.*?\\b\\+current\\b)|commit\\b|stash\\b|tag\\b)/", + "when": "!listMultiSelection && webviewItem =~ /gitlens:(commit|stash|tag)\\b/", "group": "4_gitlens_compare@2" }, { - "command": "gitlens.graph.compareWithWorking", - "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "command": "gitlens.graph.compareBranchWithHead", + "when": "!listMultiSelection && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "group": "4_gitlens_compare@2" + }, + { + "command": "gitlens.graph.compareWithMergeBase", + "when": "webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", "group": "4_gitlens_compare@3" }, + { + "command": "gitlens.graph.compareWithWorking", + "when": "!listMultiSelection && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:(branch|commit|stash|tag)\\b/", + "group": "4_gitlens_compare@4" + }, { "command": "gitlens.graph.compareAncestryWithWorking", "when": "!gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", - "group": "4_gitlens_compare@4" + "group": "4_gitlens_compare@5" }, { "command": "gitlens.graph.addAuthor", @@ -11936,48 +15921,103 @@ }, { "command": "gitlens.graph.copy", - "when": "webviewItem =~ /gitlens:(branch|commit|contributor|pullrequest|stash|tag)\\b/", + "when": "webviewItem =~ /gitlens:(branch|commit|contributor|launchpad:item|pullrequest|stash|tag)\\b/", "group": "7_gitlens_cutcopypaste@1" }, + { + "submenu": "gitlens/graph/markers", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/", + "group": "0_markers@0" + }, { "command": "gitlens.graph.columnAuthorOn", - "when": "webviewItem =~ /gitlens:graph:columns\\b/ && webviewItemValue =~ /\\bauthor\\b/", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:author:hidden\\b/", "group": "1_columns@1" }, { "command": "gitlens.graph.columnAuthorOff", - "when": "webviewItem =~ /gitlens:graph:columns\\b/ && webviewItemValue =~ /^(?:(?!\\bauthor\\b).)*$/", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:author:visible\\b/", "group": "1_columns@1" }, { - "command": "gitlens.graph.columnChangesOn", - "when": "webviewItem =~ /gitlens:graph:columns\\b/ && webviewItemValue =~ /\\bchanges\\b/", + "command": "gitlens.graph.columnRefOn", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:ref:hidden\\b/", "group": "1_columns@2" }, { - "command": "gitlens.graph.columnChangesOff", - "when": "webviewItem =~ /gitlens:graph:columns\\b/ && webviewItemValue =~ /^(?:(?!\\bchanges\\b).)*$/", + "command": "gitlens.graph.columnRefOff", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:ref:visible\\b/", "group": "1_columns@2" }, { - "command": "gitlens.graph.columnDateTimeOn", - "when": "webviewItem =~ /gitlens:graph:columns\\b/ && webviewItemValue =~ /\\bdatetime\\b/", + "command": "gitlens.graph.columnChangesOn", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:changes:hidden\\b/", "group": "1_columns@3" }, { - "command": "gitlens.graph.columnDateTimeOff", - "when": "webviewItem =~ /gitlens:graph:columns\\b/ && webviewItemValue =~ /^(?:(?!\\bdatetime\\b).)*$/", + "command": "gitlens.graph.columnChangesOff", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:changes:visible\\b/", "group": "1_columns@3" }, { - "command": "gitlens.graph.columnShaOn", - "when": "webviewItem =~ /gitlens:graph:columns\\b/ && webviewItemValue =~ /\\bsha\\b/", + "command": "gitlens.graph.columnMessageOn", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:message:hidden\\b/", "group": "1_columns@4" }, { - "command": "gitlens.graph.columnShaOff", - "when": "webviewItem =~ /gitlens:graph:columns\\b/ && webviewItemValue =~ /^(?:(?!\\bsha\\b).)*$/", + "command": "gitlens.graph.columnMessageOff", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:message:visible\\b/", "group": "1_columns@4" + }, + { + "command": "gitlens.graph.columnDateTimeOn", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:datetime:hidden\\b/", + "group": "1_columns@5" + }, + { + "command": "gitlens.graph.columnDateTimeOff", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:datetime:visible\\b/", + "group": "1_columns@5" + }, + { + "command": "gitlens.graph.columnGraphOn", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:graph:hidden\\b/", + "group": "1_columns@6" + }, + { + "command": "gitlens.graph.columnGraphOff", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:graph:visible\\b/", + "group": "1_columns@6" + }, + { + "command": "gitlens.graph.columnShaOn", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:sha:hidden\\b/", + "group": "1_columns@7" + }, + { + "command": "gitlens.graph.columnShaOff", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:sha:visible\\b/", + "group": "1_columns@7" + }, + { + "command": "gitlens.graph.columnGraphDefault", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:graph:visible[^,]*\\+compact\\b/", + "group": "2_columns@1" + }, + { + "command": "gitlens.graph.columnGraphCompact", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/ && webviewItemValue =~ /\\bcolumn:graph:visible(?![^,]*\\+compact\\b)/", + "group": "2_columns@1" + }, + { + "command": "gitlens.graph.resetColumnsDefault", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/", + "group": "3_columns@1" + }, + { + "command": "gitlens.graph.resetColumnsCompact", + "when": "webviewItem =~ /gitlens:graph:(columns|settings)\\b/", + "group": "3_columns@2" } ], "gitlens/commit/browse": [ @@ -11986,132 +16026,230 @@ "group": "1_gitlens@1" }, { - "command": "gitlens.views.browseRepoAtRevisionInNewWindow", - "group": "1_gitlens@3" + "command": "gitlens.views.browseRepoAtRevisionInNewWindow", + "group": "1_gitlens@3" + }, + { + "command": "gitlens.views.browseRepoBeforeRevision", + "group": "1_gitlens@2" + }, + { + "command": "gitlens.views.browseRepoBeforeRevisionInNewWindow", + "group": "1_gitlens@4" + } + ], + "gitlens/commit/copy": [ + { + "command": "gitlens.copyShaToClipboard", + "when": "viewItem =~ /gitlens:(?!(commit|file|remote|repo-folder|repository|stash)\\b)/", + "group": "1_gitlens@2" + }, + { + "command": "gitlens.copyMessageToClipboard", + "when": "viewItem =~ /gitlens:(?!(commit|file|remote|repo-folder|repository|stash)\\b)/", + "group": "1_gitlens@3" + }, + { + "command": "gitlens.copyRemoteBranchUrl", + "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/", + "group": "1_gitlens@4" + } + ], + "gitlens/share": [ + { + "command": "gitlens.shareAsCloudPatch", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && viewItem =~ /gitlens:(commit|file\\b(?=.*?\\b\\+committed\\b)|stash|compare:results(?!:))\\b/", + "group": "1_a_gitlens@1" + }, + { + "command": "gitlens.graph.shareAsCloudPatch", + "when": "!listMultiSelection && !gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && webviewItem =~ /gitlens:(commit|stash)\\b/", + "group": "1_a_gitlens@1" + }, + { + "command": "gitlens.copyDeepLinkToCommit", + "when": "!listMultiSelection && viewItem =~ /gitlens:(commit|file\\b(?=.*?\\b\\+committed\\b))/", + "group": "1_gitlens@25" + }, + { + "command": "gitlens.graph.copyDeepLinkToCommit", + "when": "!listMultiSelection && webviewItem =~ /gitlens:commit\\b/", + "group": "1_gitlens@25" + }, + { + "command": "gitlens.copyDeepLinkToComparison", + "when": "!listMultiSelection && viewItem =~ /gitlens:compare:(branch(?=.*?\\b\\+comparing\\b)|results(?!:))\\b/", + "group": "1_gitlens@25" + }, + { + "command": "gitlens.copyDeepLinkToWorkspace", + "when": "!listMultiSelection && viewItem =~ /gitlens:workspace\\b/", + "group": "1_gitlens@25" + }, + { + "command": "gitlens.copyDeepLinkToFile", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:repos:withRemotes", + "group": "1_gitlens@26" + }, + { + "command": "gitlens.copyDeepLinkToFileAtRevision", + "when": "!listMultiSelection && viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/ && gitlens:repos:withRemotes", + "group": "1_gitlens@27" + }, + { + "command": "gitlens.copyDeepLinkToBranch", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch\\b(?=.*?\\b\\+(remote|tracking)\\b)|status:upstream(?!:none))\\b/", + "group": "1_gitlens@50" + }, + { + "command": "gitlens.graph.copyDeepLinkToBranch", + "when": "!listMultiSelection && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)/", + "group": "1_gitlens@50" + }, + { + "command": "gitlens.copyDeepLinkToRepo", + "when": "!listMultiSelection && viewItem =~ /gitlens:(branch\\b(?=.*?\\b\\+(remote|tracking)\\b)|remote|repo-folder|repository|status:upstream(?!:none))\\b/", + "group": "1_gitlens@99" }, { - "command": "gitlens.views.browseRepoBeforeRevision", - "group": "1_gitlens@2" + "command": "gitlens.copyDeepLinkToTag", + "when": "!listMultiSelection && viewItem =~ /gitlens:tag\\b/", + "group": "1_gitlens@50" }, { - "command": "gitlens.views.browseRepoBeforeRevisionInNewWindow", - "group": "1_gitlens@4" - } - ], - "gitlens/commit/copy": [ - { - "command": "gitlens.copyShaToClipboard", - "when": "viewItem =~ /gitlens:(?!(remote|repo-folder|repository|stash)\\b)/", - "group": "1_gitlens@1" + "command": "gitlens.graph.copyDeepLinkToTag", + "when": "!listMultiSelection && webviewItem =~ /gitlens:tag\\b/", + "group": "1_gitlens@50" }, { - "command": "gitlens.copyMessageToClipboard", - "when": "viewItem =~ /gitlens:(?!(remote|repo-folder|repository)\\b)/", - "group": "1_gitlens@2" + "command": "gitlens.graph.copyDeepLinkToRepo", + "when": "!listMultiSelection && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)/", + "group": "1_gitlens@99" }, { - "command": "gitlens.copyRemoteCommitUrl", - "when": "gitlens:hasRemotes && viewItem =~ /gitlens:(commit|file\\b(?=.*?\\b\\+committed\\b))/", + "command": "gitlens.copyRemoteFileUrlWithoutRange", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:(file\\b(?=.*?\\b\\+committed\\b)|history:(file|line)|status:file)\\b/", "group": "2_gitlens@1" }, { - "command": "gitlens.copyRemoteBranchUrl", - "when": "gitlens:hasRemotes && viewItem =~ /gitlens:branch/", + "command": "gitlens.copyRemoteFileUrlWithoutRange", + "when": "!listMultiSelection && gitlens:enabled && gitlens:repos:withRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/", "group": "2_gitlens@1" }, { - "command": "gitlens.copyRemoteRepositoryUrl", - "when": "gitlens:hasRemotes && viewItem =~ /gitlens:(remote|repo-folder|repository)\\b/", - "group": "2_gitlens@1" + "command": "gitlens.copyRemoteFileUrlFrom", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:(file\\b(?=.*?\\b\\+committed\\b)|history:(file|line)|status:file)\\b/", + "group": "2_gitlens@2" }, { - "command": "gitlens.copyRemoteFileUrlWithoutRange", - "when": "gitlens:hasRemotes && viewItem =~ /gitlens:(file\\b(?=.*?\\b\\+committed\\b)|history:(file|line)|status:file)\\b/", + "command": "gitlens.copyRemoteFileUrlFrom", + "when": "!listMultiSelection && gitlens:enabled && gitlens:repos:withRemotes && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/", "group": "2_gitlens@2" }, { - "command": "gitlens.graph.copySha", - "when": "webviewItem =~ /gitlens:(?!stash\\b)/", - "group": "1_gitlens@1" + "command": "gitlens.views.copyRemoteCommitUrl", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:(commit|file\\b(?=.*?\\b\\+committed\\b))/", + "group": "2_gitlens@25" }, { - "command": "gitlens.graph.copyMessage", - "when": "webviewItem", - "group": "1_gitlens@2" + "command": "gitlens.views.copyRemoteCommitUrl.multi", + "when": "listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:(commit|file\\b(?=.*?\\b\\+committed\\b))/", + "group": "2_gitlens@25" }, { "command": "gitlens.graph.copyRemoteCommitUrl", - "when": "gitlens:hasRemotes && webviewItem =~ /gitlens:commit/", - "group": "2_gitlens@1" + "when": "!listMultiSelection && gitlens:repos:withRemotes && webviewItem =~ /gitlens:commit\\b/", + "group": "2_gitlens@25" + }, + { + "command": "gitlens.graph.copyRemoteCommitUrl.multi", + "when": "listMultiSelection && gitlens:repos:withRemotes && webviewItems =~ /gitlens:commit\\b/", + "group": "2_gitlens@25" + }, + { + "command": "gitlens.copyRemoteBranchUrl", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)/", + "group": "2_gitlens@50" }, { "command": "gitlens.graph.copyRemoteBranchUrl", - "when": "gitlens:hasRemotes && webviewItem =~ /gitlens:branch/", - "group": "2_gitlens@1" - } - ], - "gitlens/share": [ + "when": "!listMultiSelection && gitlens:repos:withRemotes && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(remote|tracking)\\b)/", + "group": "2_gitlens@50" + }, { - "command": "gitlens.copyDeepLinkToBranch", - "when": "viewItem =~ /gitlens:branch\\b/", - "group": "2_gitlens@3" + "command": "gitlens.copyRemoteComparisonUrl", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:compare:(results(:commits|(?!:))|branch\\b(?=.*?\\b\\+comparing\\b))/", + "group": "2_gitlens@50" }, { - "command": "gitlens.copyDeepLinkToCommit", - "when": "viewItem =~ /gitlens:commit\\b/", - "group": "2_gitlens@3" + "command": "gitlens.copyRemoteRepositoryUrl", + "when": "!listMultiSelection && gitlens:repos:withRemotes && viewItem =~ /gitlens:(remote|repo-folder|repository)\\b/", + "group": "2_gitlens@99" + } + ], + "gitlens/commit/changes": [ + { + "command": "gitlens.views.openChangedFileDiffs", + "group": "1_gitlens@1" }, { - "command": "gitlens.copyDeepLinkToRepo", - "when": "viewItem =~ /gitlens:(remote|repo-folder|repository)\\b/", - "group": "2_gitlens@3" + "command": "gitlens.views.openChangedFileDiffsIndividually", + "when": "config.gitlens.views.openChangesInMultiDiffEditor && config.multiDiffEditor.experimental.enabled", + "group": "1_gitlens@2" }, { - "command": "gitlens.copyDeepLinkToTag", - "when": "viewItem =~ /gitlens:tag\\b/", - "group": "2_gitlens@3" + "command": "gitlens.views.openChangedFileDiffsWithWorking", + "group": "1_gitlens@3" }, { - "command": "gitlens.graph.copyDeepLinkToBranch", - "when": "webviewItem =~ /gitlens:branch\\b/", - "group": "3_gitlens@1" + "command": "gitlens.views.openChangedFileDiffsWithWorkingIndividually", + "when": "config.gitlens.views.openChangesInMultiDiffEditor && config.multiDiffEditor.experimental.enabled", + "group": "1_gitlens@4" }, { - "command": "gitlens.graph.copyDeepLinkToCommit", - "when": "webviewItem =~ /gitlens:commit\\b/", - "group": "3_gitlens@1" + "command": "gitlens.views.openChangedFiles", + "group": "2_gitlens@1" }, { - "command": "gitlens.graph.copyDeepLinkToRepo", - "when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+remote\\b)(?!.*?\\b\\+current\\b)/", - "group": "3_gitlens@2" + "command": "gitlens.views.openChangedFileRevisions", + "group": "2_gitlens@2" }, { - "command": "gitlens.graph.copyDeepLinkToTag", - "when": "webviewItem =~ /gitlens:tag\\b/", - "group": "3_gitlens@1" + "command": "gitlens.views.openOnlyChangedFiles", + "group": "2_gitlens@3" } ], - "gitlens/commit/changes": [ + "gitlens/graph/commit/changes": [ { - "command": "gitlens.views.openChangedFileDiffs", - "when": "viewItem =~ /gitlens:(commit|stash|results:files)\\b/", - "group": "2_gitlens_quickopen@1" + "command": "gitlens.graph.openChangedFileDiffs", + "group": "1_gitlens@1" }, { - "command": "gitlens.views.openChangedFileDiffsWithWorking", - "when": "viewItem =~ /gitlens:(commit|stash|results:files)\\b/", - "group": "2_gitlens_quickopen@2" + "command": "gitlens.graph.openChangedFileDiffsIndividually", + "when": "config.gitlens.views.openChangesInMultiDiffEditor && config.multiDiffEditor.experimental.enabled", + "group": "1_gitlens@2" }, { - "command": "gitlens.views.openChangedFiles", - "when": "viewItem =~ /gitlens:(commit|stash|results:files)\\b/", - "group": "2_gitlens_quickopen_1@1" + "command": "gitlens.graph.openChangedFileDiffsWithWorking", + "when": "webviewItem != gitlens:wip", + "group": "1_gitlens@3" }, { - "command": "gitlens.views.openChangedFileRevisions", - "when": "viewItem =~ /gitlens:(commit|stash|results:files)\\b/", - "group": "2_gitlens_quickopen_1@2" + "command": "gitlens.graph.openChangedFileDiffsWithWorkingIndividually", + "when": "config.gitlens.views.openChangesInMultiDiffEditor && config.multiDiffEditor.experimental.enabled", + "group": "1_gitlens@4" + }, + { + "command": "gitlens.graph.openChangedFiles", + "group": "2_gitlens@1" + }, + { + "command": "gitlens.graph.openChangedFileRevisions", + "group": "2_gitlens@2" + }, + { + "command": "gitlens.graph.openOnlyChangedFiles", + "group": "2_gitlens@3" } ], "gitlens/commit/file/commit": [ @@ -12126,8 +16264,8 @@ "group": "navigation@2" }, { - "command": "gitlens.openCommitOnRemote", - "when": "view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/ && gitlens:hasRemotes", + "command": "gitlens.views.openCommitOnRemote", + "when": "view =~ /^gitlens\\.views\\.(fileHistory|lineHistory)/ && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/ && gitlens:repos:withRemotes", "group": "navigation@3", "alt": "gitlens.copyRemoteCommitUrl" }, @@ -12137,14 +16275,19 @@ "group": "navigation@4" }, { - "command": "gitlens.views.push", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", + "command": "gitlens.views.undoCommit", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+HEAD\\b)/", "group": "1_gitlens_actions@0" }, + { + "command": "gitlens.views.push", + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?=.*?\\b\\+HEAD\\b)/", + "group": "1_gitlens_actions@1" + }, { "command": "gitlens.views.pushToCommit", - "when": "gitlens:hasRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", - "group": "1_gitlens_actions@0" + "when": "gitlens:repos:withRemotes && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)(?=.*?\\b\\+current\\b)(?=.*?\\b\\+unpublished\\b)(?!.*?\\b\\+HEAD\\b)/", + "group": "1_gitlens_actions@2" }, { "command": "gitlens.views.revert", @@ -12176,21 +16319,201 @@ "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", "group": "1_gitlens_secondary_actions@1" }, + { + "command": "gitlens.createPatch", + "when": "false && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "group": "1_gitlens_secondary_actions@2" + }, + { + "command": "gitlens.createCloudPatch", + "when": "!gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", + "group": "1_gitlens_secondary_actions@3" + }, { "command": "gitlens.views.createTag", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?=.*?\\b\\+committed\\b)/", - "group": "1_gitlens_secondary_actions@2" + "group": "1_gitlens_secondary_actions@4" } ], "gitlens/commit/file/changes": [ + { + "command": "gitlens.views.openPreviousChangesWithWorking", + "when": "viewItem =~ /gitlens:file\\b(?!.*?\\b\\+(conflicted|stashed|staged|unstaged)\\b)/", + "group": "1_gitlens@1" + }, { "command": "gitlens.views.openChangesWithWorking", "when": "viewItem =~ /gitlens:file\\b(?!.*?\\b\\+conflicted\\b)/", + "group": "1_gitlens@2" + }, + { + "command": "gitlens.diffWithRevision", + "group": "1_gitlens@3" + }, + { + "command": "gitlens.diffWithRevisionFrom", + "group": "1_gitlens@4" + }, + { + "command": "gitlens.externalDiff", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?!.*?\\b\\+conflicted\\b)/", + "group": "1_gitlens_@1" + }, + { + "command": "gitlens.views.highlightChanges", + "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+(committed|stashed)\\b)|:results)/", + "group": "2_gitlens@1" + }, + { + "command": "gitlens.views.highlightRevisionChanges", + "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/", + "group": "2_gitlens@2" + } + ], + "gitlens/commit/file/history": [ + { + "command": "gitlens.openFileHistory", + "when": "view != gitlens.views.fileHistory/", + "group": "1_gitlens@1" + }, + { + "command": "gitlens.showInTimeline", + "group": "1_gitlens@2" + }, + { + "command": "gitlens.quickOpenFileHistory", + "group": "1_gitlens_quick@1" + } + ], + "gitlens/editor/annotations": [ + { + "command": "gitlens.clearFileAnnotations", + "when": "resource in gitlens:tabs:blameable && (gitlens:window:annotated || resource in gitlens:tabs:annotated)", + "group": "1_gitlens@1" + }, + { + "command": "gitlens.toggleFileBlame", + "when": "resource in gitlens:tabs:blameable && !isInDiffEditor", + "group": "2_gitlens@1" + }, + { + "command": "gitlens.toggleFileBlameInDiffLeft", + "when": "resource in gitlens:tabs:blameable && isInDiffEditor && !isInDiffRightEditor", + "group": "2_gitlens@1" + }, + { + "command": "gitlens.toggleFileBlameInDiffRight", + "when": "resource in gitlens:tabs:blameable && isInDiffRightEditor", + "group": "2_gitlens@1" + }, + { + "command": "gitlens.toggleFileHeatmap", + "when": "resource in gitlens:tabs:blameable && !isInDiffEditor", + "group": "2_gitlens@2" + }, + { + "command": "gitlens.toggleFileHeatmapInDiffLeft", + "when": "resource in gitlens:tabs:blameable && isInDiffEditor && !isInDiffRightEditor", + "group": "2_gitlens@2" + }, + { + "command": "gitlens.toggleFileHeatmapInDiffRight", + "when": "resource in gitlens:tabs:blameable && isInDiffRightEditor", + "group": "2_gitlens@2" + }, + { + "command": "gitlens.toggleFileChanges", + "when": "resource in gitlens:tabs:blameable && !gitlens:hasVirtualFolders", + "group": "2_gitlens@3" + }, + { + "command": "gitlens.showSettingsPage!file-annotations", + "when": "resource in gitlens:tabs:blameable", + "group": "8_gitlens@1" + } + ], + "gitlens/editor/context/changes": [ + { + "command": "gitlens.diffLineWithPrevious", + "when": "editorTextFocus && resource in gitlens:tabs:tracked", + "group": "1_gitlens@1" + }, + { + "command": "gitlens.diffLineWithWorking", + "when": "editorTextFocus && resource in gitlens:tabs:tracked", + "group": "1_gitlens@2" + }, + { + "command": "gitlens.diffWithPrevious", + "group": "2_gitlens@1" + }, + { + "command": "gitlens.diffWithWorking", + "when": "resourceScheme == gitlens", + "group": "2_gitlens@2" + }, + { + "command": "gitlens.diffWithRevision", + "group": "2_gitlens@3" + }, + { + "command": "gitlens.diffWithRevisionFrom", + "group": "2_gitlens@4" + }, + { + "command": "gitlens.showQuickCommitFileDetails", + "group": "3_gitlens@1" + }, + { + "command": "gitlens.showLineCommitInView", + "group": "3_gitlens@2" + }, + { + "command": "gitlens.showCommitsInView", + "when": "editorTextFocus && editorHasSelection", + "group": "3_gitlens@2" + }, + { + "command": "gitlens.showQuickRevisionDetails", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && !isInDiffEditor", + "group": "3_gitlens_1@1" + }, + { + "command": "gitlens.showQuickRevisionDetailsInDiffLeft", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffEditor && !isInDiffRightEditor", + "group": "3_gitlens_1@1" + }, + { + "command": "gitlens.showQuickRevisionDetailsInDiffRight", + "when": "gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffRightEditor", + "group": "3_gitlens_1@1" + } + ], + "gitlens/editor/context/openOn": [ + { + "command": "gitlens.openFileOnRemote", + "group": "1_gitlens@2", + "alt": "gitlens.copyRemoteFileUrlToClipboard" + }, + { + "command": "gitlens.openFileOnRemoteFrom", + "group": "1_gitlens@3", + "alt": "gitlens.copyRemoteFileUrlFrom" + }, + { + "command": "gitlens.openCommitOnRemote", + "group": "1_gitlens_commit@1", + "alt": "gitlens.copyRemoteCommitUrl" + } + ], + "gitlens/editor/changes": [ + { + "command": "gitlens.diffWithPrevious", "group": "1_gitlens@1" }, { - "command": "gitlens.views.openPreviousChangesWithWorking", - "when": "viewItem =~ /gitlens:file\\b(?!.*?\\b\\+(conflicted|stashed|staged|unstaged)\\b)/", + "command": "gitlens.diffWithWorking", + "when": "resourceScheme == gitlens", "group": "1_gitlens@2" }, { @@ -12200,178 +16523,293 @@ { "command": "gitlens.diffWithRevisionFrom", "group": "1_gitlens@4" + } + ], + "gitlens/editor/history": [ + { + "command": "gitlens.openFileHistory", + "group": "1_gitlens@1" }, { - "command": "gitlens.externalDiff", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:file\\b(?!.*?\\b\\+conflicted\\b)/", - "group": "1_gitlens@5" + "command": "gitlens.showInTimeline", + "group": "1_gitlens@2" }, { - "command": "gitlens.views.highlightChanges", - "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+(committed|stashed)\\b)|:results)/", - "group": "2_gitlens@1" + "command": "gitlens.quickOpenFileHistory", + "group": "1_gitlens_quick@1" + } + ], + "gitlens/editor/openOn": [ + { + "command": "gitlens.openFileOnRemote", + "group": "1_gitlens@1", + "alt": "gitlens.copyRemoteFileUrlWithoutRange" }, { - "command": "gitlens.views.highlightRevisionChanges", - "when": "viewItem =~ /gitlens:file\\b((?=.*?\\b\\+committed\\b)|:results)/", - "group": "2_gitlens@2" + "command": "gitlens.openFileOnRemoteFrom", + "group": "1_gitlens@2", + "alt": "gitlens.copyRemoteFileUrlFrom" } ], - "gitlens/editor/annotations": [ + "gitlens/editor/lineNumber/context/changes": [ { - "command": "gitlens.clearFileAnnotations", - "when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus", + "command": "gitlens.diffLineWithPrevious", "group": "1_gitlens@1" }, { - "command": "gitlens.toggleFileBlame", - "when": "gitlens:activeFileStatus =~ /blameable/ && !isInDiffEditor", - "group": "2_gitlens@1" + "command": "gitlens.diffLineWithWorking", + "group": "1_gitlens@2" }, { - "command": "gitlens.toggleFileBlameInDiffLeft", - "when": "gitlens:activeFileStatus =~ /blameable/ && isInDiffEditor && !isInDiffRightEditor", - "group": "2_gitlens@1" + "command": "gitlens.showQuickCommitFileDetails", + "group": "3_gitlens@1" }, { - "command": "gitlens.toggleFileBlameInDiffRight", - "when": "gitlens:activeFileStatus =~ /blameable/ && isInDiffRightEditor", - "group": "2_gitlens@1" + "command": "gitlens.showLineCommitInView", + "group": "3_gitlens@2" + } + ], + "gitlens/editor/lineNumber/context/openOn": [ + { + "command": "gitlens.openFileOnRemote", + "group": "1_gitlens@2", + "alt": "gitlens.copyRemoteFileUrlToClipboard" }, { - "command": "gitlens.toggleFileHeatmap", - "when": "gitlens:activeFileStatus =~ /blameable/ && !isInDiffEditor", - "group": "2_gitlens@2" + "command": "gitlens.openFileOnRemoteFrom", + "group": "1_gitlens@3", + "alt": "gitlens.copyRemoteFileUrlFrom" }, { - "command": "gitlens.toggleFileHeatmapInDiffLeft", - "when": "gitlens:activeFileStatus =~ /blameable/ && isInDiffEditor && !isInDiffRightEditor", - "group": "2_gitlens@2" + "command": "gitlens.openCommitOnRemote", + "group": "1_gitlens_commit@1", + "alt": "gitlens.copyRemoteCommitUrl" + } + ], + "gitlens/editor/lineNumber/context/share": [ + { + "command": "gitlens.copyRemoteFileUrlToClipboard", + "group": "1_gitlens_remote@2" }, { - "command": "gitlens.toggleFileHeatmapInDiffRight", - "when": "gitlens:activeFileStatus =~ /blameable/ && isInDiffRightEditor", - "group": "2_gitlens@2" + "command": "gitlens.copyRemoteFileUrlFrom", + "group": "1_gitlens_remote@3" }, { - "command": "gitlens.toggleFileChanges", - "when": "gitlens:activeFileStatus =~ /blameable/ && !gitlens:hasVirtualFolders", - "group": "2_gitlens@3" - } - ], - "gitlens/editor/context/changes": [ + "command": "gitlens.copyRemoteCommitUrl", + "group": "1_gitlens_remote_commit@1" + }, { - "command": "gitlens.diffWithPrevious", + "command": "gitlens.copyDeepLinkToLines", "group": "1_gitlens@1" }, { - "command": "gitlens.diffWithWorking", - "when": "resourceScheme == gitlens", + "command": "gitlens.copyDeepLinkToFile", "group": "1_gitlens@2" }, { - "command": "gitlens.diffLineWithPrevious", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /blameable/", + "command": "gitlens.copyDeepLinkToFileAtRevision", + "when": "resource in gitlens:tabs:tracked", "group": "1_gitlens@3" + } + ], + "gitlens/explorer/changes": [ + { + "command": "gitlens.diffWithPrevious", + "when": "!explorerResourceIsFolder", + "group": "1_gitlens@1" }, { - "command": "gitlens.diffLineWithWorking", - "when": "editorTextFocus && gitlens:activeFileStatus =~ /blameable/", - "group": "1_gitlens@4" + "command": "gitlens.diffFolderWithRevision", + "when": "!gitlens:hasVirtualFolders && explorerResourceIsFolder", + "group": "1_gitlens@1" + }, + { + "command": "gitlens.diffFolderWithRevisionFrom", + "when": "!gitlens:hasVirtualFolders && explorerResourceIsFolder", + "group": "1_gitlens@2" }, { "command": "gitlens.diffWithRevision", - "group": "2_gitlens@1" + "when": "!explorerResourceIsFolder", + "group": "1_gitlens@2" }, { "command": "gitlens.diffWithRevisionFrom", - "group": "2_gitlens@2" - }, + "when": "!explorerResourceIsFolder", + "group": "1_gitlens@3" + } + ], + "gitlens/explorer/history": [ { - "command": "gitlens.showQuickCommitFileDetails", - "group": "3_gitlens@1" + "command": "gitlens.openFileHistory", + "when": "!explorerResourceIsRoot && !explorerResourceIsFolder && gitlens:enabled", + "group": "1_gitlens@1" }, { - "command": "gitlens.showQuickRevisionDetails", - "when": "gitlens:activeFileStatus =~ /revision/ && !isInDiffEditor", - "group": "3_gitlens@2" + "command": "gitlens.openFolderHistory", + "when": "explorerResourceIsFolder && gitlens:enabled", + "group": "1_gitlens@1" }, { - "command": "gitlens.showQuickRevisionDetailsInDiffLeft", - "when": "gitlens:activeFileStatus =~ /revision/ && isInDiffEditor && !isInDiffRightEditor", - "group": "3_gitlens@2" + "command": "gitlens.showInTimeline", + "when": "!explorerResourceIsRoot && !explorerResourceIsFolder && gitlens:enabled", + "group": "1_gitlens@2" }, { - "command": "gitlens.showQuickRevisionDetailsInDiffRight", - "when": "gitlens:activeFileStatus =~ /revision/ && isInDiffRightEditor", - "group": "3_gitlens@2" + "command": "gitlens.quickOpenFileHistory", + "when": "!explorerResourceIsRoot && !explorerResourceIsFolder && gitlens:enabled", + "group": "1_gitlens_quick@1" } ], - "gitlens/editor/changes": [ + "gitlens/explorer/openOn": [ { - "command": "gitlens.diffWithPrevious", - "group": "1_gitlens@1" + "command": "gitlens.openFileOnRemote", + "group": "1_gitlens@1", + "alt": "gitlens.copyRemoteFileUrlWithoutRange" }, { - "command": "gitlens.diffWithWorking", - "when": "resourceScheme == gitlens", - "group": "1_gitlens@2" + "command": "gitlens.openFileOnRemoteFrom", + "group": "1_gitlens@2", + "alt": "gitlens.copyRemoteFileUrlFrom" + } + ], + "gitlens/graph/configuration": [ + { + "command": "gitlens.graph.switchToEditorLayout", + "group": "1_gitlens@1" }, { - "command": "gitlens.diffWithRevision", - "group": "2_gitlens@1" + "command": "gitlens.graph.switchToPanelLayout", + "group": "1_gitlens@1" }, { - "command": "gitlens.diffWithRevisionFrom", - "group": "2_gitlens@2" + "command": "gitlens.showSettingsPage!commit-graph", + "group": "9_gitlens@1" } ], - "gitlens/explorer/changes": [ + "gitlens/graph/markers": [ { - "command": "gitlens.diffWithPrevious", - "group": "1_gitlens@1" + "command": "gitlens.graph.scrollMarkerLocalBranchOn", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:localBranches:disabled\\b/", + "group": "4_settings@1" }, { - "command": "gitlens.diffWithRevision", - "group": "2_gitlens@1" + "command": "gitlens.graph.scrollMarkerLocalBranchOff", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:localBranches:enabled\\b/", + "group": "4_settings@1" }, { - "command": "gitlens.diffWithRevisionFrom", - "group": "2_gitlens@2" + "command": "gitlens.graph.scrollMarkerRemoteBranchOn", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:remoteBranches:disabled\\b/", + "group": "4_settings@2" + }, + { + "command": "gitlens.graph.scrollMarkerRemoteBranchOff", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:remoteBranches:enabled\\b/", + "group": "4_settings@2" + }, + { + "command": "gitlens.graph.scrollMarkerPullRequestOn", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:pullRequests:disabled\\b/", + "group": "4_settings@3" + }, + { + "command": "gitlens.graph.scrollMarkerPullRequestOff", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:pullRequests:enabled\\b/", + "group": "4_settings@3" + }, + { + "command": "gitlens.graph.scrollMarkerStashOn", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:stashes:disabled\\b/", + "group": "4_settings@4" + }, + { + "command": "gitlens.graph.scrollMarkerStashOff", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:stashes:enabled\\b/", + "group": "4_settings@4" + }, + { + "command": "gitlens.graph.scrollMarkerTagOn", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:tags:disabled\\b/", + "group": "4_settings@5" + }, + { + "command": "gitlens.graph.scrollMarkerTagOff", + "when": "webviewItem =~ /gitlens:graph:settings\\b/ && webviewItemValue =~ /\\bscrollMarker:tags:enabled\\b/", + "group": "4_settings@5" } ], "gitlens/scm/resourceGroup/changes": [ { "command": "gitlens.externalDiffAll", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", - "group": "1_gitlens@1" + "group": "2_gitlens@1" }, { "command": "gitlens.diffDirectoryWithHead", "when": "!gitlens:hasVirtualFolders", - "group": "1_gitlens@2" + "group": "2_gitlens@2" }, { "command": "gitlens.diffDirectory", "when": "!gitlens:hasVirtualFolders", - "group": "1_gitlens@3" + "group": "2_gitlens@3" } ], - "gitlens/scm/resourceState/changes": [ + "gitlens/scm/resourceFolder/changes": [ { - "command": "gitlens.externalDiff", - "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.compare", - "group": "navigation" + "command": "gitlens.diffFolderWithRevision", + "when": "!gitlens:hasVirtualFolders", + "group": "1_gitlens@1" }, + { + "command": "gitlens.diffFolderWithRevisionFrom", + "when": "!gitlens:hasVirtualFolders", + "group": "1_gitlens@2" + } + ], + "gitlens/scm/resourceState/changes": [ { "command": "gitlens.diffWithRevision", - "when": "!gitlens:hasVirtualFolders && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.compare", + "when": "!gitlens:hasVirtualFolders", "group": "1_gitlens@1" }, { "command": "gitlens.diffWithRevisionFrom", - "when": "!gitlens:hasVirtualFolders && scmProvider == git && scmResourceGroup =~ /^(workingTree|index|merge)$/ && config.gitlens.menus.scmItem.compare", + "when": "!gitlens:hasVirtualFolders", + "group": "1_gitlens@2" + }, + { + "command": "gitlens.externalDiff", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders", + "group": "2_gitlens@1" + } + ], + "gitlens/scm/resourceState/history": [ + { + "command": "gitlens.openFileHistory", + "group": "1_gitlens@1" + }, + { + "command": "gitlens.showInTimeline", "group": "1_gitlens@2" + }, + { + "command": "gitlens.quickOpenFileHistory", + "group": "1_gitlens_quick@1" + } + ], + "gitlens/scm/resourceState/openOn": [ + { + "command": "gitlens.openFileOnRemote", + "group": "1_gitlens@1", + "alt": "gitlens.copyRemoteFileUrlWithoutRange" + }, + { + "command": "gitlens.openFileOnRemoteFrom", + "group": "1_gitlens@2", + "alt": "gitlens.copyRemoteFileUrlFrom" } ], "gitlens/view/repositories/sections": [ @@ -12474,11 +16912,45 @@ }, { "command": "gitlens.views.searchAndCompare.selectForCompare", - "when": "!gitlens:hasVirtualFolders && view =~ /^gitlens\\.views\\.searchAndCompare\\b/", + "when": "view =~ /^gitlens\\.views\\.searchAndCompare\\b/", "group": "navigation@11" } ], - "gitlens/view/searchAndCompare/comparison/filter": [ + "gitlens/comparison/results/files/filter": [ + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOff", + "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filtered\\b)/", + "group": "navigation@1" + }, + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOnLeft", + "when1": "viewItem =~ /gitlens:results:files\\b(?!.*?\\b\\+filtered~left\\b)/", + "group": "navigation_1@1" + }, + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOnRight", + "when1": "viewItem =~ /gitlens:results:files\\b(?!.*?\\b\\+filtered~right\\b)/", + "group": "navigation_1@2" + } + ], + "gitlens/comparison/results/files/filter/inline": [ + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOff", + "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filtered\\b)/", + "group": "navigation@1" + }, + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOnLeft", + "when1": "viewItem =~ /gitlens:results:files\\b(?!.*?\\b\\+filtered~left\\b)/", + "group": "navigation_1@1" + }, + { + "command": "gitlens.views.searchAndCompare.setFilesFilterOnRight", + "when1": "viewItem =~ /gitlens:results:files\\b(?!.*?\\b\\+filtered~right\\b)/", + "group": "navigation_1@2" + } + ], + "gitlens/comparison/results/files/filtered": [ { "command": "gitlens.views.searchAndCompare.setFilesFilterOff", "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filtered\\b)/", @@ -12495,7 +16967,7 @@ "group": "navigation_1@2" } ], - "gitlens/view/searchAndCompare/comparison/filtered": [ + "gitlens/comparison/results/files/filtered/inline": [ { "command": "gitlens.views.searchAndCompare.setFilesFilterOff", "when": "viewItem =~ /gitlens:results:files\\b(?=.*?\\b\\+filtered\\b)/", @@ -12515,56 +16987,110 @@ }, "submenus": [ { - "id": "gitlens/commit/browse", - "label": "Browse" + "id": "gitlens/commit/browse", + "label": "Browse" + }, + { + "id": "gitlens/commit/copy", + "label": "Copy As" + }, + { + "id": "gitlens/commit/changes", + "label": "Open Changes" + }, + { + "id": "gitlens/graph/commit/changes", + "label": "Open Changes" + }, + { + "id": "gitlens/commit/file/commit", + "label": "Commit" + }, + { + "id": "gitlens/commit/file/changes", + "label": "Open Changes with" + }, + { + "id": "gitlens/commit/file/history", + "label": "File History" + }, + { + "id": "gitlens/editor/annotations", + "label": "File Annotations", + "icon": "$(gitlens-gitlens)" + }, + { + "id": "gitlens/editor/context/changes", + "label": "Open Changes" + }, + { + "id": "gitlens/editor/context/openOn", + "label": "Open on Remote (Web)" + }, + { + "id": "gitlens/editor/changes", + "label": "Open Changes" }, { - "id": "gitlens/commit/copy", - "label": "Copy As" + "id": "gitlens/editor/history", + "label": "File History" }, { - "id": "gitlens/commit/changes", + "id": "gitlens/editor/openOn", + "label": "Open on Remote (Web)" + }, + { + "id": "gitlens/editor/lineNumber/context/changes", "label": "Open Changes" }, { - "id": "gitlens/commit/file/copy", - "label": "Copy As" + "id": "gitlens/editor/lineNumber/context/openOn", + "label": "Open on Remote (Web)" }, { - "id": "gitlens/commit/file/commit", - "label": "Commit" + "id": "gitlens/editor/lineNumber/context/share", + "label": "Share" }, { - "id": "gitlens/commit/file/changes", + "id": "gitlens/explorer/changes", "label": "Open Changes" }, { - "id": "gitlens/editor/annotations", - "label": "File Annotations", - "icon": { - "dark": "images/dark/icon-git.svg", - "light": "images/light/icon-git.svg" - } + "id": "gitlens/explorer/history", + "label": "File History" }, { - "id": "gitlens/editor/context/changes", - "label": "Commit Changes" + "id": "gitlens/explorer/openOn", + "label": "Open on Remote (Web)" }, { - "id": "gitlens/editor/changes", - "label": "Commit Changes" + "id": "gitlens/graph/configuration", + "label": "Commit Graph Settings", + "icon": "$(gear)" }, { - "id": "gitlens/explorer/changes", - "label": "Commit Changes" + "id": "gitlens/graph/markers", + "label": "Scroll Markers" }, { "id": "gitlens/scm/resourceGroup/changes", "label": "Open Changes" }, + { + "id": "gitlens/scm/resourceFolder/changes", + "label": "Open Changes with" + }, { "id": "gitlens/scm/resourceState/changes", - "label": "Open Changes" + "label": "Open Changes with" + }, + { + "id": "gitlens/scm/resourceState/history", + "label": "File History" + }, + { + "id": "gitlens/scm/resourceState/openOn", + "label": "Open on Remote (Web)" }, { "id": "gitlens/share", @@ -12580,13 +17106,23 @@ "icon": "$(add)" }, { - "id": "gitlens/view/searchAndCompare/comparison/filter", - "label": "Filter", + "id": "gitlens/comparison/results/files/filter", + "label": "Filter Files", + "icon": "$(filter)" + }, + { + "id": "gitlens/comparison/results/files/filter/inline", + "label": "Filter Files", "icon": "$(filter)" }, { - "id": "gitlens/view/searchAndCompare/comparison/filtered", - "label": "Filter", + "id": "gitlens/comparison/results/files/filtered", + "label": "Filter Files", + "icon": "$(filter-filled)" + }, + { + "id": "gitlens/comparison/results/files/filtered/inline", + "label": "Filter Files", "icon": "$(filter-filled)" } ], @@ -12633,10 +17169,20 @@ "key": "alt+.", "when": "gitlens:key:." }, + { + "command": "gitlens.key.alt+enter", + "key": "alt+enter", + "when": "gitlens:key:alt+enter" + }, + { + "command": "gitlens.key.ctrl+enter", + "key": "ctrl+enter", + "when": "gitlens:key:ctrl+enter" + }, { "command": "gitlens.key.escape", "key": "escape", - "when": "gitlens:key:escape && editorTextFocus && !findWidgetVisible && !renameInputVisible && !suggestWidgetVisible && !isInEmbeddedEditor" + "when": "gitlens:key:escape && editorTextFocus && !findWidgetVisible && !quickFixWidgetVisible && !renameInputVisible && !suggestWidgetVisible && !referenceSearchVisible && !codeActionMenuVisible && !parameterHintsVisible && !isInEmbeddedEditor" }, { "command": "gitlens.gitCommands", @@ -12652,13 +17198,13 @@ { "command": "gitlens.toggleFileBlame", "key": "alt+b", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /blameable/" + "when": "config.gitlens.keymap == alternate && editorTextFocus && resource in gitlens:tabs:blameable" }, { "command": "gitlens.toggleFileBlame", "key": "ctrl+shift+g b", "mac": "cmd+alt+g b", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /blameable/" + "when": "config.gitlens.keymap == chorded && editorTextFocus && resource in gitlens:tabs:blameable" }, { "command": "gitlens.toggleCodeLens", @@ -12718,90 +17264,90 @@ { "command": "gitlens.diffWithPrevious", "key": "alt+,", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /tracked/ && !isInDiffEditor" + "when": "config.gitlens.keymap == alternate && editorTextFocus && resource in gitlens:tabs:tracked && !isInDiffEditor" }, { "command": "gitlens.diffWithPrevious", "key": "ctrl+shift+g ,", "mac": "cmd+alt+g ,", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /tracked/ && !isInDiffEditor" + "when": "config.gitlens.keymap == chorded && editorTextFocus && resource in gitlens:tabs:tracked && !isInDiffEditor" }, { "command": "gitlens.diffWithPreviousInDiffLeft", "key": "alt+,", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /tracked/ && isInDiffEditor && !isInDiffRightEditor" + "when": "config.gitlens.keymap == alternate && editorTextFocus && resource in gitlens:tabs:tracked && isInDiffEditor && !isInDiffRightEditor" }, { "command": "gitlens.diffWithPreviousInDiffLeft", "key": "ctrl+shift+g ,", "mac": "cmd+alt+g ,", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /tracked/ && isInDiffEditor && !isInDiffRightEditor" + "when": "config.gitlens.keymap == chorded && editorTextFocus && resource in gitlens:tabs:tracked && isInDiffEditor && !isInDiffRightEditor" }, { "command": "gitlens.diffWithPreviousInDiffRight", "key": "alt+,", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /tracked/ && isInDiffRightEditor" + "when": "config.gitlens.keymap == alternate && editorTextFocus && resource in gitlens:tabs:tracked && isInDiffRightEditor" }, { "command": "gitlens.diffWithPreviousInDiffRight", "key": "ctrl+shift+g ,", "mac": "cmd+alt+g ,", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /tracked/ && isInDiffRightEditor" + "when": "config.gitlens.keymap == chorded && editorTextFocus && resource in gitlens:tabs:tracked && isInDiffRightEditor" }, { "command": "gitlens.diffWithNext", "key": "alt+.", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && !isInDiffEditor" + "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && !isInDiffEditor" }, { "command": "gitlens.diffWithNext", "key": "ctrl+shift+g .", "mac": "cmd+alt+g .", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && !isInDiffEditor" + "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && !isInDiffEditor" }, { "command": "gitlens.diffWithNextInDiffLeft", "key": "alt+.", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && isInDiffEditor && !isInDiffRightEditor" + "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffEditor && !isInDiffRightEditor" }, { "command": "gitlens.diffWithNextInDiffLeft", "key": "ctrl+shift+g .", "mac": "cmd+alt+g .", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && isInDiffEditor && !isInDiffRightEditor" + "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffEditor && !isInDiffRightEditor" }, { "command": "gitlens.diffWithNextInDiffRight", "key": "alt+.", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && isInDiffRightEditor" + "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffRightEditor" }, { "command": "gitlens.diffWithNextInDiffRight", "key": "ctrl+shift+g .", "mac": "cmd+alt+g .", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /revision/ && isInDiffRightEditor" + "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/ && isInDiffRightEditor" }, { "command": "gitlens.diffWithWorking", "key": "shift+alt+.", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /revision/" + "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.diffWithWorking", "key": "ctrl+shift+g shift+.", "mac": "cmd+alt+g shift+.", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /revision/" + "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:enabled && resourceScheme =~ /^(gitlens|git|pr)$/" }, { "command": "gitlens.diffLineWithPrevious", "key": "shift+alt+,", - "when": "config.gitlens.keymap == alternate && editorTextFocus && gitlens:activeFileStatus =~ /tracked/" + "when": "config.gitlens.keymap == alternate && editorTextFocus && resource in gitlens:tabs:tracked" }, { "command": "gitlens.diffLineWithPrevious", "key": "ctrl+shift+g shift+,", "mac": "cmd+alt+g shift+,", - "when": "config.gitlens.keymap == chorded && editorTextFocus && gitlens:activeFileStatus =~ /tracked/" + "when": "config.gitlens.keymap == chorded && editorTextFocus && resource in gitlens:tabs:tracked" }, { "command": "workbench.view.scm", @@ -12827,6 +17373,12 @@ "mac": "cmd+c", "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.contributors/" }, + { + "command": "gitlens.views.drafts.copy", + "key": "ctrl+c", + "mac": "cmd+c", + "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.drafts/" + }, { "command": "gitlens.views.fileHistory.copy", "key": "ctrl+c", @@ -12839,6 +17391,12 @@ "mac": "cmd+c", "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.lineHistory/" }, + { + "command": "gitlens.views.pullRequest.copy", + "key": "ctrl+c", + "mac": "cmd+c", + "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.pullRequest/" + }, { "command": "gitlens.views.remotes.copy", "key": "ctrl+c", @@ -12874,6 +17432,12 @@ "key": "ctrl+c", "mac": "cmd+c", "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.worktrees/" + }, + { + "command": "gitlens.views.workspaces.copy", + "key": "ctrl+c", + "mac": "cmd+c", + "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.workspaces/" } ], "customEditors": [ @@ -12893,9 +17457,9 @@ "authority": "*", "formatting": { "label": "${path} (${query.ref})", - "separator": "/", - "workspaceSuffix": "GitLens", - "stripPathStartingSeparator": true + "normalizeDriveLetter": true, + "tildify": true, + "workspaceSuffix": "GitLens" } } ], @@ -12905,25 +17469,35 @@ "id": "gitlens", "title": "GitLens", "icon": "$(gitlens-gitlens)" + }, + { + "id": "gitlensInspect", + "title": "GitLens Inspect", + "icon": "$(gitlens-gitlens-inspect)" + }, + { + "id": "gitlensPatch", + "title": "GitLens Patch", + "icon": "$(gitlens-cloud-patch)" } ], "panel": [ { "id": "gitlensPanel", "title": "GitLens", - "icon": "$(gitlens-gitlens)" + "icon": "$(gitlens-graph)" } ] }, "viewsWelcome": [ { "view": "gitlens.views.searchAndCompare", - "contents": "Search for commits by [message](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22pattern%22%3A%22message%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), [author](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22pattern%22%3A%22author%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), [SHA](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22pattern%22%3A%22commit%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), [file](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22pattern%22%3A%22file%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), or [changes](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22pattern%22%3A%22change%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D)\n\n[Search Commits...](command:gitlens.views.searchAndCompare.searchCommits)", + "contents": "Search for commits by [message](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22query%22%3A%22message%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), [author](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22query%22%3A%22author%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), [SHA](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22query%22%3A%22commit%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), [file](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22query%22%3A%22file%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), or [changes](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22query%22%3A%22change%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D)\n\n[Search Commits...](command:gitlens.views.searchAndCompare.searchCommits)", "when": "!gitlens:hasVirtualFolders" }, { "view": "gitlens.views.searchAndCompare", - "contents": "Search for commits by [message](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22pattern%22%3A%22message%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), [author](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22pattern%22%3A%22author%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), or [SHA](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22pattern%22%3A%22commit%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D)\n\n[Search Commits...](command:gitlens.views.searchAndCompare.searchCommits)", + "contents": "Search for commits by [message](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22query%22%3A%22message%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), [author](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22query%22%3A%22author%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D), or [SHA](command:gitlens.views.searchAndCompare.searchCommits?%7B%22search%22%3A%7B%22query%22%3A%22commit%3A%22%7D%2C%22prefillOnly%22%3Atrue%7D)\n\n[Search Commits...](command:gitlens.views.searchAndCompare.searchCommits)", "when": "gitlens:hasVirtualFolders" }, { @@ -12931,9 +17505,55 @@ "contents": "Compare a with another \n\n[Compare References...](command:gitlens.views.searchAndCompare.selectForCompare)", "when": "!gitlens:hasVirtualFolders" }, + { + "view": "gitlens.views.drafts", + "contents": "Cloud Patches ᴘʀᴇᴠÉĒᴇᴡ — easily and securely share code with your teammates or other developers, accessible from anywhere, streamlining your workflow with better collaboration." + }, + { + "view": "gitlens.views.drafts", + "contents": "[Create Cloud Patch](command:gitlens.views.drafts.create)", + "when": "gitlens:plus" + }, + { + "view": "gitlens.views.drafts", + "contents": "[Start Pro Trial](command:gitlens.plus.signUp?%7B%22source%22%3A%22cloud-patches%22%7D)\n\nStart your free 7-day Pro trial to try Cloud Patches and other Pro features, or [sign in](command:gitlens.plus.login?%7B%22source%22%3A%22cloud-patches%22%7D).", + "when": "!gitlens:plus" + }, + { + "view": "gitlens.views.drafts", + "contents": "Preview feature â˜ī¸ — requires an account and may require a paid plan in the future." + }, + { + "view": "gitlens.views.launchpad", + "contents": "[Launchpad](command:gitlens.views.launchpad.info \"Learn about Launchpad\") — organizes your pull requests into actionable groups to help you focus and keep your team unblocked.", + "when": "config.gitlens.views.launchpad.enabled" + }, + { + "view": "gitlens.views.launchpad", + "contents": "[Connect an Integration...](command:gitlens.showLaunchpad?%7B%22source%22%3A%22launchpad-view%22%7D)\n\nAllows Launchpad to organize your pull requests into actionable groups and keep your team unblocked.", + "when": "config.gitlens.views.launchpad.enabled && gitlens:launchpad:connect" + }, + { + "view": "gitlens.views.workspaces", + "contents": "Workspaces ᴘʀᴇᴠÉĒᴇᴡ — group and manage multiple repositories together, accessible from anywhere, streamlining your workflow.\n\nCreate workspaces just for yourself or share (coming soon in GitLens) them with your team for faster onboarding and better collaboration." + }, + { + "view": "gitlens.views.workspaces", + "contents": "[Create Cloud Workspace](command:gitlens.views.workspaces.create)", + "when": "gitlens:plus" + }, + { + "view": "gitlens.views.workspaces", + "contents": "[Start Pro Trial](command:gitlens.plus.signUp?%7B%22source%22%3A%22workspaces%22%7D)\n\nStart your free 7-day Pro trial to try GitKraken (GK) Workspaces and other Pro features, or [sign in](command:gitlens.plus.login?%7B%22source%22%3A%22workspaces%22%7D).", + "when": "!gitlens:plus" + }, + { + "view": "gitlens.views.workspaces", + "contents": "Preview feature â˜ī¸ — requires an account and may require a paid plan in the future." + }, { "view": "gitlens.views.worktrees", - "contents": "Worktrees, a [✨ GitLens+ feature](command:gitlens.plus.learn \"Learn more about GitLens+ features\"), help you multitask by minimizing the context switching between branches, allowing you to easily work on different branches of a repository simultaneously.\n\nYou can create multiple working trees, each of which can be opened in individual windows or all together in a single workspace." + "contents": "[Worktrees](https://help.gitkraken.com/gitlens/side-bar/#worktrees-view-pro) á´žá´ŋá´ŧ — minimize context switching by allowing you to work on multiple branches simultaneously." }, { "view": "gitlens.views.worktrees", @@ -12942,23 +17562,47 @@ }, { "view": "gitlens.views.worktrees", - "contents": "Please verify your email\n\nTo use Worktrees, please verify your email address.\n\n[Resend verification email](command:gitlens.plus.resendVerification)\n\n[Refresh verification status](command:gitlens.plus.validate)", + "contents": "[Resend Verification Email](command:gitlens.plus.resendVerification?%7B%22source%22%3A%22worktrees%22%7D)\n\nYou must verify your email before you can continue or [recheck Status](command:gitlens.plus.validate?%7B%22source%22%3A%22worktrees%22%7D).", "when": "gitlens:plus:state == -1" }, { "view": "gitlens.views.worktrees", - "contents": "[Try worktrees on private repos](command:gitlens.plus.startPreviewTrial)\n\nTo use worktrees and other [GitLens+ features](command:gitlens.plus.learn) on private repos, start a free trial of GitLens Pro, without an account, or [sign in](command:gitlens.plus.loginOrSignUp).", + "contents": "[Continue](command:gitlens.plus.startPreviewTrial?%7B%22source%22%3A%22worktrees%22%7D)\n\nContinuing gives you 3 days to preview Worktrees and other local Pro features for 3 days. [Start 7-day Pro trial](command:gitlens.plus.signUp?%7B%22source%22%3A%22worktrees%22%7D) or [sign in](command:gitlens.plus.login?%7B%22source%22%3A%22worktrees%22%7D) for full access to Pro features.", "when": "gitlens:plus:required && gitlens:plus:state == 0" }, { "view": "gitlens.views.worktrees", - "contents": "[Extend Pro Trial](command:gitlens.plus.loginOrSignUp)\n\nYour free 3-day GitLens Pro trial has ended, extend your trial to get an additional 7-days of worktrees and other [GitLens+ features](command:gitlens.plus.learn) on private repos.", + "contents": "[Start Pro Trial](command:gitlens.plus.signUp?%7B%22source%22%3A%22worktrees%22%7D)\n\nStart your free 7-day Pro trial to try Worktrees and other Pro features, or [sign in](command:gitlens.plus.login?%7B%22source%22%3A%22worktrees%22%7D).", "when": "gitlens:plus:required && gitlens:plus:state == 2" }, { "view": "gitlens.views.worktrees", - "contents": "[Upgrade to Pro](command:gitlens.plus.purchase)\n\nYour GitLens Pro trial has ended, please upgrade to GitLens Pro to continue to use worktrees and other [GitLens+ features](command:gitlens.plus.learn) on private repos.", + "contents": "[Upgrade to Pro](command:gitlens.plus.upgrade?%7B%22source%22%3A%22worktrees%22%7D)", + "when": "gitlens:plus:required && gitlens:plus:state == 4" + }, + { + "view": "gitlens.views.worktrees", + "contents": "Limited-time Sale: Save 33% or more on your 1st seat of Pro.", + "when": "gitlens:plus:required && gitlens:plus:state == 4 && (gitlens:promo == pro50 || !gitlens:promo)" + }, + { + "view": "gitlens.views.worktrees", + "contents": "Launchpad Sale: Save 75% or more on GitLens Pro", + "when": "gitlens:plus:required && gitlens:plus:state == 4 && gitlens:promo =~ /(launchpad|launchpad-extended)/" + }, + { + "view": "gitlens.views.worktrees", + "contents": "Your Pro trial has ended. Please upgrade for full access to Worktrees and other Pro features.", "when": "gitlens:plus:required && gitlens:plus:state == 4" + }, + { + "view": "gitlens.views.worktrees", + "contents": "[Continue](command:gitlens.plus.reactivateProTrial?%7B%22source%22%3A%22worktrees%22%7D)\n\nReactivate your Pro trial and experience Worktrees and all the new Pro features — free for another 7 days!", + "when": "gitlens:plus:required && gitlens:plus:state == 5" + }, + { + "view": "gitlens.views.worktrees", + "contents": "Pro feature — requires a paid plan for use on privately-hosted repos." } ], "views": { @@ -12969,72 +17613,154 @@ "name": "Home", "contextualTitle": "GitLens", "icon": "$(gitlens-gitlens)", - "visibility": "visible", - "initialSize": 5 + "initialSize": 6, + "visibility": "visible" }, { - "id": "gitlens.views.contributors", - "name": "Contributors", - "when": "!gitlens:disabled", + "id": "gitlens.views.launchpad", + "name": "Launchpad", + "when": "config.gitlens.views.launchpad.enabled", "contextualTitle": "GitLens", - "icon": "$(gitlens-contributors-view)", - "visibility": "visible", - "initialSize": 1 + "icon": "$(rocket)", + "initialSize": 2, + "visibility": "visible" + }, + { + "id": "gitlens.views.drafts", + "name": "Cloud Patches", + "when": "!gitlens:untrusted && !gitlens:hasVirtualFolders && gitlens:gk:organization:drafts:enabled && config.gitlens.cloudPatches.enabled", + "contextualTitle": "GitLens", + "icon": "$(gitlens-cloud-patch)", + "initialSize": 2, + "visibility": "visible" + }, + { + "id": "gitlens.views.workspaces", + "name": "GK Workspaces", + "when": "!gitlens:untrusted && !gitlens:hasVirtualFolders", + "contextualTitle": "GitLens", + "icon": "$(gitlens-workspaces-view)", + "initialSize": 2, + "visibility": "visible" + }, + { + "type": "webview", + "id": "gitlens.views.account", + "name": "GitKraken Account", + "contextualTitle": "GitLens", + "icon": "$(gitlens-gitlens)", + "initialSize": 1, + "visibility": "collapsed" } ], - "gitlensPanel": [ + "gitlensInspect": [ { "type": "webview", - "id": "gitlens.views.timeline", - "name": "Visual File History", - "when": "!gitlens:disabled && gitlens:plus:enabled", + "id": "gitlens.views.commitDetails", + "name": "Inspect", + "when": "!gitlens:disabled", "contextualTitle": "GitLens", - "icon": "$(gitlens-history-view)", + "icon": "$(gitlens-commit-view)", + "initialSize": 6, "visibility": "visible" - } - ], - "scm": [ + }, { - "id": "gitlens.views.repositories", - "name": "Repositories", + "id": "gitlens.views.pullRequest", + "name": "Pull Request", + "when": "!gitlens:disabled && gitlens:views:pullRequest:visible", + "contextualTitle": "GitLens", + "icon": "$(git-pull-request)", + "initialSize": 1, + "visibility": "visible" + }, + { + "id": "gitlens.views.lineHistory", + "name": "Line History", "when": "!gitlens:disabled && !gitlens:hasVirtualFolders", "contextualTitle": "GitLens", - "icon": "$(gitlens-repositories-view)", - "visibility": "hidden" + "icon": "$(gitlens-history-view)", + "initialSize": 1, + "visibility": "collapsed" }, { - "id": "gitlens.views.commits", - "name": "Commits", + "id": "gitlens.views.fileHistory", + "name": "File History", "when": "!gitlens:disabled", "contextualTitle": "GitLens", - "icon": "$(gitlens-commits-view)", + "icon": "$(gitlens-history-view)", + "initialSize": 2, "visibility": "visible" }, { "type": "webview", - "id": "gitlens.views.commitDetails", - "name": "Commit Details", - "when": "!gitlens:disabled", + "id": "gitlens.views.timeline", + "name": "Visual File History", + "when": "!gitlens:disabled && gitlens:plus:enabled", "contextualTitle": "GitLens", - "icon": "$(gitlens-commit-view)", + "icon": "$(graph-scatter)", + "initialSize": 1, "visibility": "visible" }, { - "id": "gitlens.views.fileHistory", - "name": "File History", + "id": "gitlens.views.searchAndCompare", + "name": "Search & Compare", "when": "!gitlens:disabled", "contextualTitle": "GitLens", - "icon": "$(gitlens-history-view)", + "icon": "$(gitlens-search-view)", + "initialSize": 2, + "visibility": "visible" + } + ], + "gitlensPanel": [ + { + "type": "webview", + "id": "gitlens.views.graph", + "name": "Graph", + "when": "!gitlens:disabled && gitlens:plus:enabled", + "contextualTitle": "GitLens", + "icon": "$(gitlens-graph)", + "initialSize": 4, "visibility": "visible" }, { - "id": "gitlens.views.lineHistory", - "name": "Line History", - "when": "!gitlens:disabled && !gitlens:hasVirtualFolders", + "type": "webview", + "id": "gitlens.views.graphDetails", + "name": "Graph Details", + "when": "!gitlens:disabled && gitlens:plus:enabled", "contextualTitle": "GitLens", - "icon": "$(gitlens-history-view)", + "icon": "$(gitlens-commit-view)", + "initialSize": 1, + "visibility": "visible" + } + ], + "gitlensPatch": [ + { + "type": "webview", + "id": "gitlens.views.patchDetails", + "name": "Patch", + "when": "!gitlens:untrusted && config.gitlens.cloudPatches.enabled && gitlens:views:patchDetails:mode", + "contextualTitle": "GitLens", + "icon": "$(gitlens-cloud-patch)", + "initialSize": 24 + } + ], + "scm": [ + { + "id": "gitlens.views.repositories", + "name": "Repositories", + "when": "!gitlens:disabled", + "contextualTitle": "GitLens", + "icon": "$(gitlens-repositories-view)", "visibility": "hidden" }, + { + "id": "gitlens.views.commits", + "name": "Commits", + "when": "!gitlens:disabled", + "contextualTitle": "GitLens", + "icon": "$(gitlens-commits-view)", + "visibility": "visible" + }, { "id": "gitlens.views.branches", "name": "Branches", @@ -13076,206 +17802,125 @@ "visibility": "collapsed" }, { - "id": "gitlens.views.searchAndCompare", - "name": "Search & Compare", + "id": "gitlens.views.contributors", + "name": "Contributors", "when": "!gitlens:disabled", "contextualTitle": "GitLens", - "icon": "$(gitlens-search-view)", + "icon": "$(gitlens-contributors-view)", "visibility": "collapsed" } ] }, "walkthroughs": [ { - "id": "gitlens.welcome", + "id": "welcome", "title": "Get Started with GitLens", "description": "Discover and personalize features that supercharge your Git experience", "steps": [ { - "id": "gitlens.welcome.tutorial", - "title": "Watch Tutorial", - "description": "Sit back and watch the Getting Started video.\n\n[Watch Tutorial Video](https://www.youtube.com/watch?v=UQPb73Zz9qk \"Watch the Getting Started video\")", - "media": { - "markdown": "walkthroughs/getting-started/0-tutorial.md" - } - }, - { - "id": "gitlens.welcome.experience", - "title": "Get setup quickly", - "description": "Use the Quick Setup to easily configure frequently used GitLens features.\n\n[Open Quick Setup](command:gitlens.showWelcomePage?%22quick-setup%22 \"Opens the GitLens Quick Setup\")", - "media": { - "markdown": "walkthroughs/getting-started/1-setup.md" - } - }, - { - "id": "gitlens.welcome.settings", - "title": "Easily customize every aspect of GitLens", - "description": "A rich, interactive settings editor enables seemingly endless customization possibilities.\n\n[Open Settings](command:gitlens.showSettingsPage \"Opens the GitLens Interactive Settings\")", - "media": { - "markdown": "walkthroughs/getting-started/2-customize.md" - } - }, - { - "id": "gitlens.welcome.currentLineBlame", - "title": "See who made what changes at a glance", - "description": "Current line and status bar blame provide historical context about line changes.", - "media": { - "markdown": "walkthroughs/getting-started/3-current-line-blame.md" - } - }, - { - "id": "gitlens.welcome.gitCodeLens", - "title": "View Git authorship via CodeLens", - "description": "CodeLens adds contextual authorship information and links at the top of each file and at the beginning of each block of code.", - "media": { - "markdown": "walkthroughs/getting-started/4-git-codelens.md" - } - }, - { - "id": "gitlens.welcome.revisionHistory", - "title": "Easily navigate revision history", - "description": "", + "id": "get-started", + "title": "Welcome & Overview", + "description": "Quickly [get started](command:gitlens.showWelcomePage \"Opens GitLens Welcome\") and discover the many powerful GitLens features, or sit back and watch our [tutorial video](https://www.youtube.com/watch?v=UQPb73Zz9qk \"Watch the Getting Started Tutorial video\").\n\n**Side Bar & Panel Overview**\n\n$(gitlens-gitlens-inspect)  **GitLens Inspect** — an x-ray into your code's history. Offers contextual insights & details focused on what you're currently working on.\n\n[Open GitLens Inspect](command:workbench.view.extension.gitlensInspect)\n\n$(gitlens-gitlens)  **GitLens** — quick access to many GitLens features. And the home of our team and collaboration services.\n\n[Open GitLens](command:workbench.view.extension.gitlens)\n\n$(source-control) **Source Control** — packed with additional features for working with, exploring, and managing your repositories.\n\n[Open Source Control](command:workbench.view.scm)\n\n$(layout-panel)  **(Bottom) Panel** — access to the powerful Commit Graph and its dedicated details view.\n\n[Open Commit Graph](command:gitlens.showGraphView)\n💡 While our views are arranged for focus and productivity, you can easily drag them around to suit your needs. Use the [Reset Views Layout](command:workbench.action.quickOpen?%22>GitLens%3A%20Reset%20Views%20Layout%22) command to quickly get back to the default layout.\n💡 **Want more control?** Use the interactive [GitLens Settings](command:gitlens.showSettingsPage \"Opens GitLens Settings\") editor to customize GitLens to meet your needs.", "media": { - "markdown": "walkthroughs/getting-started/5-revision-history.md" + "markdown": "walkthroughs/welcome/get-started.md" } }, { - "id": "gitlens.welcome.fileAnnotations", - "title": "See more context with file annotations", - "description": "Whole file annotations place visual indicators in the gutter and scroll bar that provide additional context about changes.", + "id": "core-features", + "title": "Discover Core Features", + "description": "**Inline blame** and status bar blame provide historical context about line changes.\n💡 Hover over annotations to reveal rich details & actions.\n**Git CodeLens** adds contextual and actionable authorship information at the top of each file and at the beginning of each block of code.\n💡 Use the [Toggle Line Blame](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20Line%20Blame%22) and [Toggle Git CodeLens](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20Git%20CodeLens%22) commands.\n**File annotations**, visual indicators that augment your editor, provide insights into authorship, recent changes, or a heatmap. Annotations can be toggled on-demand for individual files or holistically.\n💡 Use the [Toggle File Blame](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Blame%22), [Toggle File Changes](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Changes%22), and [Toggle File Heatmap](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Heatmap%22) commands on an active file.\n\n**Navigate revision history** with just a click of a button at the top of any file and compare changes over time.", "media": { - "markdown": "walkthroughs/getting-started/6-file-annotations.md" + "altText": "Illustrations of Inline Blame, Codelens, File Annotations and Revision Navigation", + "svg": "walkthroughs/welcome/core-features.svg" } }, { - "id": "gitlens.welcome.gitSideBarViews", - "title": "Explore repositories from the side bar", - "description": "Rich views expose even more Git functionality in your side bar.\n\n[Set Views Layout](command:gitlens.setViewsLayout)", + "id": "pro-features", + "title": "Power-up with Pro", + "description": "Unlock the full power of GitLens with [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) and get access to the full [GitKraken DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links).\n\n[Start Pro Trial](command:gitlens.plus.signUp?%7B%22source%22%3A%22walkthrough%22%7D)\n\nAlready have an account? [sign in](command:gitlens.plus.login?%7B%22source%22%3A%22walkthrough%22%7D)\n\n**Pro Features**\n$(gitlens-graph)  [Commit Graph](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize your repository and keep track of all work in progress\n$(rocket)  [Launchpad](command:gitlens.openWalkthrough?%7B%22step%22%3A%22launchpad%22,%22source%22%3A%22walkthrough%22%7D) — stay focused and keep your team unblocked\n$(gitlens-code-suggestion)  [Code Suggest](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — free your code reviews from unnecessary restrictions\n$(gitlens-cloud-patch)  [Cloud Patches](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — easily and securely share code with your teammates\n$(gitlens-worktrees-view)  **Worktrees** — work on multiple branches simultaneously\n$(gitlens-workspaces-view)  **Workspaces** — group and manage multiple repositories together\n$(graph-scatter)  [Visual File History](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize the evolution of a file and quickly identify when the most impactful changes were made and by whom", "media": { - "markdown": "walkthroughs/getting-started/7-git-side-bar-views.md" - } + "markdown": "walkthroughs/welcome/pro-features.md" + }, + "when": "gitlens:plus:state >= 0 && gitlens:plus:state <= 2" }, { - "id": "gitlens.welcome.hostingServiceIntegrations", - "title": "Integrate with Git hosting services", - "description": "Quickly gain insights from pull requests and issues without leaving your editor.", + "id": "pro-trial", + "title": "Get Started with Pro", + "description": "During your trial, you have access to all [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) and to the full [GitKraken DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links). Be sure to take full advantage of these powerful features.\n\n[Upgrade to Pro](command:gitlens.plus.upgrade?%7B%22source%22%3A%22walkthrough%22%7D)\n\n💡Special: 1st seat of Pro is now 50%+ off.\n**Pro Features**\n$(gitlens-graph)  [Commit Graph](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize your repository and keep track of all work in progress\n$(rocket)  [Launchpad](command:gitlens.openWalkthrough?%7B%22step%22%3A%22launchpad%22,%22source%22%3A%22walkthrough%22%7D) — stay focused and keep your team unblocked\n$(gitlens-code-suggestion)  [Code Suggest](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — free your code reviews from unnecessary restrictions\n$(gitlens-cloud-patch)  [Cloud Patches](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — easily and securely share code with your teammates\n$(gitlens-worktrees-view)  **Worktrees** — work on multiple branches simultaneously\n$(gitlens-workspaces-view)  **Workspaces** — group and manage multiple repositories together\n$(graph-scatter)  [Visual File History](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize the evolution of a file and quickly identify when the most impactful changes were made and by whom", "media": { - "markdown": "walkthroughs/getting-started/8-hosting-service-integrations.md" - } + "markdown": "walkthroughs/welcome/pro-trial.md" + }, + "when": "gitlens:plus:state == 3" }, { - "id": "gitlens.welcome.gitCommandPalette", - "title": "Work faster with Git Command Palette", - "description": "Now you don't have to remember all those Git commands.\n\n[Open Git Command Palette](command:gitlens.gitCommands)", + "id": "pro-upgrade", + "title": "Upgrade to Pro", + "description": "Your Pro trial has ended. Please upgrade for full access to all [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) and to the full [GitKraken DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links).\n\n[Upgrade to Pro](command:gitlens.plus.upgrade?%7B%22source%22%3A%22walkthrough%22%7D)\n\n💡Special: 1st seat of Pro is now 50%+ off.\n**Pro Features**\n$(gitlens-graph)  [Commit Graph](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize your repository and keep track of all work in progress\n$(rocket)  [Launchpad](command:gitlens.openWalkthrough?%7B%22step%22%3A%22launchpad%22,%22source%22%3A%22walkthrough%22%7D) — stay focused and keep your team unblocked\n$(gitlens-code-suggestion)  [Code Suggest](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — free your code reviews from unnecessary restrictions\n$(gitlens-cloud-patch)  [Cloud Patches](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — easily and securely share code with your teammates\n$(gitlens-worktrees-view)  **Worktrees** — work on multiple branches simultaneously\n$(gitlens-workspaces-view)  **Workspaces** — group and manage multiple repositories together\n$(graph-scatter)  [Visual File History](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize the evolution of a file and quickly identify when the most impactful changes were made and by whom", "media": { - "markdown": "walkthroughs/getting-started/9-git-command-palette.md" - } + "markdown": "walkthroughs/welcome/pro-upgrade.md" + }, + "when": "gitlens:plus:state == 4" }, { - "id": "gitlens.welcome.interactiveRebaseEditor", - "title": "Visualize interactive rebase operations", - "description": "A user-friendly interactive rebase editor to easily configure an interactive rebase session", + "id": "pro-reactivate", + "title": "Reactivate Pro Power-up", + "description": "Reactivate your Pro trial and experience all the new [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) and the full [GitKraken DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links) — free for another 7 days!.\n\n[Reactivate Pro Trial](command:gitlens.plus.reactivateProTrial?%7B%22source%22%3A%22walkthrough%22%7D)\n\n**Pro Features**\n$(gitlens-graph)  [Commit Graph](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize your repository and keep track of all work in progress\n$(rocket)  [Launchpad](command:gitlens.openWalkthrough?%7B%22step%22%3A%22launchpad%22,%22source%22%3A%22walkthrough%22%7D) — stay focused and keep your team unblocked\n$(gitlens-code-suggestion)  [Code Suggest](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — free your code reviews from unnecessary restrictions\n$(gitlens-cloud-patch)  [Cloud Patches](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — easily and securely share code with your teammates\n$(gitlens-worktrees-view)  **Worktrees** — work on multiple branches simultaneously\n$(gitlens-workspaces-view)  **Workspaces** — group and manage multiple repositories together\n$(graph-scatter)  [Visual File History](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize the evolution of a file and quickly identify when the most impactful changes were made and by whom", "media": { - "markdown": "walkthroughs/getting-started/10-interactive-rebase-editor.md" - } + "markdown": "walkthroughs/welcome/pro-reactivate.md" + }, + "when": "gitlens:plus:state == 5" }, { - "id": "gitlens.welcome.terminal", - "title": "Jump to git details from the terminal", - "description": "Using ctrl/cmd+click on autolinks in the integrated terminal will quickly jump to more details for commits, branches, tags, and more.", + "id": "pro-paid", + "title": "Powered-up with Pro", + "description": "You have the full power of GitLens with [Pro features](https://gitkraken.com/gitlens/pro-features?utm_source=gitlens-extension&utm_medium=in-app-links) and the [GitKraken DevEx platform](https://gitkraken.com/devex?utm_source=gitlens-extension&utm_medium=in-app-links).\n\n**Pro Features**\n$(gitlens-graph)  [Commit Graph](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize your repository and keep track of all work in progress\n$(rocket)  [Launchpad](command:gitlens.openWalkthrough?%7B%22step%22%3A%22launchpad%22,%22source%22%3A%22walkthrough%22%7D) — stay focused and keep your team unblocked\n$(gitlens-code-suggestion)  [Code Suggest](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — free your code reviews from unnecessary restrictions\n$(gitlens-cloud-patch)  [Cloud Patches](command:gitlens.openWalkthrough?%7B%22step%22%3A%22code-collab%22,%22source%22%3A%22walkthrough%22%7D) — easily and securely share code with your teammates\n$(gitlens-worktrees-view)  **Worktrees** — work on multiple branches simultaneously\n$(gitlens-workspaces-view)  **Workspaces** — group and manage multiple repositories together\n$(graph-scatter)  [Visual File History](command:gitlens.openWalkthrough?%7B%22step%22%3A%22visualize%22,%22source%22%3A%22walkthrough%22%7D) — visualize the evolution of a file and quickly identify when the most impactful changes were made and by whom", "media": { - "markdown": "walkthroughs/getting-started/11-terminal.md" - } + "markdown": "walkthroughs/welcome/pro-paid.md" + }, + "when": "gitlens:plus:state == 6" }, { - "id": "gitlens.welcome.plus", - "title": "Introducing GitLens+ Features", - "description": "Check out the all-new, powerful, additional GitLens+ features.\n\n[Learn about GitLens+ features](command:gitlens.plus.learn?false \"Open the GitLens+ features walkthrough\")", - "media": { - "markdown": "walkthroughs/getting-started/12-plus.md" - } - } - ] - }, - { - "id": "gitlens.plus", - "title": "Introducing GitLens+ Features", - "description": "Get even more out of GitLens in VS Code!", - "steps": [ - { - "id": "gitlens.plus.intro", - "title": "Introducing GitLens+ Features", - "description": "All-new, powerful, additional features that enhance your current GitLens experience.", + "id": "visualize", + "title": "Visualize with Commit Graph & Visual File History", + "description": "**Commit Graph**\nEasily visualize your repository and keep track of all work in progress.\nUse the rich commit search to find exactly what you're looking for. Its powerful filters allow you to search by a specific commit, message, author, a changed file or files, or even a specific code change. [Learn more](https://gitkraken.com/solutions/commit-graph?utm_source=gitlens-extension&utm_medium=in-app-links)\n\n[Open Commit Graph](command:gitlens.showGraph)\n\n💡Quickly toggle the Graph via the [Toggle Commit Graph](command:gitlens.toggleGraph) command or maximize it using the [Toggle Maximized Commit Graph](command:gitlens.toggleMaximizedGraph) command.\n**Visual File History**\nVisualize the evolution of a file and quickly identify when the most impactful changes were made and by whom\n\n[Open Visual File History](command:gitlens.showTimelineView)", "media": { - "markdown": "walkthroughs/plus/intro.md" + "altText": "Illustrations of the Commit Graph & Visual File History", + "svg": "walkthroughs/welcome/visualize.svg" } }, { - "id": "gitlens.plus.commitGraph", - "title": "Commit Graph (new)", - "description": "The Commit Graph helps you to easily visualize branch structure and commit history.\n\n[Open Commit Graph](command:gitlens.showGraphPage)", + "id": "launchpad", + "title": "Unblock your team with Launchpad", + "description": "**Launchpad** ᴘʀᴏ brings all of your GitHub pull requests into a unified, actionable list to better track work in progress, pending work, reviews, and more. Stay focused and take action on the most important items to keep your team unblocked. [Learn more](https://gitkraken.com/solutions/launchpad?utm_source=gitlens-extension&utm_medium=in-app-links)\n\n[Open Launchpad](command:gitlens.showLaunchpad?%7B%22source%22%3A%22walkthrough%22%7D)", "media": { - "markdown": "walkthroughs/plus/commit-graph.md" + "altText": "Illustrations of Launchpad", + "svg": "walkthroughs/welcome/launchpad-quick.svg" } }, { - "id": "gitlens.plus.visualFileHistory", - "title": "Visualize file history", - "description": "A more visual way to analyze and explore changes made to a file.\n\n[Open Visual File History view](command:gitlens.showTimelineView)", + "id": "code-collab", + "title": "Collaborate with Code Suggest & Cloud Patches", + "description": "**Code Suggest** ᴘʀᴇᴠÉĒᴇᴡ\n\nLiberate your code reviews from GitHub's restrictive, comment-only feedback style. Like suggesting changes on a Google-doc, suggest code changes from where you're already coding — your IDE and on anything in your project, not just on the lines of code changed in the PR. [Learn more](https://gitkraken.com/solutions/code-suggest?utm_source=gitlens-extension&utm_medium=in-app-links)\n\n**Cloud Patches** ᴘʀᴇᴠÉĒᴇᴡ\n\nEasily and securely share code changes with your teammates or other developers by creating a Cloud Patch from your WIP, commit or stash and sharing the generated link. Use Cloud Patches to collaborate early for feedback on direction, approach, and more, to minimize rework and streamline your workflow. [Learn more](https://gitkraken.com/solutions/cloud-patches?utm_source=gitlens-extension&utm_medium=in-app-links)\n\n[Open Cloud Patches](command:gitlens.showDraftsView)", "media": { - "markdown": "walkthroughs/plus/visual-file-history.md" + "altText": "Illustrations of Code Suggest & Cloud Patches", + "image": "walkthroughs/welcome/code-collab.png" } }, { - "id": "gitlens.plus.worktrees", - "title": "Worktrees", - "description": "Create worktrees to have multiple branches checked-out at once on the same repository.\n\n[Open Worktrees view](command:gitlens.showWorktreesView)", + "id": "integrations", + "title": "Integrate with Git Hosting & Issue Services", + "description": "GitLens automatically detects patterns in commit messages to generate autolinks to pull requests and issues for Git hosting services including GitHub, GitLab, Gitea, Gerrit, Google Source, Bitbucket, Azure DevOps, and custom servers.\n\n[Configure autolinks](command:gitlens.showSettingsPage!autolinks) for custom pattern-matching with other services.\n\n**Rich Integrations with GitHub, GitLab, and Jira**\nConnect [GitHub](command:gitlens.connectRemoteProvider), [GitLab](command:gitlens.connectRemoteProvider), and [Jira](command:gitlens.plus.cloudIntegrations.connect?%7B%22integrationIds%22%3A%5B%22jira%22%5D%2C%22source%22%3A%22walkthrough%22%2C%22detail%22%3A%7B%22action%22%3A%22connect%22%2C%22integration%22%3A%22jira%22%7D%7D) integrations to enhance autolinks with more data available via APIs, associate branches and commits with PRs, and review pull requests within VS Code.", "media": { - "markdown": "walkthroughs/plus/worktrees.md" + "markdown": "walkthroughs/welcome/integrations.md" } }, { - "id": "gitlens.plus.richIntegrations", - "title": "Rich self-hosted Git integrations", + "id": "more", + "title": "And More!", + "description": "**Rebase got you down?**\nEasily visualize and configure interactive rebase operations with the intuitive and user-friendly Interactive Rebase Editor\n\n**Trouble remembering Git commands?**\nNow you don't have to with the Git Command Palette — a guided, step-by-step experience to many common Git commands.\n\n[Open Git Command Palette](command:gitlens.gitCommands)\n\n**Terminal your jam?**\nQuickly jump to more details on commits, branches, tags, and more with autolinks in the Integrated Terminal.\n\n**Not sure what you are looking for?**\nOpen the Command Palette and explore the many available commands.\n\n[Open GitLens Commands](command:workbench.action.quickOpen?%22>GitLens%3A%22)", "media": { - "markdown": "walkthroughs/plus/rich-integrations.md" + "markdown": "walkthroughs/welcome/more-features.md" } - }, - { - "id": "gitlens.plus.tryNow", - "title": "Try GitLens Pro", - "description": "[GitLens+ features](command:gitlens.plus.learn) are free for local and public repos, no account required, while upgrading to GitLens Pro gives you access on private repos.\n\n[Try GitLens+ features on private repos](command:gitlens.plus.startPreviewTrial)", - "media": { - "markdown": "walkthroughs/plus/try-now.md" - }, - "when": "gitlens:plus:state == 0" - }, - { - "id": "gitlens.plus.trial", - "title": "Trialing GitLens Pro", - "description": "During your GitLens Pro trial, you have additional access to [GitLens+ features](command:gitlens.plus.learn) on private repos.\n\n[Upgrade to Pro](command:gitlens.plus.purchase)", - "media": { - "markdown": "walkthroughs/plus/try-now.md" - }, - "when": "gitlens:plus:state == 1 || gitlens:plus:state == 3" - }, - { - "id": "gitlens.plus.trial.extend", - "title": "Extend GitLens Pro Trial", - "description": "Your free 3-day GitLens Pro trial has ended, extend your trial to get an additional free 7-days of [GitLens+ features](command:gitlens.plus.learn) on private repos.\n\n[Extend Pro Trial](command:gitlens.plus.loginOrSignUp)", - "media": { - "markdown": "walkthroughs/plus/try-now.md" - }, - "when": "gitlens:plus:state == 2" - }, - { - "id": "gitlens.plus.trial.upgrade", - "title": "Upgrade to GitLens Pro", - "description": "Your GitLens Pro trial has ended, please upgrade to GitLens Pro to continue to use [GitLens+ features](command:gitlens.plus.learn) on private repos.\n\n[Upgrade to Pro](command:gitlens.plus.command:gitlens.plus.purchase)", - "media": { - "markdown": "walkthroughs/plus/try-now.md" - }, - "when": "gitlens:plus:state == 4" } ] } @@ -13285,128 +17930,158 @@ "analyze:bundle": "webpack --mode production --env analyzeBundle", "analyze:deps": "webpack --env analyzeDeps", "build": "webpack --mode development", - "build:extension": "webpack --mode development --config-name extension", + "build:quick": "webpack --mode development --env skipLint", + "build:extension": "webpack --mode development --config-name extension:node", + "build:extension:browser": "webpack --mode development --config-name extension:webworker", "build:webviews": "webpack --mode development --config-name webviews", - "build:icons": "yarn icons:svgo && yarn fantasticon && yarn icons:apply", - "build:tests": "tsc -p tsconfig.test.json && tsc-alias -p tsconfig.test.json", - "-build:tests": "webpack -c webpack.config.test.js --mode development", + "build:icons": "pnpm run icons:svgo && pnpm run fantasticon && pnpm run icons:apply", + "build:tests": "node ./scripts/esbuild.tests.mjs --mode development", + "build:tests:e2e": "tsc -p tsconfig.test.json && tsc-alias -p tsconfig.test.json", "bundle": "webpack --mode production", - "clean": "npx rimraf dist out .vscode-test .vscode-test-web .eslintcache* tsconfig*.tsbuildinfo", - "copy:images": "webpack --config webpack.config.images.js", - "graph:link": "pushd \"../GitKrakenComponents\" && yarn link && popd && yarn link @gitkraken/gitkraken-components", - "graph:unlink": "yarn unlink @gitkraken/gitkraken-components && yarn install --force", - "icons:apply": "node ./scripts/applyIconsContribution.js", + "bundle:extension": "webpack --mode production --config-name extension:node", + "clean": "pnpx rimraf dist out .vscode-test .vscode-test-web .eslintcache* tsconfig*.tsbuildinfo", + "copy:images": "webpack --config webpack.config.images.mjs", + "graph:link": "pnpm link @gitkraken/gitkraken-components", + "graph:link:main": "pushd \"../GitKrakenComponents\" && pnpm link && popd && pnpm graph:link", + "graph:unlink": "pnpm unlink @gitkraken/gitkraken-components && pnpm install --force", + "graph:unlink:main": "pnpm graph:unlink && pushd \"../GitKrakenComponents\" && pnpm unlink && popd", + "icons:apply": "node ./scripts/applyIconsContribution.mjs", "icons:svgo": "svgo -q -f ./images/icons/ --config svgo.config.js", - "lint": "eslint \"src/**/*.ts?(x)\" --fix", - "lint:webviews": "eslint \"src/webviews/apps/**/*.ts?(x)\" --fix", - "package": "vsce package --yarn", - "package-insiders": "yarn run patch-insiders && yarn run package", - "package-pre": "yarn run patch-pre && yarn run package --pre-release", - "patch-insiders": "node ./scripts/applyInsidersPatch.js", - "patch-pre": "node ./scripts/applyPreReleasePatch.js", - "pretty": "prettier --config .prettierrc --loglevel warn --write .", - "pub": "vsce publish --yarn", - "pub-pre": "vsce publish --yarn --pre-release", - "rebuild": "yarn run reset && yarn run build", - "reset": "yarn run clean && yarn --frozen-lockfile", - "test": "node ./out/test/runTest.js", + "lint": "pnpm run lint:clear-cache && eslint .", + "lint:fix": "pnpm run lint:clear-cache && eslint . --fix", + "lint:webviews": "pnpm run lint:clear-cache && eslint \"src/webviews/apps/**/*.ts?(x)\"", + "lint:clear-cache": "pnpx rimraf .eslintcache", + "package": "vsce package --no-dependencies", + "package-pre": "pnpm run patch-pre && pnpm run package --pre-release", + "patch-pre": "node ./scripts/applyPreReleasePatch.mjs", + "prep-release": "node ./scripts/prep-release.mjs", + "pretty": "prettier --config .prettierrc --write .", + "pretty:check": "prettier --config .prettierrc --check .", + "pub": "vsce publish --no-dependencies", + "pub-pre": "vsce publish --no-dependencies --pre-release", + "rebuild": "pnpm run reset && pnpm run build", + "reset": "pnpm run clean && pnpm install --frozen-lockfile", + "test": "vscode-test", + "test:e2e": "playwright test -c tests/e2e/playwright.config.ts", "watch": "webpack --watch --mode development", "watch:extension": "webpack --watch --mode development --config-name extension", "watch:webviews": "webpack --watch --mode development --config-name webviews", - "watch:tests": "concurrently \"tsc-alias -p tsconfig.test.json -w\" \"tsc -p tsconfig.test.json -w\"", - "-watch:tests": "webpack --watch -c webpack.config.test.js --mode development", + "watch:tests": "node ./scripts/esbuild.tests.mjs --watch --mode development", "web": "vscode-test-web --extensionDevelopmentPath=. --folder-uri=vscode-vfs://github/gitkraken/vscode-gitlens", - "web:serve": "npx serve --cors -l 5000", - "web:tunnel": "npx localtunnel -p 5000", - "update-dts": "pushd \"src/@types\" && npx vscode-dts dev && popd", - "update-dts:master": "pushd \"src/@types\" && npx vscode-dts master && popd", - "update-emoji": "node ./scripts/generateEmojiShortcodeMap.js", + "web:serve": "node -e \"const p = require('path'); const h = require('os').homedir(); require('child_process').execSync('pnpx serve --cors -l 5000 --ssl-cert '+p.resolve(h, 'certs/localhost.pem')+' --ssl-key '+p.resolve(h, 'certs/localhost-key.pem'), { stdio: 'inherit' })\"", + "update-dts": "pushd \"src/@types\" && pnpx @vscode/dts dev && popd", + "update-dts:main": "pushd \"src/@types\" && pnpx @vscode/dts main && popd", + "update-emoji": "node ./scripts/generateEmojiShortcodeMap.mjs", "update-licenses": "node ./scripts/generateLicenses.mjs", - "-pretest": "yarn run build:tests", - "vscode:prepublish": "yarn run bundle" + "pretest": "pnpm run build:tests", + "vscode:prepublish": "pnpm run bundle" }, "dependencies": { - "@gitkraken/gitkraken-components": "6.0.3", - "@microsoft/fast-element": "1.11.0", - "@microsoft/fast-react-wrapper": "0.3.16-0", - "@octokit/core": "4.2.0", - "@opentelemetry/api": "1.4.0", - "@opentelemetry/exporter-trace-otlp-http": "0.35.1", - "@opentelemetry/sdk-trace-base": "1.9.1", - "@vscode/codicons": "0.0.32", - "@vscode/webview-ui-toolkit": "1.2.1", - "ansi-regex": "6.0.1", - "billboard.js": "3.7.4", + "@gitkraken/gitkraken-components": "10.6.0", + "@gitkraken/provider-apis": "0.24.2", + "@gitkraken/shared-web-components": "0.1.1-rc.15", + "@gk-nzaytsev/fast-string-truncated-width": "1.1.0", + "@lit/context": "1.1.2", + "@lit/react": "1.0.5", + "@lit/task": "1.0.1", + "@microsoft/fast-element": "1.13.0", + "@microsoft/fast-foundation": "2.49.6", + "@microsoft/fast-react-wrapper": "0.3.24", + "@octokit/graphql": "8.1.1", + "@octokit/request": "9.1.3", + "@octokit/request-error": "6.1.4", + "@octokit/types": "13.5.0", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/exporter-trace-otlp-http": "0.53.0", + "@opentelemetry/resources": "1.26.0", + "@opentelemetry/sdk-trace-base": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0", + "@shoelace-style/shoelace": "2.17.1", + "@vscode/codicons": "0.0.36", + "@vscode/webview-ui-toolkit": "1.4.0", + "ansi-regex": "6.1.0", + "billboard.js": "3.13.0", "https-proxy-agent": "5.0.1", "iconv-lite": "0.6.3", - "lit": "2.6.1", - "lodash-es": "4.17.21", - "node-fetch": "2.6.9", + "lit": "3.2.0", + "marked": "14.1.2", + "node-fetch": "2.7.0", "os-browserify": "0.3.0", "path-browserify": "1.0.1", "react": "16.8.4", "react-dom": "16.8.4", - "sortablejs": "1.15.0" + "sortablejs": "1.15.0", + "tslib": "2.7.0" }, "devDependencies": { - "@types/glob": "8.0.1", - "@types/lodash-es": "4.17.6", - "@types/mocha": "10.0.1", - "@types/node": "16.11.47", - "@types/react": "17.0.47", - "@types/react-dom": "17.0.17", - "@types/sortablejs": "1.15.0", - "@types/vscode": "1.72.0", - "@typescript-eslint/eslint-plugin": "5.52.0", - "@typescript-eslint/parser": "5.52.0", - "@vscode/test-electron": "2.2.3", - "@vscode/test-web": "0.0.34", - "@vscode/vsce": "2.17.0", + "@eamodio/eslint-lite-webpack-plugin": "0.1.0", + "@eslint/js": "9.11.1", + "@playwright/test": "1.47.2", + "@swc/core": "1.7.26", + "@twbs/fantasticon": "3.0.0", + "@types/eslint__js": "8.42.3", + "@types/mocha": "10.0.8", + "@types/node": "18.15.0", + "@types/react": "17.0.82", + "@types/react-dom": "17.0.21", + "@types/sortablejs": "1.15.8", + "@types/vscode": "1.82.0", + "@typescript-eslint/parser": "8.7.0", + "@vscode/test-cli": "^0.0.10", + "@vscode/test-electron": "2.4.1", + "@vscode/test-web": "0.0.60", + "@vscode/vsce": "3.1.0", "circular-dependency-plugin": "5.2.2", "clean-webpack-plugin": "4.0.0", - "concurrently": "7.6.0", - "copy-webpack-plugin": "11.0.0", + "copy-webpack-plugin": "12.0.2", "csp-html-webpack-plugin": "5.1.0", - "css-loader": "6.7.3", - "css-minimizer-webpack-plugin": "4.2.2", - "cssnano-preset-advanced": "5.3.10", - "esbuild": "0.17.8", - "esbuild-loader": "3.0.1", - "esbuild-sass-plugin": "2.5.0", - "eslint": "8.34.0", - "eslint-cli": "1.1.1", - "eslint-config-prettier": "8.6.0", - "eslint-import-resolver-typescript": "3.5.3", + "css-loader": "7.1.2", + "css-minimizer-webpack-plugin": "7.0.0", + "cssnano-preset-advanced": "7.0.6", + "esbuild": "0.23.1", + "esbuild-loader": "4.2.2", + "esbuild-node-externals": "1.14.0", + "esbuild-sass-plugin": "3.3.1", + "eslint": "9.11.1", + "eslint-import-resolver-typescript": "3.6.3", "eslint-plugin-anti-trojan-source": "1.1.1", - "eslint-plugin-import": "2.27.5", - "eslint-plugin-lit": "1.8.2", - "fantasticon": "1.2.3", - "fork-ts-checker-webpack-plugin": "6.5.2", - "glob": "8.1.0", - "html-loader": "4.2.0", - "html-webpack-plugin": "5.5.0", - "image-minimizer-webpack-plugin": "3.8.1", - "license-checker-rseidelsohn": "4.1.1", - "mini-css-extract-plugin": "2.7.2", - "mocha": "10.2.0", - "prettier": "2.8.4", - "sass": "1.58.3", - "sass-loader": "13.2.0", - "schema-utils": "4.0.0", - "sharp": "0.31.3", - "svgo": "3.0.2", - "terser-webpack-plugin": "5.3.6", - "ts-loader": "9.4.2", - "tsc-alias": "1.8.2", - "typescript": "4.9.5", - "webpack": "5.75.0", - "webpack-bundle-analyzer": "4.8.0", - "webpack-cli": "5.0.1", + "eslint-plugin-import-x": "4.3.0", + "eslint-plugin-lit": "1.15.0", + "eslint-plugin-wc": "2.1.1", + "fork-ts-checker-webpack-plugin": "6.5.3", + "glob": "11.0.0", + "globals": "15.9.0", + "html-loader": "5.1.0", + "html-webpack-plugin": "5.6.0", + "image-minimizer-webpack-plugin": "4.1.0", + "license-checker-rseidelsohn": "4.4.2", + "lz-string": "1.5.0", + "mini-css-extract-plugin": "2.9.1", + "mocha": "10.7.3", + "playwright": "1.47.1", + "prettier": "3.1.0", + "sass": "1.79.3", + "sass-loader": "16.0.2", + "schema-utils": "4.2.0", + "sharp": "0.32.6", + "svgo": "3.3.2", + "terser-webpack-plugin": "5.3.10", + "ts-loader": "9.5.1", + "tsc-alias": "1.8.10", + "typescript": "5.6.2", + "typescript-eslint": "8.7.0", + "webpack": "5.94.0", + "webpack-bundle-analyzer": "4.10.2", + "webpack-cli": "5.1.4", "webpack-node-externals": "3.0.0", "webpack-require-from": "1.8.6" }, "resolutions": { - "node-fetch": "2.6.9", - "semver-regex": "4.0.5" - } + "esbuild": "0.23.1", + "iconv-lite": "0.6.3", + "node-fetch": "2.7.0", + "semver-regex": "4.0.5", + "tslib": "2.7.0" + }, + "packageManager": "pnpm@9.10.0" } diff --git a/patches/debug.js b/patches/debug.js new file mode 100644 index 0000000000000..337d1efc6237d --- /dev/null +++ b/patches/debug.js @@ -0,0 +1,3 @@ +export default function () { + return function () {}; +} diff --git a/patches/whatwg-url.js b/patches/whatwg-url.js new file mode 100644 index 0000000000000..ee0d73ae6929a --- /dev/null +++ b/patches/whatwg-url.js @@ -0,0 +1 @@ +exports.URL = require('url').URL; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000000000..a0113e6a3fad4 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,10643 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + esbuild: 0.23.1 + iconv-lite: 0.6.3 + node-fetch: 2.7.0 + semver-regex: 4.0.5 + tslib: 2.7.0 + +importers: + + .: + dependencies: + '@gitkraken/gitkraken-components': + specifier: 10.6.0 + version: 10.6.0 + '@gitkraken/provider-apis': + specifier: 0.24.2 + version: 0.24.2(encoding@0.1.13) + '@gitkraken/shared-web-components': + specifier: 0.1.1-rc.15 + version: 0.1.1-rc.15 + '@gk-nzaytsev/fast-string-truncated-width': + specifier: 1.1.0 + version: 1.1.0 + '@lit/context': + specifier: 1.1.2 + version: 1.1.2 + '@lit/react': + specifier: 1.0.5 + version: 1.0.5(@types/react@17.0.82) + '@lit/task': + specifier: 1.0.1 + version: 1.0.1 + '@microsoft/fast-element': + specifier: 1.13.0 + version: 1.13.0 + '@microsoft/fast-foundation': + specifier: 2.49.6 + version: 2.49.6 + '@microsoft/fast-react-wrapper': + specifier: 0.3.24 + version: 0.3.24(react@16.8.4) + '@octokit/graphql': + specifier: 8.1.1 + version: 8.1.1 + '@octokit/request': + specifier: 9.1.3 + version: 9.1.3 + '@octokit/request-error': + specifier: 6.1.4 + version: 6.1.4 + '@octokit/types': + specifier: 13.5.0 + version: 13.5.0 + '@opentelemetry/api': + specifier: 1.9.0 + version: 1.9.0 + '@opentelemetry/exporter-trace-otlp-http': + specifier: 0.53.0 + version: 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': + specifier: 1.26.0 + version: 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': + specifier: 1.26.0 + version: 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': + specifier: 1.27.0 + version: 1.27.0 + '@shoelace-style/shoelace': + specifier: 2.17.1 + version: 2.17.1(@types/react@17.0.82) + '@vscode/codicons': + specifier: 0.0.36 + version: 0.0.36 + '@vscode/webview-ui-toolkit': + specifier: 1.4.0 + version: 1.4.0(react@16.8.4) + ansi-regex: + specifier: 6.1.0 + version: 6.1.0 + billboard.js: + specifier: 3.13.0 + version: 3.13.0 + https-proxy-agent: + specifier: 5.0.1 + version: 5.0.1 + iconv-lite: + specifier: 0.6.3 + version: 0.6.3 + lit: + specifier: 3.2.0 + version: 3.2.0 + marked: + specifier: 14.1.2 + version: 14.1.2 + node-fetch: + specifier: 2.7.0 + version: 2.7.0(encoding@0.1.13) + os-browserify: + specifier: 0.3.0 + version: 0.3.0 + path-browserify: + specifier: 1.0.1 + version: 1.0.1 + react: + specifier: 16.8.4 + version: 16.8.4 + react-dom: + specifier: 16.8.4 + version: 16.8.4(react@16.8.4) + sortablejs: + specifier: 1.15.0 + version: 1.15.0 + tslib: + specifier: 2.7.0 + version: 2.7.0 + devDependencies: + '@eamodio/eslint-lite-webpack-plugin': + specifier: 0.1.0 + version: 0.1.0(@swc/core@1.7.26)(esbuild@0.23.1)(eslint@9.11.1)(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0))(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + '@eslint/js': + specifier: 9.11.1 + version: 9.11.1 + '@playwright/test': + specifier: 1.47.2 + version: 1.47.2 + '@swc/core': + specifier: 1.7.26 + version: 1.7.26 + '@twbs/fantasticon': + specifier: 3.0.0 + version: 3.0.0 + '@types/eslint__js': + specifier: 8.42.3 + version: 8.42.3 + '@types/mocha': + specifier: 10.0.8 + version: 10.0.8 + '@types/node': + specifier: 18.15.0 + version: 18.15.0 + '@types/react': + specifier: 17.0.82 + version: 17.0.82 + '@types/react-dom': + specifier: 17.0.21 + version: 17.0.21 + '@types/sortablejs': + specifier: 1.15.8 + version: 1.15.8 + '@types/vscode': + specifier: 1.82.0 + version: 1.82.0 + '@typescript-eslint/parser': + specifier: 8.7.0 + version: 8.7.0(eslint@9.11.1)(typescript@5.6.2) + '@vscode/test-cli': + specifier: ^0.0.10 + version: 0.0.10 + '@vscode/test-electron': + specifier: 2.4.1 + version: 2.4.1 + '@vscode/test-web': + specifier: 0.0.60 + version: 0.0.60 + '@vscode/vsce': + specifier: 3.1.0 + version: 3.1.0 + circular-dependency-plugin: + specifier: 5.2.2 + version: 5.2.2(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + clean-webpack-plugin: + specifier: 4.0.0 + version: 4.0.0(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + copy-webpack-plugin: + specifier: 12.0.2 + version: 12.0.2(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + csp-html-webpack-plugin: + specifier: 5.1.0 + version: 5.1.0(html-webpack-plugin@5.6.0(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)))(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + css-loader: + specifier: 7.1.2 + version: 7.1.2(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + css-minimizer-webpack-plugin: + specifier: 7.0.0 + version: 7.0.0(esbuild@0.23.1)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + cssnano-preset-advanced: + specifier: 7.0.6 + version: 7.0.6(postcss@8.4.47) + esbuild: + specifier: 0.23.1 + version: 0.23.1 + esbuild-loader: + specifier: 4.2.2 + version: 4.2.2(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + esbuild-node-externals: + specifier: 1.14.0 + version: 1.14.0(esbuild@0.23.1) + esbuild-sass-plugin: + specifier: 3.3.1 + version: 3.3.1(esbuild@0.23.1)(sass-embedded@1.77.8) + eslint: + specifier: 9.11.1 + version: 9.11.1 + eslint-import-resolver-typescript: + specifier: 3.6.3 + version: 3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import-x@4.3.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.11.1) + eslint-plugin-anti-trojan-source: + specifier: 1.1.1 + version: 1.1.1 + eslint-plugin-import-x: + specifier: 4.3.0 + version: 4.3.0(eslint@9.11.1)(typescript@5.6.2) + eslint-plugin-lit: + specifier: 1.15.0 + version: 1.15.0(eslint@9.11.1) + eslint-plugin-wc: + specifier: 2.1.1 + version: 2.1.1(eslint@9.11.1) + fork-ts-checker-webpack-plugin: + specifier: 6.5.3 + version: 6.5.3(eslint@9.11.1)(typescript@5.6.2)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + glob: + specifier: 11.0.0 + version: 11.0.0 + globals: + specifier: 15.9.0 + version: 15.9.0 + html-loader: + specifier: 5.1.0 + version: 5.1.0(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + html-webpack-plugin: + specifier: 5.6.0 + version: 5.6.0(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + image-minimizer-webpack-plugin: + specifier: 4.1.0 + version: 4.1.0(sharp@0.32.6)(svgo@3.3.2)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + license-checker-rseidelsohn: + specifier: 4.4.2 + version: 4.4.2 + lz-string: + specifier: 1.5.0 + version: 1.5.0 + mini-css-extract-plugin: + specifier: 2.9.1 + version: 2.9.1(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + mocha: + specifier: 10.7.3 + version: 10.7.3 + playwright: + specifier: 1.47.1 + version: 1.47.1 + prettier: + specifier: 3.1.0 + version: 3.1.0 + sass: + specifier: 1.79.3 + version: 1.79.3 + sass-loader: + specifier: 16.0.2 + version: 16.0.2(sass-embedded@1.77.8)(sass@1.79.3)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + schema-utils: + specifier: 4.2.0 + version: 4.2.0 + sharp: + specifier: 0.32.6 + version: 0.32.6 + svgo: + specifier: 3.3.2 + version: 3.3.2 + terser-webpack-plugin: + specifier: 5.3.10 + version: 5.3.10(@swc/core@1.7.26)(esbuild@0.23.1)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + ts-loader: + specifier: 9.5.1 + version: 9.5.1(typescript@5.6.2)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + tsc-alias: + specifier: 1.8.10 + version: 1.8.10 + typescript: + specifier: 5.6.2 + version: 5.6.2 + typescript-eslint: + specifier: 8.7.0 + version: 8.7.0(eslint@9.11.1)(typescript@5.6.2) + webpack: + specifier: 5.94.0 + version: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + webpack-bundle-analyzer: + specifier: 4.10.2 + version: 4.10.2 + webpack-cli: + specifier: 5.1.4 + version: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0) + webpack-node-externals: + specifier: 3.0.0 + version: 3.0.0 + webpack-require-from: + specifier: 1.8.6 + version: 1.8.6(tapable@2.2.1) + +packages: + + '@axosoft/react-virtualized@9.22.3-gitkraken.3': + resolution: {integrity: sha512-sCU8gM0Ut1I3lNBYLQCq7nmRObFsdGKkTIMZkVThZhFYtmQchl1RLnsXilicmNlwCNZdm3/uDCpOw6q7T1gtog==} + peerDependencies: + react: ^15.3.0 || ^16.0.0-alpha + react-dom: ^15.3.0 || ^16.0.0-alpha + + '@azure/abort-controller@1.1.0': + resolution: {integrity: sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==} + engines: {node: '>=12.0.0'} + + '@azure/abort-controller@2.1.2': + resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} + engines: {node: '>=18.0.0'} + + '@azure/core-auth@1.8.0': + resolution: {integrity: sha512-YvFMowkXzLbXNM11yZtVLhUCmuG0ex7JKOH366ipjmHBhL3vpDcPAeWF+jf0X+jVXwFqo3UhsWUq4kH0ZPdu/g==} + engines: {node: '>=18.0.0'} + + '@azure/core-client@1.9.2': + resolution: {integrity: sha512-kRdry/rav3fUKHl/aDLd/pDLcB+4pOFwPPTVEExuMyaI5r+JBbMWqRbCY1pn5BniDaU3lRxO9eaQ1AmSMehl/w==} + engines: {node: '>=18.0.0'} + + '@azure/core-rest-pipeline@1.17.0': + resolution: {integrity: sha512-62Vv8nC+uPId3j86XJ0WI+sBf0jlqTqPUFCBNrGtlaUeQUIXWV/D8GE5A1d+Qx8H7OQojn2WguC8kChD6v0shA==} + engines: {node: '>=18.0.0'} + + '@azure/core-tracing@1.1.2': + resolution: {integrity: sha512-dawW9ifvWAWmUm9/h+/UQ2jrdvjCJ7VJEuCJ6XVNudzcOwm53BFZH4Q845vjfgoUAM8ZxokvVNxNxAITc502YA==} + engines: {node: '>=18.0.0'} + + '@azure/core-util@1.10.0': + resolution: {integrity: sha512-dqLWQsh9Nro1YQU+405POVtXnwrIVqPyfUzc4zXCbThTg7+vNNaiMkwbX9AMXKyoFYFClxmB3s25ZFr3+jZkww==} + engines: {node: '>=18.0.0'} + + '@azure/identity@4.4.1': + resolution: {integrity: sha512-DwnG4cKFEM7S3T+9u05NstXU/HN0dk45kPOinUyNKsn5VWwpXd9sbPKEg6kgJzGbm1lMuhx9o31PVbCtM5sfBA==} + engines: {node: '>=18.0.0'} + + '@azure/logger@1.1.4': + resolution: {integrity: sha512-4IXXzcCdLdlXuCG+8UKEwLA1T1NHqUfanhXYHiQTn+6sfWCZXduqbtXDGceg3Ce5QxTGo7EqmbV6Bi+aqKuClQ==} + engines: {node: '>=18.0.0'} + + '@azure/msal-browser@3.24.0': + resolution: {integrity: sha512-JGNV9hTYAa7lsum9IMIibn2kKczAojNihGo1hi7pG0kNrcKej530Fl6jxwM05A44/6I079CSn6WxYxbVhKUmWg==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@14.15.0': + resolution: {integrity: sha512-ImAQHxmpMneJ/4S8BRFhjt1MZ3bppmpRPYYNyzeQPeFN288YKbb8TmmISQEbtfkQ1BPASvYZU5doIZOPBAqENQ==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@2.14.0': + resolution: {integrity: sha512-rrfzIpG3Q1rHjVYZmHAEDidWAZZ2cgkxlIcMQ8dHebRISaZ2KCV33Q8Vs+uaV6lxweROabNxKFlR2lIKagZqYg==} + engines: {node: '>=16'} + + '@babel/code-frame@7.24.7': + resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.24.7': + resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} + engines: {node: '>=6.9.0'} + + '@babel/highlight@7.24.7': + resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} + engines: {node: '>=6.9.0'} + + '@babel/runtime-corejs2@7.25.6': + resolution: {integrity: sha512-24uCmOJPrsnS7HtRamCibYabHRV0bscPJNFFcyKgj7FqUA0V5XcbZUmz9PVNDW4L+euMsZtCIetU1LxTmUaIlA==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.25.6': + resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@bufbuild/protobuf@1.10.0': + resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==} + + '@ctrl/tinycolor@4.1.0': + resolution: {integrity: sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==} + engines: {node: '>=14'} + + '@discoveryjs/json-ext@0.5.7': + resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} + engines: {node: '>=10.0.0'} + + '@eamodio/eslint-lite-webpack-plugin@0.1.0': + resolution: {integrity: sha512-iMN1Z9Z/UGxM3Zp9oP1aloz6Z0gSXnJHunDisAJbWad4IXg5o/41on7ZHFy1i3EviaXV0oVWykJW/xCT9l50JQ==} + engines: {node: '>= 18.15.0'} + peerDependencies: + eslint: ^9.10.0 + webpack: ^5.94.0 + + '@esbuild/aix-ppc64@0.23.1': + resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.23.1': + resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.23.1': + resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.23.1': + resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.23.1': + resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.23.1': + resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.23.1': + resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.23.1': + resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.23.1': + resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.23.1': + resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.23.1': + resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.23.1': + resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.23.1': + resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.23.1': + resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.23.1': + resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.23.1': + resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.23.1': + resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.23.1': + resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.23.1': + resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.23.1': + resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.23.1': + resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.23.1': + resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.23.1': + resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.23.1': + resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.0': + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.11.1': + resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.18.0': + resolution: {integrity: sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.6.0': + resolution: {integrity: sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.1.0': + resolution: {integrity: sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.11.1': + resolution: {integrity: sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.4': + resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.2.0': + resolution: {integrity: sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.6.8': + resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} + + '@floating-ui/dom@1.6.11': + resolution: {integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==} + + '@floating-ui/utils@0.2.8': + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + + '@gar/promisify@1.1.3': + resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + + '@gitkraken/gitkraken-components@10.6.0': + resolution: {integrity: sha512-98wHDs0uL5PDvBmsjnrYyGQ98GTUbURy9nAV4QCOysMtKM2Zbt92KXK/4FUCJXC2P53TkcQ66pMgdPbzOK134g==} + + '@gitkraken/provider-apis@0.24.2': + resolution: {integrity: sha512-Il0QqYbw0mzjda+EhApny2re0cMY7LOYygmGh5ASQ33juT9rinMsZo1Wu3wh93jwSbAMEzS0royOHFfw37vz2w==} + engines: {node: '>= 14'} + + '@gitkraken/shared-web-components@0.1.1-rc.15': + resolution: {integrity: sha512-BXGWoZoFzWftJC3BgEEPm/cU2qYasoGveGiG8oL8cWydl2TCkRCxddsiDswsMQaZDZIeTYLwbUPwA2PBeAAg4A==} + + '@gk-nzaytsev/fast-string-truncated-width@1.1.0': + resolution: {integrity: sha512-NPKNmdjRFUNpMRzQU3m+AmKzbiQ3WGFXxacMyfmRgm1N+vRhuCzAD3t2dRD29aX1n6a+PNBK2a6hwPwFTfx1rw==} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.3.0': + resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.6': + resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@koa/cors@5.0.0': + resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} + engines: {node: '>= 14.0.0'} + + '@koa/router@13.1.0': + resolution: {integrity: sha512-mNVu1nvkpSd8Q8gMebGbCkDWJ51ODetrFvLKYusej+V0ByD4btqHYnPIzTBLXnQMVUlm/oxVwqmWBY3zQfZilw==} + engines: {node: '>= 18'} + + '@lit-labs/ssr-dom-shim@1.2.1': + resolution: {integrity: sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==} + + '@lit/context@1.1.2': + resolution: {integrity: sha512-S0nw2C6Tkm7fVX5TGYqeROGD+Z9Coa2iFpW+ysYBDH3YvCqOY3wVQvSgwbaliLJkjTnSEYCBe9qFqKV8WUFpVw==} + + '@lit/react@1.0.5': + resolution: {integrity: sha512-RSHhrcuSMa4vzhqiTenzXvtQ6QDq3hSPsnHHO3jaPmmvVFeoNNm4DHoQ0zLdKAUvY3wP3tTENSUf7xpyVfrDEA==} + peerDependencies: + '@types/react': 17 || 18 + + '@lit/reactive-element@2.0.4': + resolution: {integrity: sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==} + + '@lit/task@1.0.1': + resolution: {integrity: sha512-fVLDtmwCau8NywnFIXaJxsCZjzaIxnVq+cFRKYC1Y4tA4/0rMTvF6DLZZ2JE51BwzOluaKtgJX8x1QDsQtAaIw==} + + '@microsoft/fast-element@1.13.0': + resolution: {integrity: sha512-iFhzKbbD0cFRo9cEzLS3Tdo9BYuatdxmCEKCpZs1Cro/93zNMpZ/Y9/Z7SknmW6fhDZbpBvtO8lLh9TFEcNVAQ==} + + '@microsoft/fast-foundation@2.49.6': + resolution: {integrity: sha512-DZVr+J/NIoskFC1Y6xnAowrMkdbf2d5o7UyWK6gW5AiQ6S386Ql8dw4KcC4kHaeE1yL2CKvweE79cj6ZhJhTvA==} + + '@microsoft/fast-react-wrapper@0.3.24': + resolution: {integrity: sha512-sRnSBIKaO42p4mYoYR60spWVkg89wFxFAgQETIMazAm2TxtlsnsGszJnTwVhXq2Uz+XNiD8eKBkfzK5c/i6/Kw==} + peerDependencies: + react: '>=16.9.0' + + '@microsoft/fast-web-utilities@5.4.1': + resolution: {integrity: sha512-ReWYncndjV3c8D8iq9tp7NcFNc1vbVHvcBFPME2nNFKNbS1XCesYZGlIlf3ot5EmuOXPlrzUHOWzQ2vFpIkqDg==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@npmcli/fs@2.1.2': + resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + '@npmcli/fs@3.1.1': + resolution: {integrity: sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@npmcli/move-file@2.0.1': + resolution: {integrity: sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This functionality has been moved to @npmcli/fs + + '@octokit/endpoint@10.1.1': + resolution: {integrity: sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==} + engines: {node: '>= 18'} + + '@octokit/graphql@8.1.1': + resolution: {integrity: sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==} + engines: {node: '>= 18'} + + '@octokit/openapi-types@22.2.0': + resolution: {integrity: sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==} + + '@octokit/request-error@6.1.4': + resolution: {integrity: sha512-VpAhIUxwhWZQImo/dWAN/NpPqqojR6PSLgLYAituLM6U+ddx9hCioFGwBr5Mi+oi5CLeJkcAs3gJ0PYYzU6wUg==} + engines: {node: '>= 18'} + + '@octokit/request@9.1.3': + resolution: {integrity: sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==} + engines: {node: '>= 18'} + + '@octokit/types@13.5.0': + resolution: {integrity: sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==} + + '@opentelemetry/api-logs@0.53.0': + resolution: {integrity: sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==} + engines: {node: '>=14'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@1.26.0': + resolution: {integrity: sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-trace-otlp-http@0.53.0': + resolution: {integrity: sha512-m7F5ZTq+V9mKGWYpX8EnZ7NjoqAU7VemQ1E2HAG+W/u0wpY1x0OmbxAXfGKFHCspdJk8UKlwPGrpcB8nay3P8A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/otlp-exporter-base@0.53.0': + resolution: {integrity: sha512-UCWPreGQEhD6FjBaeDuXhiMf6kkBODF0ZQzrk/tuQcaVDJ+dDQ/xhJp192H9yWnKxVpEjFrSSLnpqmX4VwX+eA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/otlp-transformer@0.53.0': + resolution: {integrity: sha512-rM0sDA9HD8dluwuBxLetUmoqGJKSAbWenwD65KY9iZhUxdBHRLrIdrABfNDP7aiTjcgK8XFyTn5fhDz7N+W6DA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@1.26.0': + resolution: {integrity: sha512-CPNYchBE7MBecCSVy0HKpUISEeJOniWqcHaAHpmasZ3j9o6V3AyBzhRc90jdmemq0HOxDr6ylhUbDhBqqPpeNw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.53.0': + resolution: {integrity: sha512-dhSisnEgIj/vJZXZV6f6KcTnyLDx/VuQ6l3ejuZpMpPlh9S1qMHiZU9NMmOkVkwwHkMy3G6mEBwdP23vUZVr4g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@1.26.0': + resolution: {integrity: sha512-0SvDXmou/JjzSDOjUmetAAvcKQW6ZrvosU0rkbDGpXvvZN+pQF6JbK/Kd4hNdK4q/22yeruqvukXEJyySTzyTQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@1.26.0': + resolution: {integrity: sha512-olWQldtvbK4v22ymrKLbIcBi9L2SpMO84sCPY54IVsJhP9fRsxJT194C/AVaAuJzLE30EdhhM1VmvVYR7az+cw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.27.0': + resolution: {integrity: sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==} + engines: {node: '>=14'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/browser-chromium@1.47.2': + resolution: {integrity: sha512-tsk9bLcGzIu4k4xI2ixlwDrdJhMqCalUCsSj7TRI8VuvK7cLiJIa5SR0dprKbX+wkku/JMR4EN6g9DMHvfna+Q==} + engines: {node: '>=18'} + + '@playwright/test@1.47.2': + resolution: {integrity: sha512-jTXRsoSPONAs8Za9QEQdyjFn+0ZQFjCiIztAIF6bi1HqhBzG9Ma7g1WotyiGqFSBRZjIEqMdT8RUlbk1QVhzCQ==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.28': + resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + + '@shoelace-style/animations@1.2.0': + resolution: {integrity: sha512-avvo1xxkLbv2dgtabdewBbqcJfV0e0zCwFqkPMnHFGbJbBHorRFfMAHh1NG9ymmXn0jW95ibUVH03E1NYXD6Gw==} + + '@shoelace-style/localize@3.2.1': + resolution: {integrity: sha512-r4C9C/5kSfMBIr0D9imvpRdCNXtUNgyYThc4YlS6K5Hchv1UyxNQ9mxwj+BTRH2i1Neits260sR3OjKMnplsFA==} + + '@shoelace-style/shoelace@2.17.1': + resolution: {integrity: sha512-fB9+bPHLg5zVwPbBKEqY3ghyttkJq9RuUzFMTZKweKrNKKDMUACtI8DlMYUqNwpdZMJhf7a0xeak6vFVBSxcbQ==} + engines: {node: '>=14.17.0'} + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@sindresorhus/merge-streams@2.3.0': + resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} + engines: {node: '>=18'} + + '@swc/core-darwin-arm64@1.7.26': + resolution: {integrity: sha512-FF3CRYTg6a7ZVW4yT9mesxoVVZTrcSWtmZhxKCYJX9brH4CS/7PRPjAKNk6kzWgWuRoglP7hkjQcd6EpMcZEAw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.7.26': + resolution: {integrity: sha512-az3cibZdsay2HNKmc4bjf62QVukuiMRh5sfM5kHR/JMTrLyS6vSw7Ihs3UTkZjUxkLTT8ro54LI6sV6sUQUbLQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.7.26': + resolution: {integrity: sha512-VYPFVJDO5zT5U3RpCdHE5v1gz4mmR8BfHecUZTmD2v1JeFY6fv9KArJUpjrHEEsjK/ucXkQFmJ0jaiWXmpOV9Q==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.7.26': + resolution: {integrity: sha512-YKevOV7abpjcAzXrhsl+W48Z9mZvgoVs2eP5nY+uoMAdP2b3GxC0Df1Co0I90o2lkzO4jYBpTMcZlmUXLdXn+Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.7.26': + resolution: {integrity: sha512-3w8iZICMkQQON0uIcvz7+Q1MPOW6hJ4O5ETjA0LSP/tuKqx30hIniCGOgPDnv3UTMruLUnQbtBwVCZTBKR3Rkg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.7.26': + resolution: {integrity: sha512-c+pp9Zkk2lqb06bNGkR2Looxrs7FtGDMA4/aHjZcCqATgp348hOKH5WPvNLBl+yPrISuWjbKDVn3NgAvfvpH4w==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.7.26': + resolution: {integrity: sha512-PgtyfHBF6xG87dUSSdTJHwZ3/8vWZfNIXQV2GlwEpslrOkGqy+WaiiyE7Of7z9AvDILfBBBcJvJ/r8u980wAfQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.7.26': + resolution: {integrity: sha512-9TNXPIJqFynlAOrRD6tUQjMq7KApSklK3R/tXgIxc7Qx+lWu8hlDQ/kVPLpU7PWvMMwC/3hKBW+p5f+Tms1hmA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.7.26': + resolution: {integrity: sha512-9YngxNcG3177GYdsTum4V98Re+TlCeJEP4kEwEg9EagT5s3YejYdKwVAkAsJszzkXuyRDdnHUpYbTrPG6FiXrQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.7.26': + resolution: {integrity: sha512-VR+hzg9XqucgLjXxA13MtV5O3C0bK0ywtLIBw/+a+O+Oc6mxFWHtdUeXDbIi5AiPbn0fjgVJMqYnyjGyyX8u0w==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.7.26': + resolution: {integrity: sha512-f5uYFf+TmMQyYIoxkn/evWhNGuUzC730dFwAKGwBVHHVoPyak1/GvJUm6i1SKl+2Hrj9oN0i3WSoWWZ4pgI8lw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '*' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.12': + resolution: {integrity: sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==} + + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + + '@twbs/fantasticon@3.0.0': + resolution: {integrity: sha512-Vuf7M0IyOP9G7OhibereG3pXEqz+xcp1QuJ/GpezDqbUEx7mrJPiS0/WMftItWTtJ0C/yGK8slHME6+G7FWeEw==} + engines: {node: '>=16'} + hasBin: true + + '@types/d3-selection@3.0.10': + resolution: {integrity: sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==} + + '@types/d3-transition@3.0.8': + resolution: {integrity: sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/eslint__js@8.42.3': + resolution: {integrity: sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==} + + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + + '@types/glob@7.2.0': + resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} + + '@types/html-minifier-terser@6.1.0': + resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/minimatch@5.1.2': + resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} + + '@types/minimist@1.2.5': + resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + + '@types/mocha@10.0.8': + resolution: {integrity: sha512-HfMcUmy9hTMJh66VNcmeC9iVErIZJli2bszuXc6julh5YGuRb/W5OnkHjwLNYdFlMis0sY3If5SEAp+PktdJjw==} + + '@types/node@18.15.0': + resolution: {integrity: sha512-z6nr0TTEOBGkzLGmbypWOGnpSpSIBorEhC4L+4HeQ2iezKCi4f77kyslRwvHeNitymGQ+oFyIWGP96l/DPSV9w==} + + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/prop-types@15.7.13': + resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} + + '@types/react-dom@17.0.21': + resolution: {integrity: sha512-3rQEFUNUUz2MYiRwJJj6UekcW7rFLOtmK7ajQP7qJpjNdggInl3I/xM4I3Hq1yYPdCGVMgax1gZsB7BBTtayXg==} + + '@types/react@17.0.82': + resolution: {integrity: sha512-wTW8Lu/PARGPFE8tOZqCvprOKg5sen/2uS03yKn2xbCDFP9oLncm7vMDQ2+dEQXHVIXrOpW6u72xUXEXO0ypSw==} + + '@types/scheduler@0.16.8': + resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==} + + '@types/sortablejs@1.15.8': + resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/vscode@1.82.0': + resolution: {integrity: sha512-VSHV+VnpF8DEm8LNrn8OJ8VuUNcBzN3tMvKrNpbhhfuVjFm82+6v44AbDhLvVFgCzn6vs94EJNTp7w8S6+Q1Rw==} + + '@types/webpack@5.28.5': + resolution: {integrity: sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.33': + resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + + '@typescript-eslint/eslint-plugin@8.7.0': + resolution: {integrity: sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@8.7.0': + resolution: {integrity: sha512-lN0btVpj2unxHlNYLI//BQ7nzbMJYBVQX5+pbNXvGYazdlgYonMn4AhhHifQ+J4fGRYA/m1DjaQjx+fDetqBOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@8.7.0': + resolution: {integrity: sha512-87rC0k3ZlDOuz82zzXRtQ7Akv3GKhHs0ti4YcbAJtaomllXoSO8hi7Ix3ccEvCd824dy9aIX+j3d2UMAfCtVpg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.7.0': + resolution: {integrity: sha512-tl0N0Mj3hMSkEYhLkjREp54OSb/FI6qyCzfiiclvJvOqre6hsZTGSnHtmFLDU8TIM62G7ygEa1bI08lcuRwEnQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@8.7.0': + resolution: {integrity: sha512-LLt4BLHFwSfASHSF2K29SZ+ZCsbQOM+LuarPjRUuHm+Qd09hSe3GCeaQbcCr+Mik+0QFRmep/FyZBO6fJ64U3w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.7.0': + resolution: {integrity: sha512-MC8nmcGHsmfAKxwnluTQpNqceniT8SteVwd2voYlmiSWGOtjvGXdPl17dYu2797GVscK30Z04WRM28CrKS9WOg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@8.7.0': + resolution: {integrity: sha512-ZbdUdwsl2X/s3CiyAu3gOlfQzpbuG3nTWKPoIvAu1pu5r8viiJvv2NPN2AqArL35NCYtw/lrPPfM4gxrMLNLPw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + + '@typescript-eslint/visitor-keys@8.7.0': + resolution: {integrity: sha512-b1tx0orFCCh/THWPQa2ZwWzvOeyzzp36vkJYOpVg0u8UVOIsfVrnuC9FqAw9gRKn+rG2VmWQ/zDJZzkxUnj/XQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vscode/codicons@0.0.36': + resolution: {integrity: sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==} + + '@vscode/test-cli@0.0.10': + resolution: {integrity: sha512-B0mMH4ia+MOOtwNiLi79XhA+MLmUItIC8FckEuKrVAVriIuSWjt7vv4+bF8qVFiNFe4QRfzPaIZk39FZGWEwHA==} + engines: {node: '>=18'} + hasBin: true + + '@vscode/test-electron@2.4.1': + resolution: {integrity: sha512-Gc6EdaLANdktQ1t+zozoBVRynfIsMKMc94Svu1QreOBC8y76x4tvaK32TljrLi1LI2+PK58sDVbL7ALdqf3VRQ==} + engines: {node: '>=16'} + + '@vscode/test-web@0.0.60': + resolution: {integrity: sha512-U3Eif3GZEfmtFJFTRl5IZUxlqDocslvpHCoW4uz407F9YINc2ujJPB0QG+9ZtjjowZDzFJlg60J44RqmnlA0cg==} + engines: {node: '>=16'} + hasBin: true + + '@vscode/vsce-sign-alpine-arm64@2.0.2': + resolution: {integrity: sha512-E80YvqhtZCLUv3YAf9+tIbbqoinWLCO/B3j03yQPbjT3ZIHCliKZlsy1peNc4XNZ5uIb87Jn0HWx/ZbPXviuAQ==} + cpu: [arm64] + os: [alpine] + + '@vscode/vsce-sign-alpine-x64@2.0.2': + resolution: {integrity: sha512-n1WC15MSMvTaeJ5KjWCzo0nzjydwxLyoHiMJHu1Ov0VWTZiddasmOQHekA47tFRycnt4FsQrlkSCTdgHppn6bw==} + cpu: [x64] + os: [alpine] + + '@vscode/vsce-sign-darwin-arm64@2.0.2': + resolution: {integrity: sha512-rz8F4pMcxPj8fjKAJIfkUT8ycG9CjIp888VY/6pq6cuI2qEzQ0+b5p3xb74CJnBbSC0p2eRVoe+WgNCAxCLtzQ==} + cpu: [arm64] + os: [darwin] + + '@vscode/vsce-sign-darwin-x64@2.0.2': + resolution: {integrity: sha512-MCjPrQ5MY/QVoZ6n0D92jcRb7eYvxAujG/AH2yM6lI0BspvJQxp0o9s5oiAM9r32r9tkLpiy5s2icsbwefAQIw==} + cpu: [x64] + os: [darwin] + + '@vscode/vsce-sign-linux-arm64@2.0.2': + resolution: {integrity: sha512-Ybeu7cA6+/koxszsORXX0OJk9N0GgfHq70Wqi4vv2iJCZvBrOWwcIrxKjvFtwyDgdeQzgPheH5nhLVl5eQy7WA==} + cpu: [arm64] + os: [linux] + + '@vscode/vsce-sign-linux-arm@2.0.2': + resolution: {integrity: sha512-Fkb5jpbfhZKVw3xwR6t7WYfwKZktVGNXdg1m08uEx1anO0oUPUkoQRsNm4QniL3hmfw0ijg00YA6TrxCRkPVOQ==} + cpu: [arm] + os: [linux] + + '@vscode/vsce-sign-linux-x64@2.0.2': + resolution: {integrity: sha512-NsPPFVtLaTlVJKOiTnO8Cl78LZNWy0Q8iAg+LlBiCDEgC12Gt4WXOSs2pmcIjDYzj2kY4NwdeN1mBTaujYZaPg==} + cpu: [x64] + os: [linux] + + '@vscode/vsce-sign-win32-arm64@2.0.2': + resolution: {integrity: sha512-wPs848ymZ3Ny+Y1Qlyi7mcT6VSigG89FWQnp2qRYCyMhdJxOpA4lDwxzlpL8fG6xC8GjQjGDkwbkWUcCobvksQ==} + cpu: [arm64] + os: [win32] + + '@vscode/vsce-sign-win32-x64@2.0.2': + resolution: {integrity: sha512-pAiRN6qSAhDM5SVOIxgx+2xnoVUePHbRNC7OD2aOR3WltTKxxF25OfpK8h8UQ7A0BuRkSgREbB59DBlFk4iAeg==} + cpu: [x64] + os: [win32] + + '@vscode/vsce-sign@2.0.4': + resolution: {integrity: sha512-0uL32egStKYfy60IqnynAChMTbL0oqpqk0Ew0YHiIb+fayuGZWADuIPHWUcY1GCnAA+VgchOPDMxnc2R3XGWEA==} + + '@vscode/vsce@3.1.0': + resolution: {integrity: sha512-fwdfp1Ol+bZtlSGkpcd/nztfo6+SVsTOMWjZ/+a88lVtUn7gXNbSu7dbniecl5mz4vINl+oaVDVtVdGbJDApmw==} + engines: {node: '>= 20'} + hasBin: true + + '@vscode/webview-ui-toolkit@1.4.0': + resolution: {integrity: sha512-modXVHQkZLsxgmd5yoP3ptRC/G8NBDD+ob+ngPiWNQdlrH6H1xR/qgOBD85bfU3BhOB5sZzFWBwwhp9/SfoHww==} + peerDependencies: + react: '>=16.9.0' + + '@webassemblyjs/ast@1.12.1': + resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} + + '@webassemblyjs/floating-point-hex-parser@1.11.6': + resolution: {integrity: sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==} + + '@webassemblyjs/helper-api-error@1.11.6': + resolution: {integrity: sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==} + + '@webassemblyjs/helper-buffer@1.12.1': + resolution: {integrity: sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==} + + '@webassemblyjs/helper-numbers@1.11.6': + resolution: {integrity: sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==} + + '@webassemblyjs/helper-wasm-bytecode@1.11.6': + resolution: {integrity: sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==} + + '@webassemblyjs/helper-wasm-section@1.12.1': + resolution: {integrity: sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==} + + '@webassemblyjs/ieee754@1.11.6': + resolution: {integrity: sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==} + + '@webassemblyjs/leb128@1.11.6': + resolution: {integrity: sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==} + + '@webassemblyjs/utf8@1.11.6': + resolution: {integrity: sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==} + + '@webassemblyjs/wasm-edit@1.12.1': + resolution: {integrity: sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==} + + '@webassemblyjs/wasm-gen@1.12.1': + resolution: {integrity: sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==} + + '@webassemblyjs/wasm-opt@1.12.1': + resolution: {integrity: sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==} + + '@webassemblyjs/wasm-parser@1.12.1': + resolution: {integrity: sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==} + + '@webassemblyjs/wast-printer@1.12.1': + resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} + + '@webpack-cli/configtest@2.1.1': + resolution: {integrity: sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + + '@webpack-cli/info@2.0.2': + resolution: {integrity: sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + + '@webpack-cli/serve@2.0.5': + resolution: {integrity: sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + webpack-dev-server: '*' + peerDependenciesMeta: + webpack-dev-server: + optional: true + + '@xmldom/xmldom@0.7.13': + resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} + engines: {node: '>=10.0.0'} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.1: + resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} + engines: {node: '>= 14'} + + agentkeepalive@4.5.0: + resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==} + engines: {node: '>= 8.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + anti-trojan-source@1.4.1: + resolution: {integrity: sha512-DruSp30RgiEW36/n5+e2RtJf2W57jBS01YHvH8SL1vSFIpIeArfreTCxelHPMEhGLpk/BZUeA3uWt5AeTCHq9g==} + engines: {node: '>=14.0.0'} + hasBin: true + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + aproba@2.0.0: + resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + + are-we-there-yet@3.0.1: + resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.1: + resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} + engines: {node: '>= 0.4'} + + array-find-index@1.0.2: + resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} + engines: {node: '>=0.10.0'} + + array-includes@3.1.8: + resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} + engines: {node: '>= 0.4'} + + array-union@1.0.2: + resolution: {integrity: sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==} + engines: {node: '>=0.10.0'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array-union@3.0.1: + resolution: {integrity: sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==} + engines: {node: '>=12'} + + array-uniq@1.0.3: + resolution: {integrity: sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==} + engines: {node: '>=0.10.0'} + + array.prototype.findlastindex@1.2.5: + resolution: {integrity: sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.2: + resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.2: + resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} + engines: {node: '>= 0.4'} + + arrify@1.0.1: + resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} + engines: {node: '>=0.10.0'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + atoa@1.0.0: + resolution: {integrity: sha512-VVE1H6cc4ai+ZXo/CRWoJiHXrA1qfA31DPnx6D20+kSI547hQN5Greh51LQ1baMRMfxO5K5M4ImMtZbZt2DODQ==} + + autoprefixer@10.4.20: + resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + azure-devops-node-api@12.5.0: + resolution: {integrity: sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==} + + b4a@1.6.6: + resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bare-events@2.4.2: + resolution: {integrity: sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==} + + bare-fs@2.3.5: + resolution: {integrity: sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==} + + bare-os@2.4.4: + resolution: {integrity: sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==} + + bare-path@2.1.3: + resolution: {integrity: sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==} + + bare-stream@2.3.0: + resolution: {integrity: sha512-pVRWciewGUeCyKEuRxwv06M079r+fRjAQjBEK2P6OYGrO43O+Z0LrPZZEjlc4mB6C2RpZ9AxJ1s7NLEtOHO6eA==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + big.js@5.2.2: + resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + + billboard.js@3.13.0: + resolution: {integrity: sha512-zTvDlCRxaxZtgIpook29V87AsVG97CYRmqJJGNwyJS2cT2g43vIhBOksmpsUmF00hppqqinTjiBjE3qE+48mZQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bl@5.1.0: + resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + + browserify-zlib@0.1.4: + resolution: {integrity: sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==} + + browserslist@4.23.3: + resolution: {integrity: sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-builder@0.2.0: + resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bufferstreams@3.0.0: + resolution: {integrity: sha512-Qg0ggJUWJq90vtg4lDsGN9CDWvzBMQxhiEkSOD/sJfYt6BLect3eV1/S6K7SCSKJ34n60rf6U5eUPmQENVE4UA==} + engines: {node: '>=8.12.0'} + + c8@9.1.0: + resolution: {integrity: sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==} + engines: {node: '>=14.14.0'} + hasBin: true + + cacache@16.1.3: + resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + cache-content-type@1.0.1: + resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==} + engines: {node: '>= 6.0.0'} + + call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + + camelcase-keys@7.0.2: + resolution: {integrity: sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==} + engines: {node: '>=12'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + + caniuse-lite@1.0.30001663: + resolution: {integrity: sha512-o9C3X27GLKbLeTYZ6HBOLU1tsAcBZsLis28wrVzddShCS16RujjHp9GDHKZqrB3meE0YjhawvMFsGb/igqiPzA==} + + case@1.6.3: + resolution: {integrity: sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ==} + engines: {node: '>= 0.8.0'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.0.0: + resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==} + engines: {node: '>=18.17'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.1: + resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} + engines: {node: '>= 14.16.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + circular-dependency-plugin@5.2.2: + resolution: {integrity: sha512-g38K9Cm5WRwlaH6g03B9OEz/0qRizI+2I7n+Gz+L5DxXJAPAiWQvwlYNm1V1jkdpUv95bOe/ASm2vfi/G560jQ==} + engines: {node: '>=6.0.0'} + peerDependencies: + webpack: '>=4.0.1' + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + clean-webpack-plugin@4.0.0: + resolution: {integrity: sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w==} + engines: {node: '>=10.0.0'} + peerDependencies: + webpack: '>=4.0.0 <6.0.0' + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + cockatiel@3.2.1: + resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==} + engines: {node: '>=16'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + composed-offset-position@0.0.4: + resolution: {integrity: sha512-vMlvu1RuNegVE0YsCDSV/X4X10j56mq7PCIyOKK74FxkXzGLwhOUmdkJLSdOBOMwWycobGUMgft2lp+YgTe8hw==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + contra@1.9.4: + resolution: {integrity: sha512-N9ArHAqwR/lhPq4OdIAwH4e1btn6EIZMAz4TazjnzCiVECcWUPTma+dRAM38ERImEJBh8NiCCpjoQruSZ+agYg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookies@0.9.1: + resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + engines: {node: '>= 0.8'} + + copy-webpack-plugin@12.0.2: + resolution: {integrity: sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + webpack: ^5.1.0 + + core-js@2.6.12: + resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cosmiconfig@6.0.0: + resolution: {integrity: sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==} + engines: {node: '>=8'} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + crossvent@1.5.4: + resolution: {integrity: sha512-b6gEmNAh3kemyfNJ0LQzA/29A+YeGwevlSkNp2x0TzLOMYc0b85qRAD06OUuLWLQpR7HdJHNZQTlD1cfwoTrzg==} + + csp-html-webpack-plugin@5.1.0: + resolution: {integrity: sha512-6l/s6hACE+UA01PLReNKZfgLZWM98f7ewWmE79maDWIbEXiPcIWQGB3LQR/Zw+hPBj4XPZZ5zNrrO+aygqaLaQ==} + peerDependencies: + html-webpack-plugin: ^4 || ^5 + webpack: ^4 || ^5 + + css-declaration-sorter@7.2.0: + resolution: {integrity: sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.0.9 + + css-loader@7.1.2: + resolution: {integrity: sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@rspack/core': 0.x || 1.x + webpack: ^5.27.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true + + css-minimizer-webpack-plugin@7.0.0: + resolution: {integrity: sha512-niy66jxsQHqO+EYbhPuIhqRQ1mNcNVUHrMnkzzir9kFOERJUaQDDRhh7dKDz33kBpkWMF9M8Vx0QlDbc5AHOsw==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@parcel/css': '*' + '@swc/css': '*' + clean-css: '*' + csso: '*' + esbuild: '*' + lightningcss: '*' + webpack: ^5.0.0 + peerDependenciesMeta: + '@parcel/css': + optional: true + '@swc/css': + optional: true + clean-css: + optional: true + csso: + optional: true + esbuild: + optional: true + lightningcss: + optional: true + + css-select@4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} + + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssnano-preset-advanced@7.0.6: + resolution: {integrity: sha512-wk/YPSv965EjpPNEGteiXZ32BKilJcYNnX4EGUd/AriVGgHL/y59uaWVJ/ZDx69jCNUrmwiBzioCV+SG5wk3PQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + cssnano-preset-default@7.0.6: + resolution: {integrity: sha512-ZzrgYupYxEvdGGuqL+JKOY70s7+saoNlHSCK/OGn1vB2pQK8KSET8jvenzItcY+kA7NoWvfbb/YhlzuzNKjOhQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + cssnano-utils@5.0.0: + resolution: {integrity: sha512-Uij0Xdxc24L6SirFr25MlwC2rCFX6scyUmuKpzI+JQ7cyqDEwD42fJ0xfB3yLfOnRDU5LKGgjQ9FA6LYh76GWQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + cssnano@7.0.6: + resolution: {integrity: sha512-54woqx8SCbp8HwvNZYn68ZFAepuouZW4lTwiMVnBErM3VkO7/Sd4oTOt3Zz3bPx3kxQ36aISppyXj2Md4lg8bw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + cubic2quad@1.2.1: + resolution: {integrity: sha512-wT5Y7mO8abrV16gnssKdmIhIbA9wSkeMzhh27jAguKrV82i24wER0vL5TGhUJ9dbJNDcigoRZ0IAHFEEEI4THQ==} + + custom-event@1.0.0: + resolution: {integrity: sha512-6nOXX3UitrmdvSJWoVR2dlzhbX5bEUqmqsMUyx1ypCLZkHHkcuYtdpW3p94RGvcFkTV7DkLo+Ilbwnlwi8L+jw==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} + engines: {node: '>= 0.4'} + + debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize-keys@1.1.1: + resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} + engines: {node: '>=0.10.0'} + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + + decamelize@5.0.1: + resolution: {integrity: sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==} + engines: {node: '>=10'} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + del@4.1.1: + resolution: {integrity: sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + + diff@5.2.0: + resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} + engines: {node: '>=0.3.1'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-converter@0.2.0: + resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} + + dom-helpers@3.4.0: + resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + + domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + + dragula@3.7.2: + resolution: {integrity: sha512-iDPdNTPZY7P/l0CQ800QiX+PNA2XF9iC3ePLWfGxeb/j8iPPedRuQdfSOfZrazgSpmaShYvYQ/jx7keWb4YNzA==} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.27: + resolution: {integrity: sha512-o37j1vZqCoEgBuWWXLHQgTN/KDKe7zwpiY5CPeq2RvUqOyJw9xnrULzZAEVQ5p4h+zjMk7hgtOoPdnLxr7m/jw==} + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + emojis-list@3.0.0: + resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} + engines: {node: '>= 4'} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encoding-sniffer@0.2.0: + resolution: {integrity: sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + + enhanced-resolve@5.17.1: + resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} + engines: {node: '>=10.13.0'} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + envinfo@7.14.0: + resolution: {integrity: sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==} + engines: {node: '>=4'} + hasBin: true + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + + es-abstract@1.23.3: + resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.5.4: + resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} + + es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.0.2: + resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} + + es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + + esbuild-loader@4.2.2: + resolution: {integrity: sha512-Mdq/A1L8p37hkibp8jGFwuQTDSWhDmlueAefsrCPRwNWThEOlQmIglV7Gd6GE2mO5bt7ksfxKOMwkuY7jjVTXg==} + peerDependencies: + webpack: ^4.40.0 || ^5.0.0 + + esbuild-node-externals@1.14.0: + resolution: {integrity: sha512-jMWnTlCII3cLEjR5+u0JRSTJuP+MgbjEHKfwSIAI41NgLQ0ZjfzjchlbEn0r7v2u5gCBMSEYvYlkO7GDG8gG3A==} + engines: {node: '>=12'} + peerDependencies: + esbuild: 0.23.1 + + esbuild-sass-plugin@3.3.1: + resolution: {integrity: sha512-SnO1ls+d52n6j8gRRpjexXI8MsHEaumS0IdDHaYM29Y6gakzZYMls6i9ql9+AWMSQk/eryndmUpXEgT34QrX1A==} + peerDependencies: + esbuild: 0.23.1 + sass-embedded: ^1.71.1 + + esbuild@0.23.1: + resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-import-resolver-typescript@3.6.3: + resolution: {integrity: sha512-ud9aw4szY9cCT1EWWdGv1L1XR6hh2PaRWif0j2QjQ0pgTY/69iw+W0Z4qZv5wHahOl8isEr+k/JnyAqNQkLkIA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.11.1: + resolution: {integrity: sha512-EwcbfLOhwVMAfatfqLecR2yv3dE5+kQ8kx+Rrt0DvDXEVwW86KQ/xbMDQhtp5l42VXukD5SOF8mQQHbaNtO0CQ==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-anti-trojan-source@1.1.1: + resolution: {integrity: sha512-gWDuG2adNNccwRM+2/Q3UHqV1DgrAUSpSi/Tdnx2Ybr0ndWMSBn7lt4AbxdPuFSEs2OAokX/vdIHbBbTLzWspw==} + + eslint-plugin-import-x@4.3.0: + resolution: {integrity: sha512-PxGzP7gAjF2DLeRnQtbYkkgZDg1intFyYr/XS1LgTYXUDrSXMHGkXx8++6i2eDv2jMs0jfeO6G6ykyeWxiFX7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + + eslint-plugin-import@2.29.1: + resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-lit@1.15.0: + resolution: {integrity: sha512-Yhr2MYNz6Ln8megKcX503aVZQln8wsywCG49g0heiJ/Qr5UjkE4pGr4Usez2anNcc7NvlvHbQWMYwWcgH3XRKA==} + engines: {node: '>= 12'} + peerDependencies: + eslint: '>= 5' + + eslint-plugin-wc@2.1.1: + resolution: {integrity: sha512-GfJo05ZgWfwAFbW6Gkf+9CMOIU6fmbd3b4nm+PKESHgUdUTmi7vawlELCrzOhdiQjXUPZxDfFIVxYt9D/v/GdQ==} + peerDependencies: + eslint: '>=5' + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@8.0.2: + resolution: {integrity: sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.0.0: + resolution: {integrity: sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.11.1: + resolution: {integrity: sha512-MobhYKIoAO1s1e4VUrgx1l1Sk2JBR/Gqjjgw8+mfgoLE2xwsHur4gdfTxyTgShrhvdVFTaJSgMiQBl1jv/AWxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.1.0: + resolution: {integrity: sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + exenv-es6@1.1.1: + resolution: {integrity: sha512-vlVu3N8d6yEMpMsEm+7sUBAI81aqYYuEvfK0jNqmdb/OPXzzH7QWDDnVjMvDSY47JdHEqx/dfC/q8WkfoTmpGQ==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + exponential-backoff@3.1.1: + resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.0.1: + resolution: {integrity: sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + + for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@6.5.3: + resolution: {integrity: sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ==} + engines: {node: '>=10', yarn: '>=1.0.0'} + peerDependencies: + eslint: '>= 6' + typescript: '>= 2.7' + vue-template-compiler: '*' + webpack: '>= 4' + peerDependenciesMeta: + eslint: + optional: true + vue-template-compiler: + optional: true + + form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs-monkey@1.0.6: + resolution: {integrity: sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gauge@4.0.4: + resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.8.1: + resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@11.0.0: + resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} + engines: {node: 20 || >=22} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.9.0: + resolution: {integrity: sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globby@12.2.0: + resolution: {integrity: sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + globby@14.0.2: + resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==} + engines: {node: '>=18'} + + globby@6.1.0: + resolution: {integrity: sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==} + engines: {node: '>=0.10.0'} + + gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + gunzip-maybe@1.4.2: + resolution: {integrity: sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==} + hasBin: true + + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + hard-rejection@2.1.0: + resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} + engines: {node: '>=6'} + + has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + hosted-git-info@6.1.1: + resolution: {integrity: sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-loader@5.1.0: + resolution: {integrity: sha512-Jb3xwDbsm0W3qlXrCZwcYqYGnYz55hb6aoKQTlzyZPXsPpi6tHXzAfqalecglMQgNvtEfxrCQPaKT90Irt5XDA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + webpack: ^5.0.0 + + html-minifier-terser@6.1.0: + resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==} + engines: {node: '>=12'} + hasBin: true + + html-minifier-terser@7.2.0: + resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==} + engines: {node: ^14.13.1 || >=16.0.0} + hasBin: true + + html-webpack-plugin@5.6.0: + resolution: {integrity: sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw==} + engines: {node: '>=10.13.0'} + peerDependencies: + '@rspack/core': 0.x || 1.x + webpack: ^5.20.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true + + htmlparser2@6.1.0: + resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} + + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + + http-assert@1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + + http-cache-semantics@4.1.1: + resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + + http-errors@1.6.3: + resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==} + engines: {node: '>= 0.6'} + + http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.5: + resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} + engines: {node: '>= 14'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + icss-utils@5.1.0: + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + image-minimizer-webpack-plugin@4.1.0: + resolution: {integrity: sha512-HZwGd1CxApD3Vi+/k1Vz+Lksooz2TIkCcfejhCkqUfHPRByIYY0HD5RvsxTAMU8nlom+WeknF6f7+2ZQSKiw2g==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@squoosh/lib': '*' + imagemin: '*' + sharp: '*' + svgo: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@squoosh/lib': + optional: true + imagemin: + optional: true + sharp: + optional: true + svgo: + optional: true + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + immutable@4.3.7: + resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + infer-owner@1.0.4: + resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.3: + resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + internal-slot@1.0.7: + resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} + engines: {node: '>= 0.4'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + ip-address@9.0.5: + resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} + engines: {node: '>= 12'} + + is-array-buffer@3.0.4: + resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + + is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + + is-bun-module@1.2.1: + resolution: {integrity: sha512-AmidtEM6D6NmUiLOvvU7+IePxjEjOzra2h0pSrsfSAcXwl/83zLLXDByafUJy9k/rKK0pvXMLdwKwGHlX2Ke6Q==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} + engines: {node: '>= 0.4'} + + is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + + is-deflate@1.0.0: + resolution: {integrity: sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.0.10: + resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-gzip@1.0.0: + resolution: {integrity: sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ==} + engines: {node: '>=0.10.0'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-lambda@1.0.1: + resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-cwd@2.2.0: + resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} + engines: {node: '>=6'} + + is-path-in-cwd@2.1.0: + resolution: {integrity: sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==} + engines: {node: '>=6'} + + is-path-inside@2.1.0: + resolution: {integrity: sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==} + engines: {node: '>=6'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-plain-obj@1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} + engines: {node: '>=0.10.0'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} + engines: {node: '>= 0.4'} + + is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + + is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} + engines: {node: '>= 0.4'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-valid-element-name@1.0.0: + resolution: {integrity: sha512-GZITEJY2LkSjQfaIPBha7eyZv+ge0PhBR7KITeCCWvy7VBQrCUdFkvpI+HrAPQjVtVjy1LvlEkqQTHckoszruw==} + + is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jackspeak@4.0.2: + resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==} + engines: {node: 20 || >=22} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + js-base64@3.7.5: + resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==} + + js-levenshtein-esm@1.2.0: + resolution: {integrity: sha512-fzreKVq1eD7eGcQr7MtRpQH94f8gIfhdrc7yeih38xh684TNMK9v5aAu2wxfIRMk/GpAJRrzcirMAPIaSDaByQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsbn@1.1.0: + resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-parse-even-better-errors@3.0.2: + resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + + jwa@2.0.0: + resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + + jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + + keycode@2.2.1: + resolution: {integrity: sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==} + + keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + + keytar@7.9.0: + resolution: {integrity: sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + + koa-convert@2.0.0: + resolution: {integrity: sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==} + engines: {node: '>= 10'} + + koa-morgan@1.0.1: + resolution: {integrity: sha512-JOUdCNlc21G50afBXfErUrr1RKymbgzlrO5KURY+wmDG1Uvd2jmxUJcHgylb/mYXy2SjiNZyYim/ptUBGsIi3A==} + + koa-mount@4.0.0: + resolution: {integrity: sha512-rm71jaA/P+6HeCpoRhmCv8KVBIi0tfGuO/dMKicbQnQW/YJntJ6MnnspkodoA4QstMVEZArsCphmd0bJEtoMjQ==} + engines: {node: '>= 7.6.0'} + + koa-send@5.0.1: + resolution: {integrity: sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==} + engines: {node: '>= 8'} + + koa-static@5.0.0: + resolution: {integrity: sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==} + engines: {node: '>= 7.6.0'} + + koa@2.15.3: + resolution: {integrity: sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==} + engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + license-checker-rseidelsohn@4.4.2: + resolution: {integrity: sha512-Sf8WaJhd2vELvCne+frS9AXqnY/vv591s2/nZcJDwTnoNgltG4mAmoenffVb8L2YPRYbxARLyrHJBC38AVfpuA==} + engines: {node: '>=18', npm: '>=8'} + hasBin: true + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lilconfig@3.1.2: + resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + lit-element@4.1.0: + resolution: {integrity: sha512-gSejRUQJuMQjV2Z59KAS/D4iElUhwKpIyJvZ9w+DIagIQjfJnhR20h2Q5ddpzXGS+fF0tMZ/xEYGMnKmaI/iww==} + + lit-html@3.2.0: + resolution: {integrity: sha512-pwT/HwoxqI9FggTrYVarkBKFN9MlTUpLrDHubTmW4SrkL3kkqW5gxwbxMMUnbbRHBC0WTZnYHcjDSCM559VyfA==} + + lit@3.2.0: + resolution: {integrity: sha512-s6tI33Lf6VpDu7u4YqsSX78D28bYQulM+VAzsGch4fx2H0eLZnJsUBsPWmGYSGoKDNbjtRv02rio1o+UdPVwvw==} + + loader-runner@4.3.0: + resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} + engines: {node: '>=6.11.5'} + + loader-utils@2.0.4: + resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} + engines: {node: '>=8.9.0'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-symbols@5.1.0: + resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==} + engines: {node: '>=12'} + + long@5.2.3: + resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.0.1: + resolution: {integrity: sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ==} + engines: {node: 20 || >=22} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-fetch-happen@10.2.1: + resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + map-obj@1.0.1: + resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} + engines: {node: '>=0.10.0'} + + map-obj@4.3.0: + resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} + engines: {node: '>=8'} + + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + + marked@14.1.2: + resolution: {integrity: sha512-f3r0yqpz31VXiDB/wj9GaOB0a2PRLQl6vJmXiFrniNwjkKdvakqJRULhjFKJpxOchlCRiG5fcacoUZY5Xa6PEQ==} + engines: {node: '>= 18'} + hasBin: true + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + meow@10.1.5: + resolution: {integrity: sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + microbuffer@1.0.0: + resolution: {integrity: sha512-O/SUXauVN4x6RaEJFqSPcXNtLFL+QzJHKZlyDVYFwcDDRVca3Fa/37QXXC+4zAGGa4YhHrHxKXuuHvLDIQECtA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + mini-css-extract-plugin@2.9.1: + resolution: {integrity: sha512-+Vyi+GCCOHnrJ2VPS+6aPoXN2k2jgUzDRhTFLjjTBn23qyXJXkjUWQgTL+mXpF5/A8ixLdCc6kWsoeOjKGejKQ==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^5.0.0 + + minimatch@10.0.1: + resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} + engines: {node: 20 || >=22} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist-options@4.1.0: + resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} + engines: {node: '>= 6'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@1.0.2: + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} + + minipass-fetch@2.1.2: + resolution: {integrity: sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + minipass-flush@1.0.5: + resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + mocha@10.7.3: + resolution: {integrity: sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A==} + engines: {node: '>= 14.0.0'} + hasBin: true + + morgan@1.10.0: + resolution: {integrity: sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==} + engines: {node: '>= 0.8.0'} + + mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + mylas@2.1.13: + resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==} + engines: {node: '>=12.0.0'} + + nan@2.20.0: + resolution: {integrity: sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==} + + nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@1.0.2: + resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + + node-abi@3.68.0: + resolution: {integrity: sha512-7vbj10trelExNjFSBm5kTvZXXa7pZyKWx9RCKIyqe6I9Ev3IzGpQoqBP3a+cOdxY+pWj6VkP28n/2wWysBHD/A==} + engines: {node: '>=10'} + + node-addon-api@4.3.0: + resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp@9.4.1: + resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} + engines: {node: ^12.13 || ^14.13 || >=16} + hasBin: true + + node-releases@2.0.18: + resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + + nopt@6.0.0: + resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + hasBin: true + + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + normalize-package-data@3.0.3: + resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} + engines: {node: '>=10'} + + normalize-package-data@5.0.0: + resolution: {integrity: sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + npm-normalize-package-bin@3.0.1: + resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npmlog@6.0.2: + resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.2: + resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.5: + resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.0: + resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + only@0.0.2: + resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@7.0.1: + resolution: {integrity: sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==} + engines: {node: '>=16'} + + os-browserify@0.3.0: + resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.0: + resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} + + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-semver@1.1.1: + resolution: {integrity: sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==} + + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5-htmlparser2-tree-adapter@7.0.0: + resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + + parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + path-type@5.0.0: + resolution: {integrity: sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==} + engines: {node: '>=12'} + + peek-stream@1.1.3: + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.0: + resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pinkie-promise@2.0.1: + resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} + engines: {node: '>=0.10.0'} + + pinkie@2.0.4: + resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} + engines: {node: '>=0.10.0'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + playwright-core@1.47.1: + resolution: {integrity: sha512-i1iyJdLftqtt51mEk6AhYFaAJCDx0xQ/O5NU8EKaWFgMjItPVma542Nh/Aq8aLCjIJSzjaiEQGW/nyqLkGF1OQ==} + engines: {node: '>=18'} + hasBin: true + + playwright-core@1.47.2: + resolution: {integrity: sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.47.1: + resolution: {integrity: sha512-SUEKi6947IqYbKxRiqnbUobVZY4bF1uu+ZnZNJX9DfU1tlf2UhWfvVjLf01pQx9URsOr18bFVUKXmanYWhbfkw==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.47.2: + resolution: {integrity: sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==} + engines: {node: '>=18'} + hasBin: true + + plimit-lit@1.6.1: + resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} + engines: {node: '>=12'} + + possible-typed-array-names@1.0.0: + resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} + engines: {node: '>= 0.4'} + + postcss-calc@10.0.2: + resolution: {integrity: sha512-DT/Wwm6fCKgpYVI7ZEWuPJ4az8hiEHtCUeYjZXqU7Ou4QqYh1Df2yCQ7Ca6N7xqKPFkxN3fhf+u9KSoOCJNAjg==} + engines: {node: ^18.12 || ^20.9 || >=22.0} + peerDependencies: + postcss: ^8.4.38 + + postcss-colormin@7.0.2: + resolution: {integrity: sha512-YntRXNngcvEvDbEjTdRWGU606eZvB5prmHG4BF0yLmVpamXbpsRJzevyy6MZVyuecgzI2AWAlvFi8DAeCqwpvA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-convert-values@7.0.4: + resolution: {integrity: sha512-e2LSXPqEHVW6aoGbjV9RsSSNDO3A0rZLCBxN24zvxF25WknMPpX8Dm9UxxThyEbaytzggRuZxaGXqaOhxQ514Q==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-comments@7.0.3: + resolution: {integrity: sha512-q6fjd4WU4afNhWOA2WltHgCbkRhZPgQe7cXF74fuVB/ge4QbM9HEaOIzGSiMvM+g/cOsNAUGdf2JDzqA2F8iLA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-duplicates@7.0.1: + resolution: {integrity: sha512-oZA+v8Jkpu1ct/xbbrntHRsfLGuzoP+cpt0nJe5ED2FQF8n8bJtn7Bo28jSmBYwqgqnqkuSXJfSUEE7if4nClQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-empty@7.0.0: + resolution: {integrity: sha512-e+QzoReTZ8IAwhnSdp/++7gBZ/F+nBq9y6PomfwORfP7q9nBpK5AMP64kOt0bA+lShBFbBDcgpJ3X4etHg4lzA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-overridden@7.0.0: + resolution: {integrity: sha512-GmNAzx88u3k2+sBTZrJSDauR0ccpE24omTQCVmaTTZFz1du6AasspjaUPMJ2ud4RslZpoFKyf+6MSPETLojc6w==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-unused@7.0.3: + resolution: {integrity: sha512-OVxIMVMjkJ1anDTbHFSYUZnmoDWv3vF5JPZvr9hi6HjMNH/RjfR39IMeLThbIjrSb9ZLcwzqziU+XxFQkgF4Vw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-merge-idents@7.0.0: + resolution: {integrity: sha512-Kr+DniMg0IsW7OGoaMB1Foreb3fIE2XcExCRynogQLngkpNVKTX5GlaxyEZDBB8bISeoztFHFK/GcQtFiPTnpQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-merge-longhand@7.0.4: + resolution: {integrity: sha512-zer1KoZA54Q8RVHKOY5vMke0cCdNxMP3KBfDerjH/BYHh4nCIh+1Yy0t1pAEQF18ac/4z3OFclO+ZVH8azjR4A==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-merge-rules@7.0.4: + resolution: {integrity: sha512-ZsaamiMVu7uBYsIdGtKJ64PkcQt6Pcpep/uO90EpLS3dxJi6OXamIobTYcImyXGoW0Wpugh7DSD3XzxZS9JCPg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-font-values@7.0.0: + resolution: {integrity: sha512-2ckkZtgT0zG8SMc5aoNwtm5234eUx1GGFJKf2b1bSp8UflqaeFzR50lid4PfqVI9NtGqJ2J4Y7fwvnP/u1cQog==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-gradients@7.0.0: + resolution: {integrity: sha512-pdUIIdj/C93ryCHew0UgBnL2DtUS3hfFa5XtERrs4x+hmpMYGhbzo6l/Ir5de41O0GaKVpK1ZbDNXSY6GkXvtg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-params@7.0.2: + resolution: {integrity: sha512-nyqVLu4MFl9df32zTsdcLqCFfE/z2+f8GE1KHPxWOAmegSo6lpV2GNy5XQvrzwbLmiU7d+fYay4cwto1oNdAaQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-selectors@7.0.4: + resolution: {integrity: sha512-JG55VADcNb4xFCf75hXkzc1rNeURhlo7ugf6JjiiKRfMsKlDzN9CXHZDyiG6x/zGchpjQS+UAgb1d4nqXqOpmA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-modules-extract-imports@3.1.0: + resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-local-by-default@4.0.5: + resolution: {integrity: sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-scope@3.2.0: + resolution: {integrity: sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-values@4.0.0: + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-normalize-charset@7.0.0: + resolution: {integrity: sha512-ABisNUXMeZeDNzCQxPxBCkXexvBrUHV+p7/BXOY+ulxkcjUZO0cp8ekGBwvIh2LbCwnWbyMPNJVtBSdyhM2zYQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-display-values@7.0.0: + resolution: {integrity: sha512-lnFZzNPeDf5uGMPYgGOw7v0BfB45+irSRz9gHQStdkkhiM0gTfvWkWB5BMxpn0OqgOQuZG/mRlZyJxp0EImr2Q==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-positions@7.0.0: + resolution: {integrity: sha512-I0yt8wX529UKIGs2y/9Ybs2CelSvItfmvg/DBIjTnoUSrPxSV7Z0yZ8ShSVtKNaV/wAY+m7bgtyVQLhB00A1NQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-repeat-style@7.0.0: + resolution: {integrity: sha512-o3uSGYH+2q30ieM3ppu9GTjSXIzOrRdCUn8UOMGNw7Af61bmurHTWI87hRybrP6xDHvOe5WlAj3XzN6vEO8jLw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-string@7.0.0: + resolution: {integrity: sha512-w/qzL212DFVOpMy3UGyxrND+Kb0fvCiBBujiaONIihq7VvtC7bswjWgKQU/w4VcRyDD8gpfqUiBQ4DUOwEJ6Qg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-timing-functions@7.0.0: + resolution: {integrity: sha512-tNgw3YV0LYoRwg43N3lTe3AEWZ66W7Dh7lVEpJbHoKOuHc1sLrzMLMFjP8SNULHaykzsonUEDbKedv8C+7ej6g==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-unicode@7.0.2: + resolution: {integrity: sha512-ztisabK5C/+ZWBdYC+Y9JCkp3M9qBv/XFvDtSw0d/XwfT3UaKeW/YTm/MD/QrPNxuecia46vkfEhewjwcYFjkg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-url@7.0.0: + resolution: {integrity: sha512-+d7+PpE+jyPX1hDQZYG+NaFD+Nd2ris6r8fPTBAjE8z/U41n/bib3vze8x7rKs5H1uEw5ppe9IojewouHk0klQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-whitespace@7.0.0: + resolution: {integrity: sha512-37/toN4wwZErqohedXYqWgvcHUGlT8O/m2jVkAfAe9Bd4MzRqlBmXrJRePH0e9Wgnz2X7KymTgTOaaFizQe3AQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-ordered-values@7.0.1: + resolution: {integrity: sha512-irWScWRL6nRzYmBOXReIKch75RRhNS86UPUAxXdmW/l0FcAsg0lvAXQCby/1lymxn/o0gVa6Rv/0f03eJOwHxw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-reduce-idents@7.0.0: + resolution: {integrity: sha512-ghFHqxigYW/bbfr+bXSDB5Tv3qPaYZZxiQh+Gne0NYRlTOzFft1V/DUvGFVJbFkackHleSjFdVXdlNB+5f3mKg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-reduce-initial@7.0.2: + resolution: {integrity: sha512-pOnu9zqQww7dEKf62Nuju6JgsW2V0KRNBHxeKohU+JkHd/GAH5uvoObqFLqkeB2n20mr6yrlWDvo5UBU5GnkfA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-reduce-transforms@7.0.0: + resolution: {integrity: sha512-pnt1HKKZ07/idH8cpATX/ujMbtOGhUfE+m8gbqwJE05aTaNw8gbo34a2e3if0xc0dlu75sUOiqvwCGY3fzOHew==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-svgo@7.0.1: + resolution: {integrity: sha512-0WBUlSL4lhD9rA5k1e5D8EN5wCEyZD6HJk0jIvRxl+FDVOMlJ7DePHYWGGVc5QRqrJ3/06FTXM0bxjmJpmTPSA==} + engines: {node: ^18.12.0 || ^20.9.0 || >= 18} + peerDependencies: + postcss: ^8.4.31 + + postcss-unique-selectors@7.0.3: + resolution: {integrity: sha512-J+58u5Ic5T1QjP/LDV9g3Cx4CNOgB5vz+kM6+OxHHhFACdcDeKhBXjQmB7fnIZM12YSTvsL0Opwco83DmacW2g==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss-zindex@7.0.0: + resolution: {integrity: sha512-Agp+5C0qBZxT9S4k9iO/C9oqce3gvPJ/7av4JcAsDl17vsboSN60ncTokIYDtDMlVXvwuhFED3edoy1YG5O1+g==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + postcss@8.4.47: + resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.2: + resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==} + engines: {node: '>=10'} + hasBin: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.1.0: + resolution: {integrity: sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==} + engines: {node: '>=14'} + hasBin: true + + pretty-error@4.0.0: + resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + promise-inflight@1.0.1: + resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + prop-types-extra@1.1.1: + resolution: {integrity: sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==} + peerDependencies: + react: '>=0.14.0' + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + protobufjs@7.4.0: + resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==} + engines: {node: '>=12.0.0'} + + pump@2.0.1: + resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==} + + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + + pumpify@1.5.1: + resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qr-creator@1.0.0: + resolution: {integrity: sha512-C0cqfbS1P5hfqN4NhsYsUXePlk9BO+a45bAQ3xLYjBL3bOIFzoVEjs79Fado9u9BPBD3buHi3+vY+C8tHh4qMQ==} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + queue-lit@1.5.2: + resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} + engines: {node: '>=12'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + queue-tick@1.0.1: + resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + re-resizable@6.9.11: + resolution: {integrity: sha512-a3hiLWck/NkmyLvGWUuvkAmN1VhwAz4yOhS6FdMTaxCUVN9joIWkT11wsO68coG/iEYuwn+p/7qAmfQzRhiPLQ==} + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 + + react-bootstrap@0.32.4: + resolution: {integrity: sha512-xj+JfaPOvnvr3ow0aHC7Y3HaBKZNR1mm361hVxVzVX3fcdJNIrfiodbQ0m9nLBpNxiKG6FTU2lq/SbTDYT2vew==} + peerDependencies: + react: ^0.14.9 || >=15.3.0 + react-dom: ^0.14.9 || >=15.3.0 + + react-dom@16.8.4: + resolution: {integrity: sha512-Ob2wK7XG2tUDt7ps7LtLzGYYB6DXMCLj0G5fO6WeEICtT4/HdpOi7W/xLzZnR6RCG1tYza60nMdqtxzA8FaPJQ==} + peerDependencies: + react: ^16.0.0 + + react-dragula@1.1.17: + resolution: {integrity: sha512-gJdY190sPWAyV8jz79vyK9SGk97bVOHjUguVNIYIEVosvt27HLxnbJo4qiuEkb/nAuGY13Im2CHup92fUyO3fw==} + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-lifecycles-compat@3.0.4: + resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} + + react-onclickoutside@6.13.0: + resolution: {integrity: sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A==} + peerDependencies: + react: ^15.5.x || ^16.x || ^17.x || ^18.x + react-dom: ^15.5.x || ^16.x || ^17.x || ^18.x + + react-overlays@0.8.3: + resolution: {integrity: sha512-h6GT3jgy90PgctleP39Yu3eK1v9vaJAW73GOA/UbN9dJ7aAN4BTZD6793eI1D5U+ukMk17qiqN/wl3diK1Z5LA==} + peerDependencies: + react: ^0.14.9 || >=15.3.0 + react-dom: ^0.14.9 || >=15.3.0 + + react-prop-types@0.4.0: + resolution: {integrity: sha512-IyjsJhDX9JkoOV9wlmLaS7z+oxYoIWhfzDcFy7inwoAKTu+VcVNrVpPmLeioJ94y6GeDRsnwarG1py5qofFQMg==} + peerDependencies: + react: '>=0.14.0' + + react-transition-group@2.9.0: + resolution: {integrity: sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==} + peerDependencies: + react: '>=15.0.0' + react-dom: '>=15.0.0' + + react@16.8.4: + resolution: {integrity: sha512-0GQ6gFXfUH7aZcjGVymlPOASTuSjlQL4ZtVC5YKH+3JL6bBLCVO21DknzmaPlI90LN253ojj02nsapy+j7wIjg==} + engines: {node: '>=0.10.0'} + + read-installed-packages@2.0.1: + resolution: {integrity: sha512-t+fJOFOYaZIjBpTVxiV8Mkt7yQyy4E6MSrrnt5FmPd4enYvpU/9DYGirDmN1XQwkfeuWIhM/iu0t2rm6iSr0CA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + read-package-json@6.0.4: + resolution: {integrity: sha512-AEtWXYfopBj2z5N5PbkAOeNHRPUg5q+Nen7QLxV8M2zJq1ym6/lCz3fYNTCXe19puu2d06jfHhrP7v/S2PtMMw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + deprecated: This package is no longer supported. Please use @npmcli/package-json instead. + + read-pkg-up@8.0.0: + resolution: {integrity: sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==} + engines: {node: '>=12'} + + read-pkg@6.0.0: + resolution: {integrity: sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==} + engines: {node: '>=12'} + + read@1.0.7: + resolution: {integrity: sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==} + engines: {node: '>=0.8'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.0.1: + resolution: {integrity: sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==} + engines: {node: '>= 14.16.0'} + + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + + redent@4.0.0: + resolution: {integrity: sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==} + engines: {node: '>=12'} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + regexp.prototype.flags@1.5.2: + resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} + engines: {node: '>= 0.4'} + + relateurl@0.2.7: + resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} + engines: {node: '>= 0.10'} + + renderkid@3.0.0: + resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requireindex@1.2.0: + resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} + engines: {node: '>=0.10.5'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-path@1.4.0: + resolution: {integrity: sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==} + engines: {node: '>= 0.8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-identifier@0.4.2: + resolution: {integrity: sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==} + + safe-regex-test@1.0.3: + resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass-embedded-android-arm64@1.77.8: + resolution: {integrity: sha512-EmWHLbEx0Zo/f/lTFzMeH2Du+/I4RmSRlEnERSUKQWVp3aBSO04QDvdxfFezgQ+2Yt/ub9WMqBpma9P/8MPsLg==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [android] + hasBin: true + + sass-embedded-android-arm@1.77.8: + resolution: {integrity: sha512-GpGL7xZ7V1XpFbnflib/NWbM0euRzineK0iwoo31/ntWKAXGj03iHhGzkSiOwWSFcXgsJJi3eRA5BTmBvK5Q+w==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [android] + hasBin: true + + sass-embedded-android-ia32@1.77.8: + resolution: {integrity: sha512-+GjfJ3lDezPi4dUUyjQBxlNKXNa+XVWsExtGvVNkv1uKyaOxULJhubVo2G6QTJJU0esJdfeXf5Ca5/J0ph7+7w==} + engines: {node: '>=14.0.0'} + cpu: [ia32] + os: [android] + hasBin: true + + sass-embedded-android-x64@1.77.8: + resolution: {integrity: sha512-YZbFDzGe5NhaMCygShqkeCWtzjhkWxGVunc7ULR97wmxYPQLPeVyx7XFQZc84Aj0lKAJBJS4qRZeqphMqZEJsQ==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [android] + hasBin: true + + sass-embedded-darwin-arm64@1.77.8: + resolution: {integrity: sha512-aifgeVRNE+i43toIkDFFJc/aPLMo0PJ5s5hKb52U+oNdiJE36n65n2L8F/8z3zZRvCa6eYtFY2b7f1QXR3B0LA==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [darwin] + hasBin: true + + sass-embedded-darwin-x64@1.77.8: + resolution: {integrity: sha512-/VWZQtcWIOek60Zj6Sxk6HebXA1Qyyt3sD8o5qwbTgZnKitB1iEBuNunyGoAgMNeUz2PRd6rVki6hvbas9hQ6w==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [darwin] + hasBin: true + + sass-embedded-linux-arm64@1.77.8: + resolution: {integrity: sha512-6iIOIZtBFa2YfMsHqOb3qake3C9d/zlKxjooKKnTSo+6g6z+CLTzMXe1bOfayb7yxeenElmFoK1k54kWD/40+g==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + hasBin: true + + sass-embedded-linux-arm@1.77.8: + resolution: {integrity: sha512-2edZMB6jf0whx3T0zlgH+p131kOEmWp+I4wnKj7ZMUeokiY4Up05d10hSvb0Q63lOrSjFAWu6P5/pcYUUx8arQ==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + hasBin: true + + sass-embedded-linux-ia32@1.77.8: + resolution: {integrity: sha512-63GsFFHWN5yRLTWiSef32TM/XmjhCBx1DFhoqxmj+Yc6L9Z1h0lDHjjwdG6Sp5XTz5EmsaFKjpDgnQTP9hJX3Q==} + engines: {node: '>=14.0.0'} + cpu: [ia32] + os: [linux] + hasBin: true + + sass-embedded-linux-musl-arm64@1.77.8: + resolution: {integrity: sha512-j8cgQxNWecYK+aH8ESFsyam/Q6G+9gg8eJegiRVpA9x8yk3ykfHC7UdQWwUcF22ZcuY4zegrjJx8k+thsgsOVA==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + + sass-embedded-linux-musl-arm@1.77.8: + resolution: {integrity: sha512-nFkhSl3uu9btubm+JBW7uRglNVJ8W8dGfzVqh3fyQJKS1oyBC3vT3VOtfbT9YivXk28wXscSHpqXZwY7bUuopA==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + + sass-embedded-linux-musl-ia32@1.77.8: + resolution: {integrity: sha512-oWveMe+8TFlP8WBWPna/+Ec5TV0CE+PxEutyi0ltSruBds2zxRq9dPVOqrpPcDN9QUx50vNZC0Afgch0aQEd0g==} + engines: {node: '>=14.0.0'} + cpu: [ia32] + os: [linux] + + sass-embedded-linux-musl-x64@1.77.8: + resolution: {integrity: sha512-2NtRpMXHeFo9kaYxuZ+Ewwo39CE7BTS2JDfXkTjZTZqd8H+8KC53eBh516YQnn2oiqxSiKxm7a6pxbxGZGwXOQ==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + + sass-embedded-linux-x64@1.77.8: + resolution: {integrity: sha512-ND5qZLWUCpOn7LJfOf0gLSZUWhNIysY+7NZK1Ctq+pM6tpJky3JM5I1jSMplNxv5H3o8p80n0gSm+fcjsEFfjQ==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + hasBin: true + + sass-embedded-win32-arm64@1.77.8: + resolution: {integrity: sha512-7L8zT6xzEvTYj86MvUWnbkWYCNQP+74HvruLILmiPPE+TCgOjgdi750709BtppVJGGZSs40ZuN6mi/YQyGtwXg==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [win32] + hasBin: true + + sass-embedded-win32-ia32@1.77.8: + resolution: {integrity: sha512-7Buh+4bP0WyYn6XPbthkIa3M2vtcR8QIsFVg3JElVlr+8Ng19jqe0t0SwggDgbMX6AdQZC+Wj4F1BprZSok42A==} + engines: {node: '>=14.0.0'} + cpu: [ia32] + os: [win32] + hasBin: true + + sass-embedded-win32-x64@1.77.8: + resolution: {integrity: sha512-rZmLIx4/LLQm+4GW39sRJW0MIlDqmyV0fkRzTmhFP5i/wVC7cuj8TUubPHw18rv2rkHFfBZKZJTCkPjCS5Z+SA==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [win32] + hasBin: true + + sass-embedded@1.77.8: + resolution: {integrity: sha512-WGXA6jcaoBo5Uhw0HX/s6z/sl3zyYQ7ZOnLOJzqwpctFcFmU4L07zn51e2VSkXXFpQZFAdMZNqOGz/7h/fvcRA==} + engines: {node: '>=16.0.0'} + + sass-loader@16.0.2: + resolution: {integrity: sha512-Ll6iXZ1EYwYT19SqW4mSBb76vSSi8JgzElmzIerhEGgzB5hRjDQIWsPmuk1UrAXkR16KJHqVY0eH+5/uw9Tmfw==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@rspack/core': 0.x || 1.x + node-sass: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + sass: ^1.3.0 + sass-embedded: '*' + webpack: ^5.0.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + node-sass: + optional: true + sass: + optional: true + sass-embedded: + optional: true + webpack: + optional: true + + sass@1.79.3: + resolution: {integrity: sha512-m7dZxh0W9EZ3cw50Me5GOuYm/tVAJAn91SUnohLRo9cXBixGUOdvmryN+dXpwR831bhoY3Zv7rEFt85PUwTmzA==} + engines: {node: '>=14.0.0'} + hasBin: true + + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + + scheduler@0.13.6: + resolution: {integrity: sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==} + + schema-utils@2.7.0: + resolution: {integrity: sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==} + engines: {node: '>= 8.9.0'} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.2.0: + resolution: {integrity: sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==} + engines: {node: '>= 12.13.0'} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.1.0: + resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + + sharp@0.32.6: + resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} + engines: {node: '>=14.15.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@4.0.0: + resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} + engines: {node: '>=12'} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slide@1.1.6: + resolution: {integrity: sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw==} + + slugify@1.6.6: + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} + engines: {node: '>=8.0.0'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@7.0.0: + resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} + engines: {node: '>= 10'} + + socks@2.8.3: + resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + sortablejs@1.15.0: + resolution: {integrity: sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==} + + source-list-map@2.0.1: + resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + spdx-compare@1.0.0: + resolution: {integrity: sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A==} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.20: + resolution: {integrity: sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==} + + spdx-ranges@2.1.1: + resolution: {integrity: sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA==} + + spdx-satisfies@5.0.1: + resolution: {integrity: sha512-Nwor6W6gzFp8XX4neaKQ7ChV4wmpSh2sSDemMFSzHxpTw460jxFYeOn+jq4ybnSSw/5sc3pjka9MQPouksQNpw==} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + ssri@9.0.1: + resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + stable-hash@0.0.4: + resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + stdin-discarder@0.1.0: + resolution: {integrity: sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + stoppable@1.1.0: + resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} + engines: {node: '>=4', npm: '>=6'} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + streamx@2.20.1: + resolution: {integrity: sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@6.1.0: + resolution: {integrity: sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==} + engines: {node: '>=16'} + + string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.8: + resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-indent@4.0.0: + resolution: {integrity: sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + stylehacks@7.0.4: + resolution: {integrity: sha512-i4zfNrGMt9SB4xRK9L83rlsFCgdGANfeDAYacO1pkqcE7cRHPdWHwnKZVz7WY17Veq/FvyYsRAU++Ga+qDFIww==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.4.31 + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-color@9.4.0: + resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} + engines: {node: '>=12'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + + svg2ttf@6.0.3: + resolution: {integrity: sha512-CgqMyZrbOPpc+WqH7aga4JWkDPso23EgypLsbQ6gN3uoPWwwiLjXvzgrwGADBExvCRJrWFzAeK1bSoSpE7ixSQ==} + hasBin: true + + svgicons2svgfont@12.0.0: + resolution: {integrity: sha512-fjyDkhiG0M1TPBtZzD12QV3yDcG2fUgiqHPOCYzf7hHE40Hl3GhnE6P1njsJCCByhwM7MiufyDW3L7IOR5dg9w==} + engines: {node: '>=16.15.0'} + hasBin: true + + svgo@3.3.2: + resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} + engines: {node: '>=14.0.0'} + hasBin: true + + svgpath@2.6.0: + resolution: {integrity: sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==} + + tabbable@5.3.3: + resolution: {integrity: sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==} + + tapable@1.1.3: + resolution: {integrity: sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==} + engines: {node: '>=6'} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + tar-fs@2.1.1: + resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} + + tar-fs@3.0.6: + resolution: {integrity: sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + + terser-webpack-plugin@5.3.10: + resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + + terser@5.33.0: + resolution: {integrity: sha512-JuPVaB7s1gdFKPKTelwUyRq5Sid2A3Gko2S0PncwdBq7kN9Ti9HPWDQ06MPsEDGsZeVESjKEnyGy68quBk1w6g==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + text-decoder@1.2.0: + resolution: {integrity: sha512-n1yg1mOj9DNpk3NeZOx7T6jchTbyJS3i3cucbNN6FcdPriMZx7NsgrGpWWdWZZGxD7ES1XB+3uoqHMgOKaN+fg==} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + + ticky@1.0.1: + resolution: {integrity: sha512-RX35iq/D+lrsqhcPWIazM9ELkjOe30MSeoBHQHSsRwd1YuhJO5ui1K1/R0r7N3mFvbLBs33idw+eR6j+w6i/DA==} + + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + treeify@1.1.0: + resolution: {integrity: sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==} + engines: {node: '>=0.6'} + + trim-newlines@4.1.1: + resolution: {integrity: sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==} + engines: {node: '>=12'} + + ts-api-utils@1.3.0: + resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-loader@9.5.1: + resolution: {integrity: sha512-rNH3sK9kGZcH9dYzC7CewQm4NtxJTjSEVRJ2DyBZR7f8/wcta+iV44UPCXc5+nzDzivKtlzV6c9P4e+oFhDLYg==} + engines: {node: '>=12.0.0'} + peerDependencies: + typescript: '*' + webpack: ^5.0.0 + + tsc-alias@1.8.10: + resolution: {integrity: sha512-Ibv4KAWfFkFdKJxnWfVtdOmB0Zi1RJVxcbPGiCDsFpCQSsmpWyuzHG3rQyI5YkobWwxFPEyQfu1hdo4qLG2zPw==} + hasBin: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + + ttf2eot@3.1.0: + resolution: {integrity: sha512-aHTbcYosNHVqb2Qtt9Xfta77ae/5y0VfdwNLUS6sGBeGr22cX2JDMo/i5h3uuOf+FAD3akYOr17+fYd5NK8aXw==} + hasBin: true + + ttf2woff2@5.0.0: + resolution: {integrity: sha512-FplhShJd3rT8JGa8N04YWQuP7xRvwr9AIq+9/z5O/5ubqNiCADshKl8v51zJDFkhDVcYpdUqUpm7T4M53Z2JoQ==} + engines: {node: '>=14'} + hasBin: true + + ttf2woff@3.0.0: + resolution: {integrity: sha512-OvmFcj70PhmAsVQKfC15XoKH55cRWuaRzvr2fpTNhTNer6JBpG8n6vOhRrIgxMjcikyYt88xqYXMMVapJ4Rjvg==} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@1.4.0: + resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} + engines: {node: '>=10'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} + engines: {node: '>= 0.4'} + + typed-rest-client@1.8.11: + resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==} + + typescript-eslint@8.7.0: + resolution: {integrity: sha512-nEHbEYJyHwsuf7c3V3RS7Saq+1+la3i0ieR3qP0yjqWSzVmh8Drp47uOl9LjbPANac4S7EFSqvcYIKXUUwIfIQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + + typescript@5.6.2: + resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + + uncontrollable@5.1.0: + resolution: {integrity: sha512-5FXYaFANKaafg4IVZXUNtGyzsnYEvqlr9wQ3WpZxFpEUxl29A3H6Q4G1Dnnorvq9TGOGATBApWR4YpLAh+F5hw==} + peerDependencies: + react: '>=15.0.0' + + underscore@1.13.7: + resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} + + undici@6.19.8: + resolution: {integrity: sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==} + engines: {node: '>=18.17'} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unique-filename@2.0.1: + resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + unique-slug@3.0.0: + resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + universal-user-agent@7.0.2: + resolution: {integrity: sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + update-browserslist-db@1.1.0: + resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utila@0.4.0: + resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + varint@6.0.0: + resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vscode-uri@3.0.8: + resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + + warning@3.0.0: + resolution: {integrity: sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ==} + + warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + + watchpack@2.4.2: + resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} + engines: {node: '>=10.13.0'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webpack-bundle-analyzer@4.10.2: + resolution: {integrity: sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==} + engines: {node: '>= 10.13.0'} + hasBin: true + + webpack-cli@5.1.4: + resolution: {integrity: sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==} + engines: {node: '>=14.15.0'} + hasBin: true + peerDependencies: + '@webpack-cli/generators': '*' + webpack: 5.x.x + webpack-bundle-analyzer: '*' + webpack-dev-server: '*' + peerDependenciesMeta: + '@webpack-cli/generators': + optional: true + webpack-bundle-analyzer: + optional: true + webpack-dev-server: + optional: true + + webpack-merge@5.10.0: + resolution: {integrity: sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==} + engines: {node: '>=10.0.0'} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-require-from@1.8.6: + resolution: {integrity: sha512-QmRsOkOYPKeNXp4uVc7qxnPrFQPrP4bhOc/gl4QenTFNgXdEbF1U8VC+jM/Sljb0VzJLNgyNiHlVkuHjcmDtBQ==} + peerDependencies: + tapable: ^2.2.0 + + webpack-sources@1.4.3: + resolution: {integrity: sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==} + + webpack-sources@3.2.3: + resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} + engines: {node: '>=10.13.0'} + + webpack@5.94.0: + resolution: {integrity: sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + + which-typed-array@1.1.15: + resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + wildcard@2.0.1: + resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + workerpool@6.5.1: + resolution: {integrity: sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yazl@2.5.1: + resolution: {integrity: sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==} + + ylru@1.4.0: + resolution: {integrity: sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==} + engines: {node: '>= 4.0.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@axosoft/react-virtualized@9.22.3-gitkraken.3(react-dom@16.8.4(react@16.8.4))(react@16.8.4)': + dependencies: + '@babel/runtime': 7.25.6 + clsx: 1.2.1 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 16.8.4 + react-dom: 16.8.4(react@16.8.4) + react-lifecycles-compat: 3.0.4 + + '@azure/abort-controller@1.1.0': + dependencies: + tslib: 2.7.0 + + '@azure/abort-controller@2.1.2': + dependencies: + tslib: 2.7.0 + + '@azure/core-auth@1.8.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.10.0 + tslib: 2.7.0 + + '@azure/core-client@1.9.2': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.8.0 + '@azure/core-rest-pipeline': 1.17.0 + '@azure/core-tracing': 1.1.2 + '@azure/core-util': 1.10.0 + '@azure/logger': 1.1.4 + tslib: 2.7.0 + transitivePeerDependencies: + - supports-color + + '@azure/core-rest-pipeline@1.17.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.8.0 + '@azure/core-tracing': 1.1.2 + '@azure/core-util': 1.10.0 + '@azure/logger': 1.1.4 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 + tslib: 2.7.0 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.1.2': + dependencies: + tslib: 2.7.0 + + '@azure/core-util@1.10.0': + dependencies: + '@azure/abort-controller': 2.1.2 + tslib: 2.7.0 + + '@azure/identity@4.4.1': + dependencies: + '@azure/abort-controller': 1.1.0 + '@azure/core-auth': 1.8.0 + '@azure/core-client': 1.9.2 + '@azure/core-rest-pipeline': 1.17.0 + '@azure/core-tracing': 1.1.2 + '@azure/core-util': 1.10.0 + '@azure/logger': 1.1.4 + '@azure/msal-browser': 3.24.0 + '@azure/msal-node': 2.14.0 + events: 3.3.0 + jws: 4.0.0 + open: 8.4.2 + stoppable: 1.1.0 + tslib: 2.7.0 + transitivePeerDependencies: + - supports-color + + '@azure/logger@1.1.4': + dependencies: + tslib: 2.7.0 + + '@azure/msal-browser@3.24.0': + dependencies: + '@azure/msal-common': 14.15.0 + + '@azure/msal-common@14.15.0': {} + + '@azure/msal-node@2.14.0': + dependencies: + '@azure/msal-common': 14.15.0 + jsonwebtoken: 9.0.2 + uuid: 8.3.2 + + '@babel/code-frame@7.24.7': + dependencies: + '@babel/highlight': 7.24.7 + picocolors: 1.1.0 + + '@babel/helper-validator-identifier@7.24.7': {} + + '@babel/highlight@7.24.7': + dependencies: + '@babel/helper-validator-identifier': 7.24.7 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.1.0 + + '@babel/runtime-corejs2@7.25.6': + dependencies: + core-js: 2.6.12 + regenerator-runtime: 0.14.1 + + '@babel/runtime@7.25.6': + dependencies: + regenerator-runtime: 0.14.1 + + '@bcoe/v8-coverage@0.2.3': {} + + '@bufbuild/protobuf@1.10.0': {} + + '@ctrl/tinycolor@4.1.0': {} + + '@discoveryjs/json-ext@0.5.7': {} + + '@eamodio/eslint-lite-webpack-plugin@0.1.0(@swc/core@1.7.26)(esbuild@0.23.1)(eslint@9.11.1)(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0))(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4))': + dependencies: + '@types/eslint': 9.6.1 + '@types/webpack': 5.28.5(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0)) + eslint: 9.11.1 + fast-glob: 3.3.2 + minimatch: 10.0.1 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + - webpack-cli + + '@esbuild/aix-ppc64@0.23.1': + optional: true + + '@esbuild/android-arm64@0.23.1': + optional: true + + '@esbuild/android-arm@0.23.1': + optional: true + + '@esbuild/android-x64@0.23.1': + optional: true + + '@esbuild/darwin-arm64@0.23.1': + optional: true + + '@esbuild/darwin-x64@0.23.1': + optional: true + + '@esbuild/freebsd-arm64@0.23.1': + optional: true + + '@esbuild/freebsd-x64@0.23.1': + optional: true + + '@esbuild/linux-arm64@0.23.1': + optional: true + + '@esbuild/linux-arm@0.23.1': + optional: true + + '@esbuild/linux-ia32@0.23.1': + optional: true + + '@esbuild/linux-loong64@0.23.1': + optional: true + + '@esbuild/linux-mips64el@0.23.1': + optional: true + + '@esbuild/linux-ppc64@0.23.1': + optional: true + + '@esbuild/linux-riscv64@0.23.1': + optional: true + + '@esbuild/linux-s390x@0.23.1': + optional: true + + '@esbuild/linux-x64@0.23.1': + optional: true + + '@esbuild/netbsd-x64@0.23.1': + optional: true + + '@esbuild/openbsd-arm64@0.23.1': + optional: true + + '@esbuild/openbsd-x64@0.23.1': + optional: true + + '@esbuild/sunos-x64@0.23.1': + optional: true + + '@esbuild/win32-arm64@0.23.1': + optional: true + + '@esbuild/win32-ia32@0.23.1': + optional: true + + '@esbuild/win32-x64@0.23.1': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@9.11.1)': + dependencies: + eslint: 9.11.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.11.1': {} + + '@eslint/config-array@0.18.0': + dependencies: + '@eslint/object-schema': 2.1.4 + debug: 4.3.7(supports-color@8.1.1) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/core@0.6.0': {} + + '@eslint/eslintrc@3.1.0': + dependencies: + ajv: 6.12.6 + debug: 4.3.7(supports-color@8.1.1) + espree: 10.1.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.11.1': {} + + '@eslint/object-schema@2.1.4': {} + + '@eslint/plugin-kit@0.2.0': + dependencies: + levn: 0.4.1 + + '@floating-ui/core@1.6.8': + dependencies: + '@floating-ui/utils': 0.2.8 + + '@floating-ui/dom@1.6.11': + dependencies: + '@floating-ui/core': 1.6.8 + '@floating-ui/utils': 0.2.8 + + '@floating-ui/utils@0.2.8': {} + + '@gar/promisify@1.1.3': {} + + '@gitkraken/gitkraken-components@10.6.0': + dependencies: + '@axosoft/react-virtualized': 9.22.3-gitkraken.3(react-dom@16.8.4(react@16.8.4))(react@16.8.4) + classnames: 2.5.1 + re-resizable: 6.9.11(react-dom@16.8.4(react@16.8.4))(react@16.8.4) + react: 16.8.4 + react-bootstrap: 0.32.4(react-dom@16.8.4(react@16.8.4))(react@16.8.4) + react-dom: 16.8.4(react@16.8.4) + react-dragula: 1.1.17 + react-onclickoutside: 6.13.0(react-dom@16.8.4(react@16.8.4))(react@16.8.4) + + '@gitkraken/provider-apis@0.24.2(encoding@0.1.13)': + dependencies: + js-base64: 3.7.5 + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + + '@gitkraken/shared-web-components@0.1.1-rc.15': + dependencies: + '@floating-ui/dom': 1.6.11 + typescript: 4.9.5 + + '@gk-nzaytsev/fast-string-truncated-width@1.1.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.3.0': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 18.15.0 + '@types/yargs': 17.0.33 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/source-map@0.3.6': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@koa/cors@5.0.0': + dependencies: + vary: 1.1.2 + + '@koa/router@13.1.0': + dependencies: + http-errors: 2.0.0 + koa-compose: 4.1.0 + path-to-regexp: 6.3.0 + + '@lit-labs/ssr-dom-shim@1.2.1': {} + + '@lit/context@1.1.2': + dependencies: + '@lit/reactive-element': 2.0.4 + + '@lit/react@1.0.5(@types/react@17.0.82)': + dependencies: + '@types/react': 17.0.82 + + '@lit/reactive-element@2.0.4': + dependencies: + '@lit-labs/ssr-dom-shim': 1.2.1 + + '@lit/task@1.0.1': + dependencies: + '@lit/reactive-element': 2.0.4 + + '@microsoft/fast-element@1.13.0': {} + + '@microsoft/fast-foundation@2.49.6': + dependencies: + '@microsoft/fast-element': 1.13.0 + '@microsoft/fast-web-utilities': 5.4.1 + tabbable: 5.3.3 + tslib: 2.7.0 + + '@microsoft/fast-react-wrapper@0.3.24(react@16.8.4)': + dependencies: + '@microsoft/fast-element': 1.13.0 + '@microsoft/fast-foundation': 2.49.6 + react: 16.8.4 + + '@microsoft/fast-web-utilities@5.4.1': + dependencies: + exenv-es6: 1.1.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@npmcli/fs@2.1.2': + dependencies: + '@gar/promisify': 1.1.3 + semver: 7.6.3 + + '@npmcli/fs@3.1.1': + dependencies: + semver: 7.6.3 + + '@npmcli/move-file@2.0.1': + dependencies: + mkdirp: 1.0.4 + rimraf: 3.0.2 + + '@octokit/endpoint@10.1.1': + dependencies: + '@octokit/types': 13.5.0 + universal-user-agent: 7.0.2 + + '@octokit/graphql@8.1.1': + dependencies: + '@octokit/request': 9.1.3 + '@octokit/types': 13.5.0 + universal-user-agent: 7.0.2 + + '@octokit/openapi-types@22.2.0': {} + + '@octokit/request-error@6.1.4': + dependencies: + '@octokit/types': 13.5.0 + + '@octokit/request@9.1.3': + dependencies: + '@octokit/endpoint': 10.1.1 + '@octokit/request-error': 6.1.4 + '@octokit/types': 13.5.0 + universal-user-agent: 7.0.2 + + '@octokit/types@13.5.0': + dependencies: + '@octokit/openapi-types': 22.2.0 + + '@opentelemetry/api-logs@0.53.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.27.0 + + '@opentelemetry/exporter-trace-otlp-http@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-exporter-base@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.53.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.53.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) + protobufjs: 7.4.0 + + '@opentelemetry/resources@1.26.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.27.0 + + '@opentelemetry/sdk-logs@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.53.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@1.26.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.27.0 + + '@opentelemetry/semantic-conventions@1.27.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/browser-chromium@1.47.2': + dependencies: + playwright-core: 1.47.2 + + '@playwright/test@1.47.2': + dependencies: + playwright: 1.47.2 + + '@polka/url@1.0.0-next.28': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@shoelace-style/animations@1.2.0': {} + + '@shoelace-style/localize@3.2.1': {} + + '@shoelace-style/shoelace@2.17.1(@types/react@17.0.82)': + dependencies: + '@ctrl/tinycolor': 4.1.0 + '@floating-ui/dom': 1.6.11 + '@lit/react': 1.0.5(@types/react@17.0.82) + '@shoelace-style/animations': 1.2.0 + '@shoelace-style/localize': 3.2.1 + composed-offset-position: 0.0.4 + lit: 3.2.0 + qr-creator: 1.0.0 + transitivePeerDependencies: + - '@types/react' + + '@sinclair/typebox@0.27.8': {} + + '@sindresorhus/merge-streams@2.3.0': {} + + '@swc/core-darwin-arm64@1.7.26': + optional: true + + '@swc/core-darwin-x64@1.7.26': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.7.26': + optional: true + + '@swc/core-linux-arm64-gnu@1.7.26': + optional: true + + '@swc/core-linux-arm64-musl@1.7.26': + optional: true + + '@swc/core-linux-x64-gnu@1.7.26': + optional: true + + '@swc/core-linux-x64-musl@1.7.26': + optional: true + + '@swc/core-win32-arm64-msvc@1.7.26': + optional: true + + '@swc/core-win32-ia32-msvc@1.7.26': + optional: true + + '@swc/core-win32-x64-msvc@1.7.26': + optional: true + + '@swc/core@1.7.26': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.12 + optionalDependencies: + '@swc/core-darwin-arm64': 1.7.26 + '@swc/core-darwin-x64': 1.7.26 + '@swc/core-linux-arm-gnueabihf': 1.7.26 + '@swc/core-linux-arm64-gnu': 1.7.26 + '@swc/core-linux-arm64-musl': 1.7.26 + '@swc/core-linux-x64-gnu': 1.7.26 + '@swc/core-linux-x64-musl': 1.7.26 + '@swc/core-win32-arm64-msvc': 1.7.26 + '@swc/core-win32-ia32-msvc': 1.7.26 + '@swc/core-win32-x64-msvc': 1.7.26 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.12': + dependencies: + '@swc/counter': 0.1.3 + + '@tootallnate/once@2.0.0': {} + + '@trysound/sax@0.2.0': {} + + '@twbs/fantasticon@3.0.0': + dependencies: + case: 1.6.3 + commander: 11.1.0 + figures: 3.2.0 + glob: 7.2.3 + handlebars: 4.7.8 + picocolors: 1.1.0 + slugify: 1.6.6 + svg2ttf: 6.0.3 + svgicons2svgfont: 12.0.0 + ttf2eot: 3.1.0 + ttf2woff: 3.0.0 + ttf2woff2: 5.0.0 + transitivePeerDependencies: + - bluebird + - supports-color + + '@types/d3-selection@3.0.10': {} + + '@types/d3-transition@3.0.8': + dependencies: + '@types/d3-selection': 3.0.10 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.6 + '@types/json-schema': 7.0.15 + + '@types/eslint__js@8.42.3': + dependencies: + '@types/eslint': 9.6.1 + + '@types/estree@1.0.6': {} + + '@types/glob@7.2.0': + dependencies: + '@types/minimatch': 5.1.2 + '@types/node': 18.15.0 + + '@types/html-minifier-terser@6.1.0': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': + optional: true + + '@types/minimatch@5.1.2': {} + + '@types/minimist@1.2.5': {} + + '@types/mocha@10.0.8': {} + + '@types/node@18.15.0': {} + + '@types/normalize-package-data@2.4.4': {} + + '@types/parse-json@4.0.2': {} + + '@types/prop-types@15.7.13': {} + + '@types/react-dom@17.0.21': + dependencies: + '@types/react': 17.0.82 + + '@types/react@17.0.82': + dependencies: + '@types/prop-types': 15.7.13 + '@types/scheduler': 0.16.8 + csstype: 3.1.3 + + '@types/scheduler@0.16.8': {} + + '@types/sortablejs@1.15.8': {} + + '@types/trusted-types@2.0.7': {} + + '@types/vscode@1.82.0': {} + + '@types/webpack@5.28.5(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0))': + dependencies: + '@types/node': 18.15.0 + tapable: 2.2.1 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + - webpack-cli + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.33': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.7.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint@9.11.1)(typescript@5.6.2)': + dependencies: + '@eslint-community/regexpp': 4.11.1 + '@typescript-eslint/parser': 8.7.0(eslint@9.11.1)(typescript@5.6.2) + '@typescript-eslint/scope-manager': 8.7.0 + '@typescript-eslint/type-utils': 8.7.0(eslint@9.11.1)(typescript@5.6.2) + '@typescript-eslint/utils': 8.7.0(eslint@9.11.1)(typescript@5.6.2) + '@typescript-eslint/visitor-keys': 8.7.0 + eslint: 9.11.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.3.0(typescript@5.6.2) + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2)': + dependencies: + '@typescript-eslint/scope-manager': 8.7.0 + '@typescript-eslint/types': 8.7.0 + '@typescript-eslint/typescript-estree': 8.7.0(typescript@5.6.2) + '@typescript-eslint/visitor-keys': 8.7.0 + debug: 4.3.7(supports-color@8.1.1) + eslint: 9.11.1 + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.7.0': + dependencies: + '@typescript-eslint/types': 8.7.0 + '@typescript-eslint/visitor-keys': 8.7.0 + + '@typescript-eslint/type-utils@8.7.0(eslint@9.11.1)(typescript@5.6.2)': + dependencies: + '@typescript-eslint/typescript-estree': 8.7.0(typescript@5.6.2) + '@typescript-eslint/utils': 8.7.0(eslint@9.11.1)(typescript@5.6.2) + debug: 4.3.7(supports-color@8.1.1) + ts-api-utils: 1.3.0(typescript@5.6.2) + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - eslint + - supports-color + + '@typescript-eslint/types@8.7.0': {} + + '@typescript-eslint/typescript-estree@8.7.0(typescript@5.6.2)': + dependencies: + '@typescript-eslint/types': 8.7.0 + '@typescript-eslint/visitor-keys': 8.7.0 + debug: 4.3.7(supports-color@8.1.1) + fast-glob: 3.3.2 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 1.3.0(typescript@5.6.2) + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.7.0(eslint@9.11.1)(typescript@5.6.2)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.11.1) + '@typescript-eslint/scope-manager': 8.7.0 + '@typescript-eslint/types': 8.7.0 + '@typescript-eslint/typescript-estree': 8.7.0(typescript@5.6.2) + eslint: 9.11.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@8.7.0': + dependencies: + '@typescript-eslint/types': 8.7.0 + eslint-visitor-keys: 3.4.3 + + '@vscode/codicons@0.0.36': {} + + '@vscode/test-cli@0.0.10': + dependencies: + '@types/mocha': 10.0.8 + c8: 9.1.0 + chokidar: 3.6.0 + enhanced-resolve: 5.17.1 + glob: 10.4.5 + minimatch: 9.0.5 + mocha: 10.7.3 + supports-color: 9.4.0 + yargs: 17.7.2 + + '@vscode/test-electron@2.4.1': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 + jszip: 3.10.1 + ora: 7.0.1 + semver: 7.6.3 + transitivePeerDependencies: + - supports-color + + '@vscode/test-web@0.0.60': + dependencies: + '@koa/cors': 5.0.0 + '@koa/router': 13.1.0 + '@playwright/browser-chromium': 1.47.2 + glob: 11.0.0 + gunzip-maybe: 1.4.2 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 + koa: 2.15.3 + koa-morgan: 1.0.1 + koa-mount: 4.0.0 + koa-static: 5.0.0 + minimist: 1.2.8 + playwright: 1.47.1 + tar-fs: 3.0.6 + vscode-uri: 3.0.8 + transitivePeerDependencies: + - supports-color + + '@vscode/vsce-sign-alpine-arm64@2.0.2': + optional: true + + '@vscode/vsce-sign-alpine-x64@2.0.2': + optional: true + + '@vscode/vsce-sign-darwin-arm64@2.0.2': + optional: true + + '@vscode/vsce-sign-darwin-x64@2.0.2': + optional: true + + '@vscode/vsce-sign-linux-arm64@2.0.2': + optional: true + + '@vscode/vsce-sign-linux-arm@2.0.2': + optional: true + + '@vscode/vsce-sign-linux-x64@2.0.2': + optional: true + + '@vscode/vsce-sign-win32-arm64@2.0.2': + optional: true + + '@vscode/vsce-sign-win32-x64@2.0.2': + optional: true + + '@vscode/vsce-sign@2.0.4': + optionalDependencies: + '@vscode/vsce-sign-alpine-arm64': 2.0.2 + '@vscode/vsce-sign-alpine-x64': 2.0.2 + '@vscode/vsce-sign-darwin-arm64': 2.0.2 + '@vscode/vsce-sign-darwin-x64': 2.0.2 + '@vscode/vsce-sign-linux-arm': 2.0.2 + '@vscode/vsce-sign-linux-arm64': 2.0.2 + '@vscode/vsce-sign-linux-x64': 2.0.2 + '@vscode/vsce-sign-win32-arm64': 2.0.2 + '@vscode/vsce-sign-win32-x64': 2.0.2 + + '@vscode/vsce@3.1.0': + dependencies: + '@azure/identity': 4.4.1 + '@vscode/vsce-sign': 2.0.4 + azure-devops-node-api: 12.5.0 + chalk: 2.4.2 + cheerio: 1.0.0 + cockatiel: 3.2.1 + commander: 6.2.1 + form-data: 4.0.0 + glob: 11.0.0 + hosted-git-info: 4.1.0 + jsonc-parser: 3.3.1 + leven: 3.1.0 + markdown-it: 14.1.0 + mime: 1.6.0 + minimatch: 3.1.2 + parse-semver: 1.1.1 + read: 1.0.7 + semver: 7.6.3 + tmp: 0.2.3 + typed-rest-client: 1.8.11 + url-join: 4.0.1 + xml2js: 0.5.0 + yauzl: 2.10.0 + yazl: 2.5.1 + optionalDependencies: + keytar: 7.9.0 + transitivePeerDependencies: + - supports-color + + '@vscode/webview-ui-toolkit@1.4.0(react@16.8.4)': + dependencies: + '@microsoft/fast-element': 1.13.0 + '@microsoft/fast-foundation': 2.49.6 + '@microsoft/fast-react-wrapper': 0.3.24(react@16.8.4) + react: 16.8.4 + tslib: 2.7.0 + + '@webassemblyjs/ast@1.12.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + + '@webassemblyjs/floating-point-hex-parser@1.11.6': {} + + '@webassemblyjs/helper-api-error@1.11.6': {} + + '@webassemblyjs/helper-buffer@1.12.1': {} + + '@webassemblyjs/helper-numbers@1.11.6': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.11.6 + '@webassemblyjs/helper-api-error': 1.11.6 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.11.6': {} + + '@webassemblyjs/helper-wasm-section@1.12.1': + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/wasm-gen': 1.12.1 + + '@webassemblyjs/ieee754@1.11.6': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.11.6': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.11.6': {} + + '@webassemblyjs/wasm-edit@1.12.1': + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/helper-wasm-section': 1.12.1 + '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/wasm-opt': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + '@webassemblyjs/wast-printer': 1.12.1 + + '@webassemblyjs/wasm-gen@1.12.1': + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 + + '@webassemblyjs/wasm-opt@1.12.1': + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + + '@webassemblyjs/wasm-parser@1.12.1': + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-api-error': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 + + '@webassemblyjs/wast-printer@1.12.1': + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@xtuc/long': 4.2.2 + + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0))(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4))': + dependencies: + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0) + + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0))(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4))': + dependencies: + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0) + + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0))(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4))': + dependencies: + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0) + + '@xmldom/xmldom@0.7.13': {} + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + abbrev@1.1.1: {} + + abbrev@2.0.0: {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-import-attributes@1.9.5(acorn@8.12.1): + dependencies: + acorn: 8.12.1 + + acorn-jsx@5.3.2(acorn@8.12.1): + dependencies: + acorn: 8.12.1 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.12.1 + + acorn@8.12.1: {} + + agent-base@6.0.2: + dependencies: + debug: 4.3.7(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + agent-base@7.1.1: + dependencies: + debug: 4.3.7(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + agentkeepalive@4.5.0: + dependencies: + humanize-ms: 1.2.1 + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-keywords@3.5.2(ajv@6.12.6): + dependencies: + ajv: 6.12.6 + + ajv-keywords@5.1.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + fast-deep-equal: 3.1.3 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.1 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + anti-trojan-source@1.4.1: + dependencies: + globby: 12.2.0 + meow: 10.1.5 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + aproba@2.0.0: {} + + are-we-there-yet@3.0.1: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + is-array-buffer: 3.0.4 + optional: true + + array-find-index@1.0.2: {} + + array-includes@3.1.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + is-string: 1.0.7 + optional: true + + array-union@1.0.2: + dependencies: + array-uniq: 1.0.3 + + array-union@2.1.0: {} + + array-union@3.0.1: {} + + array-uniq@1.0.3: {} + + array.prototype.findlastindex@1.2.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 + optional: true + + array.prototype.flat@1.3.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + optional: true + + array.prototype.flatmap@1.3.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + optional: true + + arraybuffer.prototype.slice@1.0.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 + is-shared-array-buffer: 1.0.3 + optional: true + + arrify@1.0.1: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + atoa@1.0.0: {} + + autoprefixer@10.4.20(postcss@8.4.47): + dependencies: + browserslist: 4.23.3 + caniuse-lite: 1.0.30001663 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.0 + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.0.0 + optional: true + + azure-devops-node-api@12.5.0: + dependencies: + tunnel: 0.0.6 + typed-rest-client: 1.8.11 + + b4a@1.6.6: {} + + balanced-match@1.0.2: {} + + bare-events@2.4.2: + optional: true + + bare-fs@2.3.5: + dependencies: + bare-events: 2.4.2 + bare-path: 2.1.3 + bare-stream: 2.3.0 + optional: true + + bare-os@2.4.4: + optional: true + + bare-path@2.1.3: + dependencies: + bare-os: 2.4.4 + optional: true + + bare-stream@2.3.0: + dependencies: + b4a: 1.6.6 + streamx: 2.20.1 + optional: true + + base64-js@1.5.1: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + big.js@5.2.2: {} + + billboard.js@3.13.0: + dependencies: + '@types/d3-selection': 3.0.10 + '@types/d3-transition': 3.0.8 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time-format: 4.1.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + binary-extensions@2.3.0: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bl@5.1.0: + dependencies: + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 3.6.2 + + boolbase@1.0.0: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browser-stdout@1.3.1: {} + + browserify-zlib@0.1.4: + dependencies: + pako: 0.2.9 + + browserslist@4.23.3: + dependencies: + caniuse-lite: 1.0.30001663 + electron-to-chromium: 1.5.27 + node-releases: 2.0.18 + update-browserslist-db: 1.1.0(browserslist@4.23.3) + + buffer-builder@0.2.0: {} + + buffer-crc32@0.2.13: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bufferstreams@3.0.0: + dependencies: + readable-stream: 3.6.2 + + c8@9.1.0: + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@istanbuljs/schema': 0.1.3 + find-up: 5.0.0 + foreground-child: 3.3.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.1.7 + test-exclude: 6.0.0 + v8-to-istanbul: 9.3.0 + yargs: 17.7.2 + yargs-parser: 21.1.1 + + cacache@16.1.3: + dependencies: + '@npmcli/fs': 2.1.2 + '@npmcli/move-file': 2.0.1 + chownr: 2.0.0 + fs-minipass: 2.1.0 + glob: 8.1.0 + infer-owner: 1.0.4 + lru-cache: 7.18.3 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + mkdirp: 1.0.4 + p-map: 4.0.0 + promise-inflight: 1.0.1 + rimraf: 3.0.2 + ssri: 9.0.1 + tar: 6.2.1 + unique-filename: 2.0.1 + transitivePeerDependencies: + - bluebird + + cache-content-type@1.0.1: + dependencies: + mime-types: 2.1.35 + ylru: 1.4.0 + + call-bind@1.0.7: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 + + callsites@3.1.0: {} + + camel-case@4.1.2: + dependencies: + pascal-case: 3.1.2 + tslib: 2.7.0 + + camelcase-keys@7.0.2: + dependencies: + camelcase: 6.3.0 + map-obj: 4.3.0 + quick-lru: 5.1.1 + type-fest: 1.4.0 + + camelcase@6.3.0: {} + + caniuse-api@3.0.0: + dependencies: + browserslist: 4.23.3 + caniuse-lite: 1.0.30001663 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + + caniuse-lite@1.0.30001663: {} + + case@1.6.3: {} + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.3.0: {} + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + + cheerio@1.0.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.1.0 + encoding-sniffer: 0.2.0 + htmlparser2: 9.1.0 + parse5: 7.1.2 + parse5-htmlparser2-tree-adapter: 7.0.0 + parse5-parser-stream: 7.1.2 + undici: 6.19.8 + whatwg-mimetype: 4.0.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.1: + dependencies: + readdirp: 4.0.1 + + chownr@1.1.4: {} + + chownr@2.0.0: {} + + chrome-trace-event@1.0.4: {} + + ci-info@3.9.0: {} + + circular-dependency-plugin@5.2.2(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + + classnames@2.5.1: {} + + clean-css@5.3.3: + dependencies: + source-map: 0.6.1 + + clean-stack@2.2.0: {} + + clean-webpack-plugin@4.0.0(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + del: 4.1.1 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-spinners@2.9.2: {} + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + + clsx@1.2.1: {} + + co@4.6.0: {} + + cockatiel@3.2.1: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + + color-support@1.1.3: {} + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + colord@2.9.3: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@10.0.1: {} + + commander@11.1.0: {} + + commander@2.20.3: {} + + commander@6.2.1: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + commander@9.5.0: {} + + composed-offset-position@0.0.4: {} + + concat-map@0.0.1: {} + + console-control-strings@1.1.0: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + contra@1.9.4: + dependencies: + atoa: 1.0.0 + ticky: 1.0.1 + + convert-source-map@2.0.0: {} + + cookies@0.9.1: + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + + copy-webpack-plugin@12.0.2(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + fast-glob: 3.3.2 + glob-parent: 6.0.2 + globby: 14.0.2 + normalize-path: 3.0.0 + schema-utils: 4.2.0 + serialize-javascript: 6.0.2 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + + core-js@2.6.12: {} + + core-util-is@1.0.3: {} + + cosmiconfig@6.0.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.0 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crossvent@1.5.4: + dependencies: + custom-event: 1.0.0 + + csp-html-webpack-plugin@5.1.0(html-webpack-plugin@5.6.0(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)))(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + cheerio: 1.0.0 + html-webpack-plugin: 5.6.0(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + lodash: 4.17.21 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + + css-declaration-sorter@7.2.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + + css-loader@7.1.2(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + icss-utils: 5.1.0(postcss@8.4.47) + postcss: 8.4.47 + postcss-modules-extract-imports: 3.1.0(postcss@8.4.47) + postcss-modules-local-by-default: 4.0.5(postcss@8.4.47) + postcss-modules-scope: 3.2.0(postcss@8.4.47) + postcss-modules-values: 4.0.0(postcss@8.4.47) + postcss-value-parser: 4.2.0 + semver: 7.6.3 + optionalDependencies: + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + + css-minimizer-webpack-plugin@7.0.0(esbuild@0.23.1)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + cssnano: 7.0.6(postcss@8.4.47) + jest-worker: 29.7.0 + postcss: 8.4.47 + schema-utils: 4.2.0 + serialize-javascript: 6.0.2 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + optionalDependencies: + esbuild: 0.23.1 + + css-select@4.3.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 + + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.1.0 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + css-what@6.1.0: {} + + cssesc@3.0.0: {} + + cssnano-preset-advanced@7.0.6(postcss@8.4.47): + dependencies: + autoprefixer: 10.4.20(postcss@8.4.47) + browserslist: 4.23.3 + cssnano-preset-default: 7.0.6(postcss@8.4.47) + postcss: 8.4.47 + postcss-discard-unused: 7.0.3(postcss@8.4.47) + postcss-merge-idents: 7.0.0(postcss@8.4.47) + postcss-reduce-idents: 7.0.0(postcss@8.4.47) + postcss-zindex: 7.0.0(postcss@8.4.47) + + cssnano-preset-default@7.0.6(postcss@8.4.47): + dependencies: + browserslist: 4.23.3 + css-declaration-sorter: 7.2.0(postcss@8.4.47) + cssnano-utils: 5.0.0(postcss@8.4.47) + postcss: 8.4.47 + postcss-calc: 10.0.2(postcss@8.4.47) + postcss-colormin: 7.0.2(postcss@8.4.47) + postcss-convert-values: 7.0.4(postcss@8.4.47) + postcss-discard-comments: 7.0.3(postcss@8.4.47) + postcss-discard-duplicates: 7.0.1(postcss@8.4.47) + postcss-discard-empty: 7.0.0(postcss@8.4.47) + postcss-discard-overridden: 7.0.0(postcss@8.4.47) + postcss-merge-longhand: 7.0.4(postcss@8.4.47) + postcss-merge-rules: 7.0.4(postcss@8.4.47) + postcss-minify-font-values: 7.0.0(postcss@8.4.47) + postcss-minify-gradients: 7.0.0(postcss@8.4.47) + postcss-minify-params: 7.0.2(postcss@8.4.47) + postcss-minify-selectors: 7.0.4(postcss@8.4.47) + postcss-normalize-charset: 7.0.0(postcss@8.4.47) + postcss-normalize-display-values: 7.0.0(postcss@8.4.47) + postcss-normalize-positions: 7.0.0(postcss@8.4.47) + postcss-normalize-repeat-style: 7.0.0(postcss@8.4.47) + postcss-normalize-string: 7.0.0(postcss@8.4.47) + postcss-normalize-timing-functions: 7.0.0(postcss@8.4.47) + postcss-normalize-unicode: 7.0.2(postcss@8.4.47) + postcss-normalize-url: 7.0.0(postcss@8.4.47) + postcss-normalize-whitespace: 7.0.0(postcss@8.4.47) + postcss-ordered-values: 7.0.1(postcss@8.4.47) + postcss-reduce-initial: 7.0.2(postcss@8.4.47) + postcss-reduce-transforms: 7.0.0(postcss@8.4.47) + postcss-svgo: 7.0.1(postcss@8.4.47) + postcss-unique-selectors: 7.0.3(postcss@8.4.47) + + cssnano-utils@5.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + + cssnano@7.0.6(postcss@8.4.47): + dependencies: + cssnano-preset-default: 7.0.6(postcss@8.4.47) + lilconfig: 3.1.2 + postcss: 8.4.47 + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + csstype@3.1.3: {} + + cubic2quad@1.2.1: {} + + custom-event@1.0.0: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + data-view-buffer@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + optional: true + + data-view-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + optional: true + + data-view-byte-offset@1.0.0: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + optional: true + + debounce@1.2.1: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.3.7(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decamelize-keys@1.1.1: + dependencies: + decamelize: 1.2.0 + map-obj: 1.0.1 + + decamelize@1.2.0: {} + + decamelize@4.0.0: {} + + decamelize@5.0.1: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-equal@1.0.1: {} + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + + define-lazy-prop@2.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + + del@4.1.1: + dependencies: + '@types/glob': 7.2.0 + globby: 6.1.0 + is-path-cwd: 2.2.0 + is-path-in-cwd: 2.1.0 + p-map: 2.1.0 + pify: 4.0.1 + rimraf: 2.7.1 + + delayed-stream@1.0.0: {} + + delegates@1.0.0: {} + + depd@1.1.2: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.0.3: {} + + diff@5.2.0: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + optional: true + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-converter@0.2.0: + dependencies: + utila: 0.4.0 + + dom-helpers@3.4.0: + dependencies: + '@babel/runtime': 7.25.6 + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.25.6 + csstype: 3.1.3 + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + + domutils@3.1.0: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.7.0 + + dragula@3.7.2: + dependencies: + contra: 1.9.4 + crossvent: 1.5.4 + + duplexer@0.1.2: {} + + duplexify@3.7.1: + dependencies: + end-of-stream: 1.4.4 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.3 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.27: {} + + emoji-regex@10.4.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + emojis-list@3.0.0: {} + + encodeurl@1.0.2: {} + + encoding-sniffer@0.2.0: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.17.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + entities@2.2.0: {} + + entities@4.5.0: {} + + env-paths@2.2.1: {} + + envinfo@7.14.0: {} + + err-code@2.0.3: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + es-abstract@1.23.3: + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.0.3 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.2 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + optional: true + + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.2.4 + + es-errors@1.3.0: {} + + es-module-lexer@1.5.4: {} + + es-object-atoms@1.0.0: + dependencies: + es-errors: 1.3.0 + optional: true + + es-set-tostringtag@2.0.3: + dependencies: + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + optional: true + + es-shim-unscopables@1.0.2: + dependencies: + hasown: 2.0.2 + optional: true + + es-to-primitive@1.2.1: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + optional: true + + esbuild-loader@4.2.2(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + esbuild: 0.23.1 + get-tsconfig: 4.8.1 + loader-utils: 2.0.4 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + webpack-sources: 1.4.3 + + esbuild-node-externals@1.14.0(esbuild@0.23.1): + dependencies: + esbuild: 0.23.1 + find-up: 5.0.0 + tslib: 2.7.0 + + esbuild-sass-plugin@3.3.1(esbuild@0.23.1)(sass-embedded@1.77.8): + dependencies: + esbuild: 0.23.1 + resolve: 1.22.8 + safe-identifier: 0.4.2 + sass: 1.79.3 + sass-embedded: 1.77.8 + + esbuild@0.23.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.23.1 + '@esbuild/android-arm': 0.23.1 + '@esbuild/android-arm64': 0.23.1 + '@esbuild/android-x64': 0.23.1 + '@esbuild/darwin-arm64': 0.23.1 + '@esbuild/darwin-x64': 0.23.1 + '@esbuild/freebsd-arm64': 0.23.1 + '@esbuild/freebsd-x64': 0.23.1 + '@esbuild/linux-arm': 0.23.1 + '@esbuild/linux-arm64': 0.23.1 + '@esbuild/linux-ia32': 0.23.1 + '@esbuild/linux-loong64': 0.23.1 + '@esbuild/linux-mips64el': 0.23.1 + '@esbuild/linux-ppc64': 0.23.1 + '@esbuild/linux-riscv64': 0.23.1 + '@esbuild/linux-s390x': 0.23.1 + '@esbuild/linux-x64': 0.23.1 + '@esbuild/netbsd-x64': 0.23.1 + '@esbuild/openbsd-arm64': 0.23.1 + '@esbuild/openbsd-x64': 0.23.1 + '@esbuild/sunos-x64': 0.23.1 + '@esbuild/win32-arm64': 0.23.1 + '@esbuild/win32-ia32': 0.23.1 + '@esbuild/win32-x64': 0.23.1 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.15.1 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import-x@4.3.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.11.1): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.3.7(supports-color@8.1.1) + enhanced-resolve: 5.17.1 + eslint: 9.11.1 + eslint-module-utils: 2.11.1(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import-x@4.3.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.11.1))(eslint@9.11.1) + fast-glob: 3.3.2 + get-tsconfig: 4.8.1 + is-bun-module: 1.2.1 + is-glob: 4.0.3 + optionalDependencies: + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.11.1) + eslint-plugin-import-x: 4.3.0(eslint@9.11.1)(typescript@5.6.2) + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-node + - eslint-import-resolver-webpack + - supports-color + + eslint-module-utils@2.11.1(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import-x@4.3.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.11.1))(eslint@9.11.1): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.7.0(eslint@9.11.1)(typescript@5.6.2) + eslint: 9.11.1 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import-x@4.3.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.11.1) + transitivePeerDependencies: + - supports-color + + eslint-plugin-anti-trojan-source@1.1.1: + dependencies: + anti-trojan-source: 1.4.1 + + eslint-plugin-import-x@4.3.0(eslint@9.11.1)(typescript@5.6.2): + dependencies: + '@typescript-eslint/utils': 8.7.0(eslint@9.11.1)(typescript@5.6.2) + debug: 4.3.7(supports-color@8.1.1) + doctrine: 3.0.0 + eslint: 9.11.1 + eslint-import-resolver-node: 0.3.9 + get-tsconfig: 4.8.1 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + stable-hash: 0.0.4 + tslib: 2.7.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-import@2.29.1(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.3)(eslint@9.11.1): + dependencies: + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.5 + array.prototype.flat: 1.3.2 + array.prototype.flatmap: 1.3.2 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.11.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.11.1(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import-x@4.3.0(eslint@9.11.1)(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.11.1))(eslint@9.11.1) + hasown: 2.0.2 + is-core-module: 2.15.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.0 + semver: 6.3.1 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.7.0(eslint@9.11.1)(typescript@5.6.2) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + optional: true + + eslint-plugin-lit@1.15.0(eslint@9.11.1): + dependencies: + eslint: 9.11.1 + parse5: 6.0.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + requireindex: 1.2.0 + + eslint-plugin-wc@2.1.1(eslint@9.11.1): + dependencies: + eslint: 9.11.1 + is-valid-element-name: 1.0.0 + js-levenshtein-esm: 1.2.0 + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@8.0.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.0.0: {} + + eslint@9.11.1: + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.11.1) + '@eslint-community/regexpp': 4.11.1 + '@eslint/config-array': 0.18.0 + '@eslint/core': 0.6.0 + '@eslint/eslintrc': 3.1.0 + '@eslint/js': 9.11.1 + '@eslint/plugin-kit': 0.2.0 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.3.0 + '@nodelib/fs.walk': 1.2.8 + '@types/estree': 1.0.6 + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.7(supports-color@8.1.1) + escape-string-regexp: 4.0.0 + eslint-scope: 8.0.2 + eslint-visitor-keys: 4.0.0 + espree: 10.1.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@10.1.0: + dependencies: + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) + eslint-visitor-keys: 4.0.0 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + events@3.3.0: {} + + exenv-es6@1.1.1: {} + + expand-template@2.0.3: {} + + exponential-backoff@3.1.1: {} + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.0.1: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-uri-to-path@1.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + + flat@5.0.2: {} + + flatted@3.3.1: {} + + for-each@0.3.3: + dependencies: + is-callable: 1.2.7 + optional: true + + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@6.5.3(eslint@9.11.1)(typescript@5.6.2)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + '@babel/code-frame': 7.24.7 + '@types/json-schema': 7.0.15 + chalk: 4.1.2 + chokidar: 3.6.0 + cosmiconfig: 6.0.0 + deepmerge: 4.3.1 + fs-extra: 9.1.0 + glob: 7.2.3 + memfs: 3.5.3 + minimatch: 3.1.2 + schema-utils: 2.7.0 + semver: 7.6.3 + tapable: 1.1.3 + typescript: 5.6.2 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + optionalDependencies: + eslint: 9.11.1 + + form-data@4.0.0: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + fraction.js@4.3.7: {} + + fresh@0.5.2: {} + + fs-constants@1.0.0: {} + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs-monkey@1.0.6: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + functions-have-names: 1.2.3 + optional: true + + functions-have-names@1.2.3: + optional: true + + gauge@4.0.4: + dependencies: + aproba: 2.0.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + get-caller-file@2.0.5: {} + + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + get-symbol-description@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + optional: true + + get-tsconfig@4.8.1: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@10.4.5: + dependencies: + foreground-child: 3.3.0 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 1.11.1 + + glob@11.0.0: + dependencies: + foreground-child: 3.3.0 + jackspeak: 4.0.2 + minimatch: 10.0.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 2.0.0 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + + globals@14.0.0: {} + + globals@15.9.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.0.1 + optional: true + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globby@12.2.0: + dependencies: + array-union: 3.0.1 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 4.0.0 + + globby@14.0.2: + dependencies: + '@sindresorhus/merge-streams': 2.3.0 + fast-glob: 3.3.2 + ignore: 5.3.2 + path-type: 5.0.0 + slash: 5.1.0 + unicorn-magic: 0.1.0 + + globby@6.1.0: + dependencies: + array-union: 1.0.2 + glob: 7.2.3 + object-assign: 4.1.1 + pify: 2.3.0 + pinkie-promise: 2.0.1 + + gopd@1.0.1: + dependencies: + get-intrinsic: 1.2.4 + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + gunzip-maybe@1.4.2: + dependencies: + browserify-zlib: 0.1.4 + is-deflate: 1.0.0 + is-gzip: 1.0.0 + peek-stream: 1.1.3 + pumpify: 1.5.1 + through2: 2.0.5 + + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + hard-rejection@2.1.0: {} + + has-bigints@1.0.2: + optional: true + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.0 + + has-proto@1.0.3: {} + + has-symbols@1.0.3: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.0.3 + + has-unicode@2.0.1: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + hosted-git-info@6.1.1: + dependencies: + lru-cache: 7.18.3 + + html-escaper@2.0.2: {} + + html-loader@5.1.0(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + html-minifier-terser: 7.2.0 + parse5: 7.1.2 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + + html-minifier-terser@6.1.0: + dependencies: + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 8.3.0 + he: 1.2.0 + param-case: 3.0.4 + relateurl: 0.2.7 + terser: 5.33.0 + + html-minifier-terser@7.2.0: + dependencies: + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 10.0.1 + entities: 4.5.0 + param-case: 3.0.4 + relateurl: 0.2.7 + terser: 5.33.0 + + html-webpack-plugin@5.6.0(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + '@types/html-minifier-terser': 6.1.0 + html-minifier-terser: 6.1.0 + lodash: 4.17.21 + pretty-error: 4.0.0 + tapable: 2.2.1 + optionalDependencies: + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + + htmlparser2@6.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 2.2.0 + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + + http-assert@1.5.0: + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + + http-cache-semantics@4.1.1: {} + + http-errors@1.6.3: + dependencies: + depd: 1.1.2 + inherits: 2.0.3 + setprototypeof: 1.1.0 + statuses: 1.5.0 + + http-errors@1.8.1: + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.3.7(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.1 + debug: 4.3.7(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.3.7(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.5: + dependencies: + agent-base: 7.1.1 + debug: 4.3.7(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + icss-utils@5.1.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + image-minimizer-webpack-plugin@4.1.0(sharp@0.32.6)(svgo@3.3.2)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + schema-utils: 4.2.0 + serialize-javascript: 6.0.2 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + optionalDependencies: + sharp: 0.32.6 + svgo: 3.3.2 + + immediate@3.0.6: {} + + immutable@4.3.7: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + indent-string@5.0.0: {} + + infer-owner@1.0.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.3: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + internal-slot@1.0.7: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.0.6 + optional: true + + internmap@2.0.3: {} + + interpret@3.1.1: {} + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + ip-address@9.0.5: + dependencies: + jsbn: 1.1.0 + sprintf-js: 1.1.3 + + is-array-buffer@3.0.4: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + optional: true + + is-arrayish@0.2.1: {} + + is-arrayish@0.3.2: {} + + is-bigint@1.0.4: + dependencies: + has-bigints: 1.0.2 + optional: true + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.1.2: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + optional: true + + is-bun-module@1.2.1: + dependencies: + semver: 7.6.3 + + is-callable@1.2.7: + optional: true + + is-core-module@2.15.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.1: + dependencies: + is-typed-array: 1.1.13 + optional: true + + is-date-object@1.0.5: + dependencies: + has-tostringtag: 1.0.2 + optional: true + + is-deflate@1.0.0: {} + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.0.10: + dependencies: + has-tostringtag: 1.0.2 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-gzip@1.0.0: {} + + is-interactive@2.0.0: {} + + is-lambda@1.0.1: {} + + is-negative-zero@2.0.3: + optional: true + + is-number-object@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + optional: true + + is-number@7.0.0: {} + + is-path-cwd@2.2.0: {} + + is-path-in-cwd@2.1.0: + dependencies: + is-path-inside: 2.1.0 + + is-path-inside@2.1.0: + dependencies: + path-is-inside: 1.0.2 + + is-path-inside@3.0.3: {} + + is-plain-obj@1.1.0: {} + + is-plain-obj@2.1.0: {} + + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.1.4: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + optional: true + + is-shared-array-buffer@1.0.3: + dependencies: + call-bind: 1.0.7 + optional: true + + is-string@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + optional: true + + is-symbol@1.0.4: + dependencies: + has-symbols: 1.0.3 + optional: true + + is-typed-array@1.1.13: + dependencies: + which-typed-array: 1.1.15 + optional: true + + is-unicode-supported@0.1.0: {} + + is-unicode-supported@1.3.0: {} + + is-valid-element-name@1.0.0: + dependencies: + is-potential-custom-element-name: 1.0.1 + + is-weakref@1.0.2: + dependencies: + call-bind: 1.0.7 + optional: true + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isarray@1.0.0: {} + + isarray@2.0.5: + optional: true + + isexe@2.0.0: {} + + isobject@3.0.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.1.7: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jackspeak@4.0.2: + dependencies: + '@isaacs/cliui': 8.0.2 + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 18.15.0 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-worker@27.5.1: + dependencies: + '@types/node': 18.15.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest-worker@29.7.0: + dependencies: + '@types/node': 18.15.0 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + js-base64@3.7.5: {} + + js-levenshtein-esm@1.2.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsbn@1.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-parse-even-better-errors@3.0.2: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + optional: true + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.6.3 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@1.4.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwa@2.0.0: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + + jws@4.0.0: + dependencies: + jwa: 2.0.0 + safe-buffer: 5.2.1 + + keycode@2.2.1: {} + + keygrip@1.1.0: + dependencies: + tsscmp: 1.0.6 + + keytar@7.9.0: + dependencies: + node-addon-api: 4.3.0 + prebuild-install: 7.1.2 + optional: true + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kind-of@6.0.3: {} + + koa-compose@4.1.0: {} + + koa-convert@2.0.0: + dependencies: + co: 4.6.0 + koa-compose: 4.1.0 + + koa-morgan@1.0.1: + dependencies: + morgan: 1.10.0 + transitivePeerDependencies: + - supports-color + + koa-mount@4.0.0: + dependencies: + debug: 4.3.7(supports-color@8.1.1) + koa-compose: 4.1.0 + transitivePeerDependencies: + - supports-color + + koa-send@5.0.1: + dependencies: + debug: 4.3.7(supports-color@8.1.1) + http-errors: 1.8.1 + resolve-path: 1.4.0 + transitivePeerDependencies: + - supports-color + + koa-static@5.0.0: + dependencies: + debug: 3.2.7 + koa-send: 5.0.1 + transitivePeerDependencies: + - supports-color + + koa@2.15.3: + dependencies: + accepts: 1.3.8 + cache-content-type: 1.0.1 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookies: 0.9.1 + debug: 4.3.7(supports-color@8.1.1) + delegates: 1.0.0 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 1.8.1 + is-generator-function: 1.0.10 + koa-compose: 4.1.0 + koa-convert: 2.0.0 + on-finished: 2.4.1 + only: 0.0.2 + parseurl: 1.3.3 + statuses: 1.5.0 + type-is: 1.6.18 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + license-checker-rseidelsohn@4.4.2: + dependencies: + chalk: 4.1.2 + debug: 4.3.7(supports-color@8.1.1) + lodash.clonedeep: 4.5.0 + mkdirp: 1.0.4 + nopt: 7.2.1 + read-installed-packages: 2.0.1 + semver: 7.6.3 + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + spdx-satisfies: 5.0.1 + treeify: 1.1.0 + transitivePeerDependencies: + - supports-color + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lilconfig@3.1.2: {} + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + lit-element@4.1.0: + dependencies: + '@lit-labs/ssr-dom-shim': 1.2.1 + '@lit/reactive-element': 2.0.4 + lit-html: 3.2.0 + + lit-html@3.2.0: + dependencies: + '@types/trusted-types': 2.0.7 + + lit@3.2.0: + dependencies: + '@lit/reactive-element': 2.0.4 + lit-element: 4.1.0 + lit-html: 3.2.0 + + loader-runner@4.3.0: {} + + loader-utils@2.0.4: + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 2.2.3 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.clonedeep@4.5.0: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.uniq@4.5.0: {} + + lodash@4.17.21: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-symbols@5.1.0: + dependencies: + chalk: 5.3.0 + is-unicode-supported: 1.3.0 + + long@5.2.3: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lower-case@2.0.2: + dependencies: + tslib: 2.7.0 + + lru-cache@10.4.3: {} + + lru-cache@11.0.1: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-cache@7.18.3: {} + + lz-string@1.5.0: {} + + make-dir@4.0.0: + dependencies: + semver: 7.6.3 + + make-fetch-happen@10.2.1: + dependencies: + agentkeepalive: 4.5.0 + cacache: 16.1.3 + http-cache-semantics: 4.1.1 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-lambda: 1.0.1 + lru-cache: 7.18.3 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-fetch: 2.1.2 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + negotiator: 0.6.3 + promise-retry: 2.0.1 + socks-proxy-agent: 7.0.0 + ssri: 9.0.1 + transitivePeerDependencies: + - bluebird + - supports-color + + map-obj@1.0.1: {} + + map-obj@4.3.0: {} + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + marked@14.1.2: {} + + mdn-data@2.0.28: {} + + mdn-data@2.0.30: {} + + mdurl@2.0.0: {} + + media-typer@0.3.0: {} + + memfs@3.5.3: + dependencies: + fs-monkey: 1.0.6 + + meow@10.1.5: + dependencies: + '@types/minimist': 1.2.5 + camelcase-keys: 7.0.2 + decamelize: 5.0.1 + decamelize-keys: 1.1.1 + hard-rejection: 2.1.0 + minimist-options: 4.1.0 + normalize-package-data: 3.0.3 + read-pkg-up: 8.0.0 + redent: 4.0.0 + trim-newlines: 4.1.1 + type-fest: 1.4.0 + yargs-parser: 20.2.9 + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + microbuffer@1.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mimic-fn@2.1.0: {} + + mimic-response@3.1.0: {} + + min-indent@1.0.1: {} + + mini-css-extract-plugin@2.9.1(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + schema-utils: 4.2.0 + tapable: 2.2.1 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + + minimatch@10.0.1: + dependencies: + brace-expansion: 2.0.1 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.1 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minimist-options@4.1.0: + dependencies: + arrify: 1.0.1 + is-plain-obj: 1.1.0 + kind-of: 6.0.3 + + minimist@1.2.8: {} + + minipass-collect@1.0.2: + dependencies: + minipass: 3.3.6 + + minipass-fetch@2.1.2: + dependencies: + minipass: 3.3.6 + minipass-sized: 1.0.3 + minizlib: 2.1.2 + optionalDependencies: + encoding: 0.1.13 + + minipass-flush@1.0.5: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.2: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp-classic@0.5.3: {} + + mkdirp@1.0.4: {} + + mocha@10.7.3: + dependencies: + ansi-colors: 4.1.3 + browser-stdout: 1.3.1 + chokidar: 3.6.0 + debug: 4.3.7(supports-color@8.1.1) + diff: 5.2.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 8.1.0 + he: 1.2.0 + js-yaml: 4.1.0 + log-symbols: 4.1.0 + minimatch: 5.1.6 + ms: 2.1.3 + serialize-javascript: 6.0.2 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 6.5.1 + yargs: 16.2.0 + yargs-parser: 20.2.9 + yargs-unparser: 2.0.0 + + morgan@1.10.0: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.0.2 + transitivePeerDependencies: + - supports-color + + mrmime@2.0.0: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + mute-stream@0.0.8: {} + + mylas@2.1.13: {} + + nan@2.20.0: {} + + nanoid@3.3.7: {} + + napi-build-utils@1.0.2: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + neo-async@2.6.2: {} + + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.7.0 + + node-abi@3.68.0: + dependencies: + semver: 7.6.3 + + node-addon-api@4.3.0: + optional: true + + node-addon-api@6.1.0: {} + + node-fetch@2.7.0(encoding@0.1.13): + dependencies: + whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 + + node-gyp@9.4.1: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + make-fetch-happen: 10.2.1 + nopt: 6.0.0 + npmlog: 6.0.2 + rimraf: 3.0.2 + semver: 7.6.3 + tar: 6.2.1 + which: 2.0.2 + transitivePeerDependencies: + - bluebird + - supports-color + + node-releases@2.0.18: {} + + nopt@6.0.0: + dependencies: + abbrev: 1.1.1 + + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + + normalize-package-data@3.0.3: + dependencies: + hosted-git-info: 4.1.0 + is-core-module: 2.15.1 + semver: 7.6.3 + validate-npm-package-license: 3.0.4 + + normalize-package-data@5.0.0: + dependencies: + hosted-git-info: 6.1.1 + is-core-module: 2.15.1 + semver: 7.6.3 + validate-npm-package-license: 3.0.4 + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + npm-normalize-package-bin@3.0.1: {} + + npmlog@6.0.2: + dependencies: + are-we-there-yet: 3.0.1 + console-control-strings: 1.1.0 + gauge: 4.0.4 + set-blocking: 2.0.0 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.2: {} + + object-keys@1.1.1: + optional: true + + object.assign@4.1.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 + optional: true + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + optional: true + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + optional: true + + object.values@1.2.0: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + optional: true + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.0.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + only@0.0.2: {} + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + opener@1.5.2: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@7.0.1: + dependencies: + chalk: 5.3.0 + cli-cursor: 4.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 1.3.0 + log-symbols: 5.1.0 + stdin-discarder: 0.1.0 + string-width: 6.1.0 + strip-ansi: 7.1.0 + + os-browserify@0.3.0: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@2.1.0: {} + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.0: {} + + pako@0.2.9: {} + + pako@1.0.11: {} + + param-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.7.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.24.7 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-semver@1.1.1: + dependencies: + semver: 5.7.2 + + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5-htmlparser2-tree-adapter@7.0.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.1.2 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.1.2 + + parse5@6.0.1: {} + + parse5@7.1.2: + dependencies: + entities: 4.5.0 + + parseurl@1.3.3: {} + + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.7.0 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-is-inside@1.0.2: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-scurry@2.0.0: + dependencies: + lru-cache: 11.0.1 + minipass: 7.1.2 + + path-to-regexp@6.3.0: {} + + path-type@4.0.0: {} + + path-type@5.0.0: {} + + peek-stream@1.1.3: + dependencies: + buffer-from: 1.1.2 + duplexify: 3.7.1 + through2: 2.0.5 + + pend@1.2.0: {} + + picocolors@1.1.0: {} + + picomatch@2.3.1: {} + + pify@2.3.0: {} + + pify@4.0.1: {} + + pinkie-promise@2.0.1: + dependencies: + pinkie: 2.0.4 + + pinkie@2.0.4: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + playwright-core@1.47.1: {} + + playwright-core@1.47.2: {} + + playwright@1.47.1: + dependencies: + playwright-core: 1.47.1 + optionalDependencies: + fsevents: 2.3.2 + + playwright@1.47.2: + dependencies: + playwright-core: 1.47.2 + optionalDependencies: + fsevents: 2.3.2 + + plimit-lit@1.6.1: + dependencies: + queue-lit: 1.5.2 + + possible-typed-array-names@1.0.0: + optional: true + + postcss-calc@10.0.2(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 + postcss-value-parser: 4.2.0 + + postcss-colormin@7.0.2(postcss@8.4.47): + dependencies: + browserslist: 4.23.3 + caniuse-api: 3.0.0 + colord: 2.9.3 + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-convert-values@7.0.4(postcss@8.4.47): + dependencies: + browserslist: 4.23.3 + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-discard-comments@7.0.3(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 + + postcss-discard-duplicates@7.0.1(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + + postcss-discard-empty@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + + postcss-discard-overridden@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + + postcss-discard-unused@7.0.3(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 + + postcss-merge-idents@7.0.0(postcss@8.4.47): + dependencies: + cssnano-utils: 5.0.0(postcss@8.4.47) + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-merge-longhand@7.0.4(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + stylehacks: 7.0.4(postcss@8.4.47) + + postcss-merge-rules@7.0.4(postcss@8.4.47): + dependencies: + browserslist: 4.23.3 + caniuse-api: 3.0.0 + cssnano-utils: 5.0.0(postcss@8.4.47) + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 + + postcss-minify-font-values@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-minify-gradients@7.0.0(postcss@8.4.47): + dependencies: + colord: 2.9.3 + cssnano-utils: 5.0.0(postcss@8.4.47) + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-minify-params@7.0.2(postcss@8.4.47): + dependencies: + browserslist: 4.23.3 + cssnano-utils: 5.0.0(postcss@8.4.47) + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-minify-selectors@7.0.4(postcss@8.4.47): + dependencies: + cssesc: 3.0.0 + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 + + postcss-modules-extract-imports@3.1.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + + postcss-modules-local-by-default@4.0.5(postcss@8.4.47): + dependencies: + icss-utils: 5.1.0(postcss@8.4.47) + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 + postcss-value-parser: 4.2.0 + + postcss-modules-scope@3.2.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 + + postcss-modules-values@4.0.0(postcss@8.4.47): + dependencies: + icss-utils: 5.1.0(postcss@8.4.47) + postcss: 8.4.47 + + postcss-normalize-charset@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + + postcss-normalize-display-values@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-normalize-positions@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-normalize-repeat-style@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-normalize-string@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-normalize-timing-functions@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-normalize-unicode@7.0.2(postcss@8.4.47): + dependencies: + browserslist: 4.23.3 + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-normalize-url@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-normalize-whitespace@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-ordered-values@7.0.1(postcss@8.4.47): + dependencies: + cssnano-utils: 5.0.0(postcss@8.4.47) + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-reduce-idents@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-reduce-initial@7.0.2(postcss@8.4.47): + dependencies: + browserslist: 4.23.3 + caniuse-api: 3.0.0 + postcss: 8.4.47 + + postcss-reduce-transforms@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-svgo@7.0.1(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-value-parser: 4.2.0 + svgo: 3.3.2 + + postcss-unique-selectors@7.0.3(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 + + postcss-value-parser@4.2.0: {} + + postcss-zindex@7.0.0(postcss@8.4.47): + dependencies: + postcss: 8.4.47 + + postcss@8.4.47: + dependencies: + nanoid: 3.3.7 + picocolors: 1.1.0 + source-map-js: 1.2.1 + + prebuild-install@7.1.2: + dependencies: + detect-libc: 2.0.3 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 1.0.2 + node-abi: 3.68.0 + pump: 3.0.2 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.1 + tunnel-agent: 0.6.0 + + prelude-ls@1.2.1: {} + + prettier@3.1.0: {} + + pretty-error@4.0.0: + dependencies: + lodash: 4.17.21 + renderkid: 3.0.0 + + process-nextick-args@2.0.1: {} + + promise-inflight@1.0.1: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + prop-types-extra@1.1.1(react@16.8.4): + dependencies: + react: 16.8.4 + react-is: 16.13.1 + warning: 4.0.3 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + protobufjs@7.4.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 18.15.0 + long: 5.2.3 + + pump@2.0.1: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + pumpify@1.5.1: + dependencies: + duplexify: 3.7.1 + inherits: 2.0.4 + pump: 2.0.1 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + qr-creator@1.0.0: {} + + qs@6.13.0: + dependencies: + side-channel: 1.0.6 + + queue-lit@1.5.2: {} + + queue-microtask@1.2.3: {} + + queue-tick@1.0.1: {} + + quick-lru@5.1.1: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + re-resizable@6.9.11(react-dom@16.8.4(react@16.8.4))(react@16.8.4): + dependencies: + react: 16.8.4 + react-dom: 16.8.4(react@16.8.4) + + react-bootstrap@0.32.4(react-dom@16.8.4(react@16.8.4))(react@16.8.4): + dependencies: + '@babel/runtime-corejs2': 7.25.6 + classnames: 2.5.1 + dom-helpers: 3.4.0 + invariant: 2.2.4 + keycode: 2.2.1 + prop-types: 15.8.1 + prop-types-extra: 1.1.1(react@16.8.4) + react: 16.8.4 + react-dom: 16.8.4(react@16.8.4) + react-overlays: 0.8.3(react-dom@16.8.4(react@16.8.4))(react@16.8.4) + react-prop-types: 0.4.0(react@16.8.4) + react-transition-group: 2.9.0(react-dom@16.8.4(react@16.8.4))(react@16.8.4) + uncontrollable: 5.1.0(react@16.8.4) + warning: 3.0.0 + + react-dom@16.8.4(react@16.8.4): + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + prop-types: 15.8.1 + react: 16.8.4 + scheduler: 0.13.6 + + react-dragula@1.1.17: + dependencies: + atoa: 1.0.0 + dragula: 3.7.2 + + react-is@16.13.1: {} + + react-lifecycles-compat@3.0.4: {} + + react-onclickoutside@6.13.0(react-dom@16.8.4(react@16.8.4))(react@16.8.4): + dependencies: + react: 16.8.4 + react-dom: 16.8.4(react@16.8.4) + + react-overlays@0.8.3(react-dom@16.8.4(react@16.8.4))(react@16.8.4): + dependencies: + classnames: 2.5.1 + dom-helpers: 3.4.0 + prop-types: 15.8.1 + prop-types-extra: 1.1.1(react@16.8.4) + react: 16.8.4 + react-dom: 16.8.4(react@16.8.4) + react-transition-group: 2.9.0(react-dom@16.8.4(react@16.8.4))(react@16.8.4) + warning: 3.0.0 + + react-prop-types@0.4.0(react@16.8.4): + dependencies: + react: 16.8.4 + warning: 3.0.0 + + react-transition-group@2.9.0(react-dom@16.8.4(react@16.8.4))(react@16.8.4): + dependencies: + dom-helpers: 3.4.0 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 16.8.4 + react-dom: 16.8.4(react@16.8.4) + react-lifecycles-compat: 3.0.4 + + react@16.8.4: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + prop-types: 15.8.1 + scheduler: 0.13.6 + + read-installed-packages@2.0.1: + dependencies: + '@npmcli/fs': 3.1.1 + debug: 4.3.7(supports-color@8.1.1) + read-package-json: 6.0.4 + semver: 7.6.3 + slide: 1.1.6 + optionalDependencies: + graceful-fs: 4.2.11 + transitivePeerDependencies: + - supports-color + + read-package-json@6.0.4: + dependencies: + glob: 10.4.5 + json-parse-even-better-errors: 3.0.2 + normalize-package-data: 5.0.0 + npm-normalize-package-bin: 3.0.1 + + read-pkg-up@8.0.0: + dependencies: + find-up: 5.0.0 + read-pkg: 6.0.0 + type-fest: 1.4.0 + + read-pkg@6.0.0: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 3.0.3 + parse-json: 5.2.0 + type-fest: 1.4.0 + + read@1.0.7: + dependencies: + mute-stream: 0.0.8 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + readdirp@4.0.1: {} + + rechoir@0.8.0: + dependencies: + resolve: 1.22.8 + + redent@4.0.0: + dependencies: + indent-string: 5.0.0 + strip-indent: 4.0.0 + + regenerator-runtime@0.14.1: {} + + regexp.prototype.flags@1.5.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-errors: 1.3.0 + set-function-name: 2.0.2 + optional: true + + relateurl@0.2.7: {} + + renderkid@3.0.0: + dependencies: + css-select: 4.3.0 + dom-converter: 0.2.0 + htmlparser2: 6.1.0 + lodash: 4.17.21 + strip-ansi: 6.0.1 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + requireindex@1.2.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-path@1.4.0: + dependencies: + http-errors: 1.6.3 + path-is-absolute: 1.0.1 + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + retry@0.12.0: {} + + reusify@1.0.4: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rw@1.3.3: {} + + rxjs@7.8.1: + dependencies: + tslib: 2.7.0 + + safe-array-concat@1.1.2: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + optional: true + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-identifier@0.4.2: {} + + safe-regex-test@1.0.3: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-regex: 1.1.4 + optional: true + + safer-buffer@2.1.2: {} + + sass-embedded-android-arm64@1.77.8: + optional: true + + sass-embedded-android-arm@1.77.8: + optional: true + + sass-embedded-android-ia32@1.77.8: + optional: true + + sass-embedded-android-x64@1.77.8: + optional: true + + sass-embedded-darwin-arm64@1.77.8: + optional: true + + sass-embedded-darwin-x64@1.77.8: + optional: true + + sass-embedded-linux-arm64@1.77.8: + optional: true + + sass-embedded-linux-arm@1.77.8: + optional: true + + sass-embedded-linux-ia32@1.77.8: + optional: true + + sass-embedded-linux-musl-arm64@1.77.8: + optional: true + + sass-embedded-linux-musl-arm@1.77.8: + optional: true + + sass-embedded-linux-musl-ia32@1.77.8: + optional: true + + sass-embedded-linux-musl-x64@1.77.8: + optional: true + + sass-embedded-linux-x64@1.77.8: + optional: true + + sass-embedded-win32-arm64@1.77.8: + optional: true + + sass-embedded-win32-ia32@1.77.8: + optional: true + + sass-embedded-win32-x64@1.77.8: + optional: true + + sass-embedded@1.77.8: + dependencies: + '@bufbuild/protobuf': 1.10.0 + buffer-builder: 0.2.0 + immutable: 4.3.7 + rxjs: 7.8.1 + supports-color: 8.1.1 + varint: 6.0.0 + optionalDependencies: + sass-embedded-android-arm: 1.77.8 + sass-embedded-android-arm64: 1.77.8 + sass-embedded-android-ia32: 1.77.8 + sass-embedded-android-x64: 1.77.8 + sass-embedded-darwin-arm64: 1.77.8 + sass-embedded-darwin-x64: 1.77.8 + sass-embedded-linux-arm: 1.77.8 + sass-embedded-linux-arm64: 1.77.8 + sass-embedded-linux-ia32: 1.77.8 + sass-embedded-linux-musl-arm: 1.77.8 + sass-embedded-linux-musl-arm64: 1.77.8 + sass-embedded-linux-musl-ia32: 1.77.8 + sass-embedded-linux-musl-x64: 1.77.8 + sass-embedded-linux-x64: 1.77.8 + sass-embedded-win32-arm64: 1.77.8 + sass-embedded-win32-ia32: 1.77.8 + sass-embedded-win32-x64: 1.77.8 + + sass-loader@16.0.2(sass-embedded@1.77.8)(sass@1.79.3)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + neo-async: 2.6.2 + optionalDependencies: + sass: 1.79.3 + sass-embedded: 1.77.8 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + + sass@1.79.3: + dependencies: + chokidar: 4.0.1 + immutable: 4.3.7 + source-map-js: 1.2.1 + + sax@1.4.1: {} + + scheduler@0.13.6: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + + schema-utils@2.7.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + schema-utils@4.2.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) + + semver@5.7.2: {} + + semver@6.3.1: + optional: true + + semver@7.6.3: {} + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + set-blocking@2.0.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + optional: true + + setimmediate@1.0.5: {} + + setprototypeof@1.1.0: {} + + setprototypeof@1.2.0: {} + + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + + sharp@0.32.6: + dependencies: + color: 4.2.3 + detect-libc: 2.0.3 + node-addon-api: 6.1.0 + prebuild-install: 7.1.2 + semver: 7.6.3 + simple-get: 4.0.1 + tar-fs: 3.0.6 + tunnel-agent: 0.6.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel@1.0.6: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.28 + mrmime: 2.0.0 + totalist: 3.0.1 + + slash@3.0.0: {} + + slash@4.0.0: {} + + slash@5.1.0: {} + + slide@1.1.6: {} + + slugify@1.6.6: {} + + smart-buffer@4.2.0: {} + + socks-proxy-agent@7.0.0: + dependencies: + agent-base: 6.0.2 + debug: 4.3.7(supports-color@8.1.1) + socks: 2.8.3 + transitivePeerDependencies: + - supports-color + + socks@2.8.3: + dependencies: + ip-address: 9.0.5 + smart-buffer: 4.2.0 + + sortablejs@1.15.0: {} + + source-list-map@2.0.1: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + spdx-compare@1.0.0: + dependencies: + array-find-index: 1.0.2 + spdx-expression-parse: 3.0.1 + spdx-ranges: 2.1.1 + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.20 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.20 + + spdx-license-ids@3.0.20: {} + + spdx-ranges@2.1.1: {} + + spdx-satisfies@5.0.1: + dependencies: + spdx-compare: 1.0.0 + spdx-expression-parse: 3.0.1 + spdx-ranges: 2.1.1 + + sprintf-js@1.1.3: {} + + ssri@9.0.1: + dependencies: + minipass: 3.3.6 + + stable-hash@0.0.4: {} + + statuses@1.5.0: {} + + statuses@2.0.1: {} + + stdin-discarder@0.1.0: + dependencies: + bl: 5.1.0 + + stoppable@1.1.0: {} + + stream-shift@1.0.3: {} + + streamx@2.20.1: + dependencies: + fast-fifo: 1.3.2 + queue-tick: 1.0.1 + text-decoder: 1.2.0 + optionalDependencies: + bare-events: 2.4.2 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@6.1.0: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 10.4.0 + strip-ansi: 7.1.0 + + string.prototype.trim@1.2.9: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + optional: true + + string.prototype.trimend@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + optional: true + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + optional: true + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-bom@3.0.0: + optional: true + + strip-indent@4.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + stylehacks@7.0.4(postcss@8.4.47): + dependencies: + browserslist: 4.23.3 + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-color@9.4.0: {} + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-pathdata@6.0.3: {} + + svg2ttf@6.0.3: + dependencies: + '@xmldom/xmldom': 0.7.13 + argparse: 2.0.1 + cubic2quad: 1.2.1 + lodash: 4.17.21 + microbuffer: 1.0.0 + svgpath: 2.6.0 + + svgicons2svgfont@12.0.0: + dependencies: + commander: 9.5.0 + glob: 8.1.0 + sax: 1.4.1 + svg-pathdata: 6.0.3 + + svgo@3.3.2: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.1.0 + css-tree: 2.3.1 + css-what: 6.1.0 + csso: 5.0.5 + picocolors: 1.1.0 + + svgpath@2.6.0: {} + + tabbable@5.3.3: {} + + tapable@1.1.3: {} + + tapable@2.2.1: {} + + tar-fs@2.1.1: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.2 + tar-stream: 2.2.0 + + tar-fs@3.0.6: + dependencies: + pump: 3.0.2 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 2.3.5 + bare-path: 2.1.3 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.4 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.1.7: + dependencies: + b4a: 1.6.6 + fast-fifo: 1.3.2 + streamx: 2.20.1 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + terser-webpack-plugin@5.3.10(@swc/core@1.7.26)(esbuild@0.23.1)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.33.0 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + optionalDependencies: + '@swc/core': 1.7.26 + esbuild: 0.23.1 + + terser@5.33.0: + dependencies: + '@jridgewell/source-map': 0.3.6 + acorn: 8.12.1 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + text-decoder@1.2.0: + dependencies: + b4a: 1.6.6 + + text-table@0.2.0: {} + + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + + ticky@1.0.1: {} + + tmp@0.2.3: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + totalist@3.0.1: {} + + tr46@0.0.3: {} + + treeify@1.1.0: {} + + trim-newlines@4.1.1: {} + + ts-api-utils@1.3.0(typescript@5.6.2): + dependencies: + typescript: 5.6.2 + + ts-loader@9.5.1(typescript@5.6.2)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)): + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.17.1 + micromatch: 4.0.8 + semver: 7.6.3 + source-map: 0.7.4 + typescript: 5.6.2 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + + tsc-alias@1.8.10: + dependencies: + chokidar: 3.6.0 + commander: 9.5.0 + globby: 11.1.0 + mylas: 2.1.13 + normalize-path: 3.0.0 + plimit-lit: 1.6.1 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + optional: true + + tslib@2.7.0: {} + + tsscmp@1.0.6: {} + + ttf2eot@3.1.0: + dependencies: + argparse: 2.0.1 + + ttf2woff2@5.0.0: + dependencies: + bindings: 1.5.0 + bufferstreams: 3.0.0 + nan: 2.20.0 + node-gyp: 9.4.1 + transitivePeerDependencies: + - bluebird + - supports-color + + ttf2woff@3.0.0: + dependencies: + argparse: 2.0.1 + pako: 1.0.11 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tunnel@0.0.6: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@1.4.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typed-array-buffer@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + optional: true + + typed-array-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + optional: true + + typed-array-byte-offset@1.0.2: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + optional: true + + typed-array-length@1.0.6: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + optional: true + + typed-rest-client@1.8.11: + dependencies: + qs: 6.13.0 + tunnel: 0.0.6 + underscore: 1.13.7 + + typescript-eslint@8.7.0(eslint@9.11.1)(typescript@5.6.2): + dependencies: + '@typescript-eslint/eslint-plugin': 8.7.0(@typescript-eslint/parser@8.7.0(eslint@9.11.1)(typescript@5.6.2))(eslint@9.11.1)(typescript@5.6.2) + '@typescript-eslint/parser': 8.7.0(eslint@9.11.1)(typescript@5.6.2) + '@typescript-eslint/utils': 8.7.0(eslint@9.11.1)(typescript@5.6.2) + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - eslint + - supports-color + + typescript@4.9.5: {} + + typescript@5.6.2: {} + + uc.micro@2.1.0: {} + + uglify-js@3.19.3: + optional: true + + unbox-primitive@1.0.2: + dependencies: + call-bind: 1.0.7 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + optional: true + + uncontrollable@5.1.0(react@16.8.4): + dependencies: + invariant: 2.2.4 + react: 16.8.4 + + underscore@1.13.7: {} + + undici@6.19.8: {} + + unicorn-magic@0.1.0: {} + + unique-filename@2.0.1: + dependencies: + unique-slug: 3.0.0 + + unique-slug@3.0.0: + dependencies: + imurmurhash: 0.1.4 + + universal-user-agent@7.0.2: {} + + universalify@2.0.1: {} + + update-browserslist-db@1.1.0(browserslist@4.23.3): + dependencies: + browserslist: 4.23.3 + escalade: 3.2.0 + picocolors: 1.1.0 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-join@4.0.1: {} + + util-deprecate@1.0.2: {} + + utila@0.4.0: {} + + uuid@8.3.2: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + varint@6.0.0: {} + + vary@1.1.2: {} + + vscode-uri@3.0.8: {} + + warning@3.0.0: + dependencies: + loose-envify: 1.4.0 + + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 + + watchpack@2.4.2: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + webidl-conversions@3.0.1: {} + + webpack-bundle-analyzer@4.10.2: + dependencies: + '@discoveryjs/json-ext': 0.5.7 + acorn: 8.12.1 + acorn-walk: 8.3.4 + commander: 7.2.0 + debounce: 1.2.1 + escape-string-regexp: 4.0.0 + gzip-size: 6.0.0 + html-escaper: 2.0.2 + opener: 1.5.2 + picocolors: 1.1.0 + sirv: 2.0.4 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0): + dependencies: + '@discoveryjs/json-ext': 0.5.7 + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0))(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0))(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0))(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + colorette: 2.0.20 + commander: 10.0.1 + cross-spawn: 7.0.3 + envinfo: 7.14.0 + fastest-levenshtein: 1.0.16 + import-local: 3.2.0 + interpret: 3.1.1 + rechoir: 0.8.0 + webpack: 5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4) + webpack-merge: 5.10.0 + optionalDependencies: + webpack-bundle-analyzer: 4.10.2 + + webpack-merge@5.10.0: + dependencies: + clone-deep: 4.0.1 + flat: 5.0.2 + wildcard: 2.0.1 + + webpack-node-externals@3.0.0: {} + + webpack-require-from@1.8.6(tapable@2.2.1): + dependencies: + tapable: 2.2.1 + + webpack-sources@1.4.3: + dependencies: + source-list-map: 2.0.1 + source-map: 0.6.1 + + webpack-sources@3.2.3: {} + + webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4): + dependencies: + '@types/estree': 1.0.6 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/wasm-edit': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + acorn: 8.12.1 + acorn-import-attributes: 1.9.5(acorn@8.12.1) + browserslist: 4.23.3 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.17.1 + es-module-lexer: 1.5.4 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.10(@swc/core@1.7.26)(esbuild@0.23.1)(webpack@5.94.0(@swc/core@1.7.26)(esbuild@0.23.1)(webpack-cli@5.1.4)) + watchpack: 2.4.2 + webpack-sources: 3.2.3 + optionalDependencies: + webpack-cli: 5.1.4(webpack-bundle-analyzer@4.10.2)(webpack@5.94.0) + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-boxed-primitive@1.0.2: + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + optional: true + + which-typed-array@1.1.15: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + optional: true + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + wildcard@2.0.1: {} + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + workerpool@6.5.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + ws@7.5.10: {} + + xml2js@0.5.0: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yaml@1.10.2: {} + + yargs-parser@20.2.9: {} + + yargs-parser@21.1.1: {} + + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yazl@2.5.1: + dependencies: + buffer-crc32: 0.2.13 + + ylru@1.4.0: {} + + yocto-queue@0.1.0: {} diff --git a/icons.fig b/resources/icons.fig similarity index 100% rename from icons.fig rename to resources/icons.fig diff --git a/scripts/applyIconsContribution.js b/scripts/applyIconsContribution.js deleted file mode 100644 index b015a20887e9d..0000000000000 --- a/scripts/applyIconsContribution.js +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -const fs = require('fs'); - -// Update the icons contribution point in package.json -const package = require('../package.json'); -const icons = require('../dist/icons-contribution.json').icons; -if (JSON.stringify(package.contributes.icons) !== JSON.stringify(icons)) { - package.contributes.icons = icons; - const packageJSON = `${JSON.stringify(package, undefined, '\t')}\n`; - fs.writeFileSync('./package.json', packageJSON); -} - -fs.rmSync('./dist/icons-contribution.json'); - -// Update the scss file -const newScss = fs.readFileSync('./dist/glicons.scss', 'utf8'); -const scss = fs.readFileSync('./src/webviews/apps/shared/glicons.scss', 'utf8'); -if (scss !== newScss) { - fs.writeFileSync('./src/webviews/apps/shared/glicons.scss', newScss); -} - -fs.rmSync('./dist/glicons.scss'); diff --git a/scripts/applyIconsContribution.mjs b/scripts/applyIconsContribution.mjs new file mode 100644 index 0000000000000..91501351b5c0a --- /dev/null +++ b/scripts/applyIconsContribution.mjs @@ -0,0 +1,32 @@ +import fs from 'fs'; + +const packageJSONPromises = Promise.all([ + import('../package.json', { assert: { type: 'json' } }), + import('../dist/icons-contribution.json', { assert: { type: 'json' } }), +]); + +const scssPromises = Promise.all([ + fs.promises.readFile('./dist/glicons.scss', 'utf8'), + fs.promises.readFile('./src/webviews/apps/shared/glicons.scss', 'utf8'), +]); + +let pending = []; + +// Update the icons contribution point in package.json +const [{ default: packageJSON }, { default: icons }] = await packageJSONPromises; + +if (JSON.stringify(packageJSON.contributes.icons) !== JSON.stringify(icons.icons)) { + packageJSON.contributes.icons = icons; + const json = `${JSON.stringify(packageJSON, undefined, '\t')}\n`; + pending.push(fs.promises.writeFile('./package.json', json)); +} + +// Update the scss file +const [newScss, scss] = await scssPromises; + +if (scss !== newScss) { + pending.push(fs.promises.writeFile('./src/webviews/apps/shared/glicons.scss', newScss)); +} + +pending.push(fs.promises.rm('./dist/icons-contribution.json'), fs.promises.rm('./dist/glicons.scss')); +await Promise.allSettled(pending); diff --git a/scripts/applyInsidersPatch.js b/scripts/applyInsidersPatch.js deleted file mode 100644 index 1e034fd74cf9f..0000000000000 --- a/scripts/applyInsidersPatch.js +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -const fs = require('fs'); - -// Patch README -const insert = fs.readFileSync('./README.insiders.md', { encoding: 'utf8' }); -if (insert.trim().length !== 0) { - const data = fs.readFileSync('./README.md', { encoding: 'utf8' }); - fs.writeFileSync('./README.md', `${insert}\n${data}`); -} - -// Patch package.json -const date = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/New_York' })); -let packageJSON = require('../package.json'); - -packageJSON = JSON.stringify( - { - ...packageJSON, - name: `${packageJSON.name}-insiders`, - displayName: 'GitLens (Insiders)', - version: `${String(date.getFullYear())}.${date.getMonth() + 1}.${date.getDate()}${String( - date.getHours(), - ).padStart(2, '0')}`, - preview: true, - }, - undefined, - '\t', -); -packageJSON += '\n'; - -fs.writeFileSync('./package.json', packageJSON); diff --git a/scripts/applyPreReleasePatch.js b/scripts/applyPreReleasePatch.mjs similarity index 84% rename from scripts/applyPreReleasePatch.js rename to scripts/applyPreReleasePatch.mjs index 50dc86a8888b2..c859578458e0d 100644 --- a/scripts/applyPreReleasePatch.js +++ b/scripts/applyPreReleasePatch.mjs @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -const fs = require('fs'); +import fs from 'fs'; +import pkg from '../package.json' assert { type: 'json' }; // Patch README const insert = fs.readFileSync('./README.pre.md', { encoding: 'utf8' }); @@ -10,11 +11,10 @@ if (insert.trim().length !== 0) { // Patch package.json const date = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/New_York' })); -let packageJSON = require('../package.json'); -packageJSON = JSON.stringify( +let packageJSON = JSON.stringify( { - ...packageJSON, + ...pkg, version: `${String(date.getFullYear())}.${date.getMonth() + 1}.${date.getDate()}${String( date.getHours(), ).padStart(2, '0')}`, diff --git a/esbuild.mjs b/scripts/esbuild.mjs similarity index 89% rename from esbuild.mjs rename to scripts/esbuild.mjs index b8b178d2449dc..10b1e111c3107 100644 --- a/esbuild.mjs +++ b/scripts/esbuild.mjs @@ -3,9 +3,10 @@ import { sassPlugin } from 'esbuild-sass-plugin'; import * as fs from 'fs'; import * as path from 'path'; import { minify } from 'terser'; -import { URL } from 'url'; +import { fileURLToPath } from 'url'; -const __dirname = new URL('.', import.meta.url).pathname.substring(1); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.join(path.dirname(__filename), '..'); const args = process.argv.slice(2); @@ -55,13 +56,12 @@ async function buildExtension(target, mode) { const alias = { '@env': path.resolve(__dirname, 'src', 'env', target === 'webworker' ? 'browser' : target), + // Stupid dependency that is used by `http[s]-proxy-agent` + debug: path.resolve(__dirname, 'patches', 'debug.js'), // This dependency is very large, and isn't needed for our use-case tr46: path.resolve(__dirname, 'patches', 'tr46.js'), - // Stupid dependency that is used by `http-proxy-agent` - debug: - target === 'webworker' - ? path.resolve(__dirname, 'node_modules', 'debug', 'src', 'browser.js') - : path.resolve(__dirname, 'node_modules', 'debug', 'src', 'node.js'), + // This dependency is unnecessary for our use-case + 'whatwg-url': path.resolve(__dirname, 'patches', 'whatwg-url.js'), }; if (target === 'webworker') { @@ -84,11 +84,11 @@ async function buildExtension(target, mode) { logLevel: 'info', mainFields: target === 'webworker' ? ['browser', 'module', 'main'] : ['module', 'main'], metafile: true, - minify: mode === 'production' ? true : false, + minify: mode === 'production', outdir: out, platform: target === 'webworker' ? 'browser' : target, - sourcemap: mode === 'production' ? false : true, - // splitting: target === 'webworker' ? false : true, + sourcemap: mode !== 'production', + // splitting: target !== 'webworker', // chunkNames: 'feature-[name]-[hash]', target: ['es2022', 'chrome102', 'node16.14.2'], treeShaking: true, @@ -97,6 +97,9 @@ async function buildExtension(target, mode) { plugins: plugins, }); + if (!fs.existsSync(path.join('dist', 'meta'))) { + fs.mkdirSync(path.join('dist', 'meta')); + } fs.writeFileSync( path.join('dist', 'meta', `gitlens${target === 'webworker' ? '.browser' : ''}.json`), JSON.stringify(result.metafile), diff --git a/scripts/esbuild.tests.mjs b/scripts/esbuild.tests.mjs new file mode 100644 index 0000000000000..b6039285a4b24 --- /dev/null +++ b/scripts/esbuild.tests.mjs @@ -0,0 +1,73 @@ +/** @typedef {import('esbuild').BuildOptions} BuildOptions **/ +/** @typedef {import('esbuild').WatchOptions} WatchOptions **/ + +import * as esbuild from 'esbuild'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { nodeExternalsPlugin } from 'esbuild-node-externals'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.join(path.dirname(__filename), '..'); + +const args = process.argv.slice(2); + +let index = args.indexOf('--mode'); +const mode = (index >= 0 ? args[index + 1] : undefined) || 'none'; + +const watch = args.includes('--watch'); + +/** + * @param { 'node' | 'webworker' } target + * @param { 'production' | 'development' | 'none' } mode + */ +async function buildTests(target, mode) { + /** @type BuildOptions | WatchOptions */ + const config = { + bundle: true, + entryPoints: ['src/test/suite/index.ts', 'src/**/*.test.ts'], + entryNames: '[name]', + drop: ['debugger'], + external: ['vscode'], + format: 'cjs', + logLevel: 'info', + mainFields: target === 'webworker' ? ['browser', 'module', 'main'] : ['module', 'main'], + metafile: false, + minify: mode === 'production', + outdir: target === 'webworker' ? 'out/tests/browser' : 'out/tests', + platform: target === 'webworker' ? 'browser' : target, + plugins: [nodeExternalsPlugin()], + sourcemap: mode !== 'production', + target: ['es2022', 'chrome102', 'node16.14.2'], + treeShaking: true, + tsconfig: target === 'webworker' ? 'tsconfig.test.browser.json' : 'tsconfig.test.json', + }; + + config.alias = { + '@env': path.resolve(__dirname, 'src', 'env', target === 'webworker' ? 'browser' : target), + // Stupid dependency that is used by `http[s]-proxy-agent` + debug: path.resolve(__dirname, 'patches', 'debug.js'), + // This dependency is very large, and isn't needed for our use-case + tr46: path.resolve(__dirname, 'patches', 'tr46.js'), + // This dependency is unnecessary for our use-case + 'whatwg-url': path.resolve(__dirname, 'patches', 'whatwg-url.js'), + }; + + if (target === 'webworker') { + config.alias.path = 'path-browserify'; + config.alias.os = 'os-browserify/browser'; + } + + if (watch) { + const ctx = await esbuild.context(config); + await ctx.watch(); + } else { + await esbuild.build(config); + } +} + +try { + await Promise.allSettled([buildTests('node', mode)]); +} catch (ex) { + console.error(ex); + process.exit(1); +} diff --git a/scripts/generateEmojiShortcodeMap.js b/scripts/generateEmojiShortcodeMap.mjs similarity index 65% rename from scripts/generateEmojiShortcodeMap.js rename to scripts/generateEmojiShortcodeMap.mjs index c63521693a0ba..437d2d9688f05 100644 --- a/scripts/generateEmojiShortcodeMap.js +++ b/scripts/generateEmojiShortcodeMap.mjs @@ -1,7 +1,7 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -const fs = require('fs'); -const https = require('https'); -const path = require('path'); +import * as fs from 'fs'; +import * as https from 'https'; +import * as path from 'path'; +import LZString from 'lz-string'; async function generate() { /** @@ -15,16 +15,13 @@ async function generate() { const files = ['github.raw.json', 'emojibase.raw.json']; //, 'iamcal.raw.json', 'joypixels.raw.json']; for (const file of files) { - await download( - `https://raw.githubusercontent.com/milesj/emojibase/master/packages/data/en/shortcodes/${file}`, - file, - ); - /** * @type {Record}} */ - // eslint-disable-next-line import/no-dynamic-require - const data = require(path.join(process.cwd(), file)); + const data = await downloadToJSON( + `https://raw.githubusercontent.com/milesj/emojibase/master/packages/data/en/shortcodes/${file}`, + ); + for (const [emojis, codes] of Object.entries(data)) { const emoji = emojis .split('-') @@ -38,22 +35,19 @@ async function generate() { shortcodeMap.set(code, emoji); } } - - fs.unlink(file, () => {}); } // Get gitmoji data from https://github.com/carloscuesta/gitmoji // https://github.com/carloscuesta/gitmoji/blob/master/src/data/gitmojis.json - await download( - 'https://raw.githubusercontent.com/carloscuesta/gitmoji/master/src/data/gitmojis.json', - 'gitmojis.json', - ); - /** * @type {({ code: string; emoji: string })[]} */ - // eslint-disable-next-line import/no-dynamic-require - const gitmojis = require(path.join(process.cwd(), 'gitmojis.json')).gitmojis; + const gitmojis = ( + await downloadToJSON( + 'https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json', + ) + ).gitmojis; + for (const emoji of gitmojis) { if (emoji.code.startsWith(':') && emoji.code.endsWith(':')) { emoji.code = emoji.code.substring(1, emoji.code.length - 2); @@ -66,8 +60,6 @@ async function generate() { shortcodeMap.set(emoji.code, emoji.emoji); } - fs.unlink('gitmojis.json', () => {}); - // Sort the emojis for easier diff checking const list = [...shortcodeMap.entries()]; list.sort(); @@ -77,18 +69,21 @@ async function generate() { return m; }, Object.create(null)); - fs.writeFileSync(path.join(process.cwd(), 'src/emojis.json'), JSON.stringify(map), 'utf8'); + fs.writeFileSync( + path.join(process.cwd(), 'src/emojis.generated.ts'), + `export const emojis = '${LZString.compressToBase64(JSON.stringify(map))}';\n`, + 'utf8', + ); } -function download(url, destination) { +function downloadToJSON(url) { return new Promise(resolve => { - const stream = fs.createWriteStream(destination); https.get(url, rsp => { - rsp.pipe(stream); - stream.on('finish', () => { - stream.close(); - resolve(); - }); + rsp.setEncoding('utf8'); + + let data = ''; + rsp.on('data', chunk => (data += chunk)); + rsp.on('end', () => resolve(JSON.parse(data))); }); }); } diff --git a/scripts/generateLicenses.mjs b/scripts/generateLicenses.mjs index 4fb3c4c812e21..122b9be9c4be2 100644 --- a/scripts/generateLicenses.mjs +++ b/scripts/generateLicenses.mjs @@ -2,9 +2,7 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import * as fs from 'fs'; import * as path from 'path'; -import fetch from 'node-fetch'; import * as checker from 'license-checker-rseidelsohn'; -import { spawn } from 'child_process'; /** @typedef { { licenses: string; repository: string; licenseFile: string } } PackageInfo **/ @@ -54,8 +52,8 @@ async function generateThirdpartyNotices(packages) { const index = key.lastIndexOf('@'); if (index !== -1) { - name = key.substr(0, index); - version = key.substr(index + 1); + name = key.substring(0, index); + version = key.substring(index + 1); } else { name = key; } @@ -65,7 +63,7 @@ async function generateThirdpartyNotices(packages) { let license; if (data.licenseFile.startsWith('https://')) { - const response = await fetch(data.licenseFile); + const response = await fetch(data.licenseFile, { method: 'GET' }); license = await response.text(); } else { license = fs.readFileSync(data.licenseFile, 'utf8'); diff --git a/scripts/prep-release.mjs b/scripts/prep-release.mjs new file mode 100644 index 0000000000000..edb91d4a62862 --- /dev/null +++ b/scripts/prep-release.mjs @@ -0,0 +1,80 @@ +import { exec } from 'child_process'; +import { readFileSync, writeFileSync } from 'fs'; +import { createInterface } from 'readline'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const versionRegex = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/; + +const changelogPath = path.join(__dirname, '..', 'CHANGELOG.md'); +console.log(changelogPath); +let data = readFileSync(changelogPath, 'utf8'); + +// Find the current version number +const match = /\[unreleased\]: https:\/\/github\.com\/gitkraken\/vscode-gitlens\/compare\/v(.+)\.\.\.HEAD/.exec(data); +const currentVersion = match?.[1]; +if (currentVersion == null || versionRegex.test(currentVersion) === false) { + console.error('Unable to find current version number.'); + currentVersion = '0.0.0'; +} + +// Create readline interface for getting input from user +const rl = createInterface({ + input: process.stdin, + output: process.stdout, +}); + +// Ask for new version number +rl.question(`Enter the new version number (format x.x.x, current is ${currentVersion}): `, function (version) { + // Validate the version input + if (!versionRegex.test(version)) { + console.error( + 'Invalid version number. Please use the format x.y.z where x, y, and z are positive numbers no greater than 4 digits.', + ); + rl.close(); + return; + } + + // Get today's date + const today = new Date(); + const yyyy = today.getFullYear(); + const mm = String(today.getMonth() + 1).padStart(2, '0'); //January is 0! + const dd = String(today.getDate()).padStart(2, '0'); + + const newVersionHeader = `## [Unreleased]\n\n## [${version}] - ${yyyy}-${mm}-${dd}`; + const newVersionLink = `[${version}]: https://github.com/gitkraken/vscode-gitlens/compare/v${currentVersion}...gitkraken:v${version}`; + + // Add the new version header below the ## [Unreleased] header + data = data.replace('## [Unreleased]', newVersionHeader); + + const unreleasedLink = match[0].replace(/\/compare\/v(.+?)\.\.\.HEAD/, `/compare/v${version}...HEAD`); + + // Update the [unreleased]: line + data = data.replace(match[0], `${unreleasedLink}\n${newVersionLink}`); + + // Writing the updated version data to CHANGELOG + writeFileSync(changelogPath, data); + + // Stage CHANGELOG + exec('git add CHANGELOG.md', err => { + if (err) { + console.error(`Unable to stage CHANGELOG.md: ${err}`); + return; + } + + // Call 'pnpm version' to commit and create the tag + exec(`pnpm version ${version} -m "Bumps to v%s"`, err => { + if (err) { + console.error(`'pnpm version' failed: ${err}`); + return; + } + + console.log(`${version} is ready for release.`); + }); + }); + + rl.close(); +}); diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 304146b96d528..3e49dce7e0681 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -9,13 +9,31 @@ export declare global { export type ExcludeSome = Omit & { [P in K]-?: Exclude }; export type ExtractAll = { [K in keyof T]: T[K] extends U ? T[K] : never }; + export type ExtractPrefixes = T extends `${infer Prefix}${SEP}${infer Rest}` + ? Prefix | `${Prefix}${SEP}${ExtractPrefixes}` + : T; export type ExtractSome = Omit & { [P in K]-?: Extract }; export type RequireSome = Omit & { [P in K]-?: T[P] }; + export type RequireSomeWithProps = Omit & { + [P in K]-?: RequireSome; + }; export type AllNonNullable = { [P in keyof T]-?: NonNullable }; export type SomeNonNullable = Omit & { [P in K]-?: NonNullable }; export type NarrowRepo = ExcludeSome; export type NarrowRepos = ExcludeSome; + + export type Prefix

= T extends `${P}${S}${infer R}` + ? R + : never; + + export type Replace = Omit & { [P in K]: R }; + + export type StartsWith

= T extends `${P}${S}${string}` + ? T + : never; + + export type UnwrapCustomEvent = T extends CustomEvent ? U : never; } diff --git a/src/@types/lib.textEncoder.d.ts b/src/@types/lib.textEncoder.d.ts deleted file mode 100644 index 9c7bd7903edf2..0000000000000 --- a/src/@types/lib.textEncoder.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Define TextEncoder + TextDecoder globals for both browser and node runtimes -// See: https://github.com/microsoft/TypeScript/issues/31535 - -declare let TextDecoder: typeof import('util').TextDecoder; -declare let TextEncoder: typeof import('util').TextEncoder; diff --git a/src/@types/node-fetch.d.ts b/src/@types/node-fetch.d.ts index 11f26c474a0eb..d05f1c318c26d 100644 --- a/src/@types/node-fetch.d.ts +++ b/src/@types/node-fetch.d.ts @@ -1,4 +1,4 @@ -// Type definitions for node-fetch 2.5 +// Type definitions for node-fetch 2.6 // Project: https://github.com/bitinn/node-fetch // Definitions by: Torsten Werner // Niklas Lindgren @@ -11,13 +11,15 @@ // Alex Savin // Alexis Tyler // Jakub Kisielewski +// David Glasser // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped /// declare module 'node-fetch' { - import type { Agent } from 'http'; - import type { URLSearchParams, URL } from 'url'; + import FormData = require('form-data'); + import { RequestOptions } from 'http'; + import { URLSearchParams, URL } from 'url'; export class Request extends Body { constructor(input: RequestInfo, init?: RequestInit); @@ -30,7 +32,7 @@ declare module 'node-fetch' { url: string; // node-fetch extensions to the whatwg/fetch spec - agent?: Agent | ((parsedUrl: URL) => Agent) | undefined; + agent?: RequestOptions['agent'] | ((parsedUrl: URL) => RequestOptions['agent']); compress: boolean; counter: number; follow: number; @@ -47,9 +49,10 @@ declare module 'node-fetch' { headers?: HeadersInit | undefined; method?: string | undefined; redirect?: RequestRedirect | undefined; + signal?: AbortSignal | null | undefined; // node-fetch extensions - agent?: Agent | ((parsedUrl: URL) => Agent) | undefined; // =null http.Agent instance, allows custom proxy, certificate etc. + agent?: RequestOptions['agent'] | ((parsedUrl: URL) => RequestOptions['agent']); // =null http.Agent instance, allows custom proxy, certificate etc. compress?: boolean | undefined; // =true support gzip/deflate content encoding. false to disable follow?: number | undefined; // =20 maximum redirect count. 0 to not follow redirect size?: number | undefined; // =0 maximum response body size in bytes. 0 to disable @@ -188,7 +191,7 @@ declare module 'node-fetch' { export type HeadersInit = Headers | string[][] | { [key: string]: string }; // HeaderInit is exported to support backwards compatibility. See PR #34382 export type HeaderInit = HeadersInit; - export type BodyInit = ArrayBuffer | ArrayBufferView | NodeJS.ReadableStream | string | URLSearchParams; + export type BodyInit = ArrayBuffer | ArrayBufferView | NodeJS.ReadableStream | string | URLSearchParams | FormData; export type RequestInfo = string | URLLike | Request; declare function fetch(url: RequestInfo, init?: RequestInit): Promise; diff --git a/src/@types/vscode.git.d.ts b/src/@types/vscode.git.d.ts index 59b1052d25cdc..aa5d8bf9a29b9 100644 --- a/src/@types/vscode.git.d.ts +++ b/src/@types/vscode.git.d.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, Event, ProviderResult, Uri, Command } from 'vscode'; - import { GitErrorCodes, RefType, Status, ForcePushMode } from '../@types/vscode.git.enums'; export interface Git { @@ -94,6 +93,10 @@ export interface LogOptions { /** Max number of log entries to retrieve. If not specified, the default is 32. */ readonly maxEntries?: number; readonly path?: string; + /** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */ + readonly range?: string; + readonly reverse?: boolean; + readonly sortByAuthorDate?: boolean; } export interface CommitOptions { @@ -106,7 +109,13 @@ export interface CommitOptions { requireUserConfig?: boolean; useEditor?: boolean; verbose?: boolean; - postCommitCommand?: string; + /** + * string - execute the specified command after the commit operation + * undefined - execute the command specified in git.postCommitCommand + * after the commit operation + * null - do not execute any command after the commit operation + */ + postCommitCommand?: string | null; } export interface FetchOptions { @@ -117,11 +126,19 @@ export interface FetchOptions { depth?: number; } -export interface BranchQuery { - readonly remote?: boolean; - readonly pattern?: string; - readonly count?: number; +export interface InitOptions { + defaultBranch?: string; +} + +export interface RefQuery { readonly contains?: string; + readonly count?: number; + readonly pattern?: string; + readonly sort?: 'alphabetically' | 'committerdate'; +} + +export interface BranchQuery extends RefQuery { + readonly remote?: boolean; } export interface Repository { @@ -164,9 +181,12 @@ export interface Repository { createBranch(name: string, checkout: boolean, ref?: string): Promise; deleteBranch(name: string, force?: boolean): Promise; getBranch(name: string): Promise; - getBranches(query: BranchQuery): Promise; + getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise; + getBranchBase(name: string): Promise; setBranchUpstream(name: string, upstream: string): Promise; + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; + getMergeBase(ref1: string, ref2: string): Promise; tag(name: string, upstream: string): Promise; @@ -233,6 +253,31 @@ export interface PushErrorHandler { ): Promise; } +export interface BranchProtection { + readonly remote: string; + readonly rules: BranchProtectionRule[]; +} + +export interface BranchProtectionRule { + readonly include?: string[]; + readonly exclude?: string[]; +} + +export interface BranchProtectionProvider { + onDidChangeBranchProtection: Event; + provideBranchProtection(): BranchProtection[]; +} + +export interface CommitMessageProvider { + readonly title: string; + readonly icon?: Uri | { light: Uri; dark: Uri } | ThemeIcon; + provideCommitMessage( + repository: Repository, + changes: string[], + cancellationToken?: CancellationToken, + ): Promise; +} + export type APIState = 'uninitialized' | 'initialized'; export interface PublishEvent { @@ -251,14 +296,16 @@ export interface API { toGitUri(uri: Uri, ref: string): Uri; getRepository(uri: Uri): Repository | null; - init(root: Uri): Promise; - openRepository?(root: Uri): Promise; + init(root: Uri, options?: InitOptions): Promise; + openRepository(root: Uri): Promise; registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; registerCredentialsProvider(provider: CredentialsProvider): Disposable; registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; registerPushErrorHandler(handler: PushErrorHandler): Disposable; + registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable; + registerCommitMessageProvider(provider: CommitMessageProvider): Disposable; } export interface GitExtension { diff --git a/src/@types/vscode.git.enums.ts b/src/@types/vscode.git.enums.ts index 1e87702ad4e51..862663af9013a 100644 --- a/src/@types/vscode.git.enums.ts +++ b/src/@types/vscode.git.enums.ts @@ -6,6 +6,7 @@ export const enum ForcePushMode { Force, ForceWithLease, + ForceWithLeaseIfIncludes, } export const enum RefType { @@ -26,6 +27,8 @@ export const enum Status { UNTRACKED, IGNORED, INTENT_TO_ADD, + INTENT_TO_RENAME, + TYPE_CHANGED, ADDED_BY_US, ADDED_BY_THEM, @@ -48,6 +51,8 @@ export const enum GitErrorCodes { StashConflict = 'StashConflict', UnmergedChanges = 'UnmergedChanges', PushRejected = 'PushRejected', + ForcePushWithLeaseRejected = 'ForcePushWithLeaseRejected', + ForcePushWithLeaseIfIncludesRejected = 'ForcePushWithLeaseIfIncludesRejected', RemoteConnectionError = 'RemoteConnectionError', DirtyWorkTree = 'DirtyWorkTree', CantOpenResource = 'CantOpenResource', @@ -73,4 +78,7 @@ export const enum GitErrorCodes { NoPathFound = 'NoPathFound', UnknownPath = 'UnknownPath', EmptyCommitMessage = 'EmptyCommitMessage', + BranchFastForwardRejected = 'BranchFastForwardRejected', + BranchNotYetBorn = 'BranchNotYetBorn', + TagConflict = 'TagConflict', } diff --git a/src/@types/vscode.git.resources.d.ts b/src/@types/vscode.git.resources.d.ts index 125055f596d1c..44e91a5df2016 100644 --- a/src/@types/vscode.git.resources.d.ts +++ b/src/@types/vscode.git.resources.d.ts @@ -1,5 +1,6 @@ -import { Status as ScmStatus } from '../@types/vscode.git.d.ts'; -import { ScmResourceGroupType } from '../@types/vscode.git.resources.enums'; +import type { SourceControlResourceState } from 'vscode'; +import type { Status as ScmStatus } from '../@types/vscode.git.enums.ts'; +import type { ScmResourceGroupType } from '../@types/vscode.git.resources.enums'; export interface ScmResource extends SourceControlResourceState { readonly resourceGroupType?: ScmResourceGroupType; diff --git a/src/@types/vscode.git.uri.ts b/src/@types/vscode.git.uri.ts new file mode 100644 index 0000000000000..e7b354c78dfb5 --- /dev/null +++ b/src/@types/vscode.git.uri.ts @@ -0,0 +1,18 @@ +import { Uri } from 'vscode'; +import { Schemes } from '../constants'; + +export interface GitUriQuery { + path: string; + ref: string; + + decoration?: string; +} + +export function getQueryDataFromScmGitUri(uri: Uri): GitUriQuery | undefined { + if (uri.scheme === Schemes.Git) { + try { + return JSON.parse(uri.query) as GitUriQuery; + } catch {} + } + return undefined; +} diff --git a/src/@types/vscode.iconpath.d.ts b/src/@types/vscode.iconpath.d.ts new file mode 100644 index 0000000000000..710734932efcb --- /dev/null +++ b/src/@types/vscode.iconpath.d.ts @@ -0,0 +1,3 @@ +import type { ThemeIcon, Uri } from 'vscode'; + +export type IconPath = { light: Uri; dark: Uri } | ThemeIcon; diff --git a/src/@types/vscode.proposed.languageModels.d.ts b/src/@types/vscode.proposed.languageModels.d.ts new file mode 100644 index 0000000000000..b2d732fd1f550 --- /dev/null +++ b/src/@types/vscode.proposed.languageModels.d.ts @@ -0,0 +1,324 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // https://github.com/microsoft/vscode/issues/206265 + + /** + * Represents the role of a chat message. This is either the user or the assistant. + */ + export enum LanguageModelChatMessageRole { + /** + * The user role, e.g the human interacting with a language model. + */ + User = 1, + + /** + * The assistant role, e.g. the language model generating responses. + */ + Assistant = 2, + } + + /** + * Represents a message in a chat. Can assume different roles, like user or assistant. + */ + export class LanguageModelChatMessage { + /** + * Utility to create a new user message. + * + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + static User(content: string, name?: string): LanguageModelChatMessage; + + /** + * Utility to create a new assistant message. + * + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + static Assistant(content: string, name?: string): LanguageModelChatMessage; + + /** + * The role of this message. + */ + role: LanguageModelChatMessageRole; + + /** + * The content of this message. + */ + content: string; + + /** + * The optional name of a user for this message. + */ + name: string | undefined; + + /** + * Create a new user message. + * + * @param role The role of the message. + * @param content The content of the message. + * @param name The optional name of a user for the message. + */ + constructor(role: LanguageModelChatMessageRole, content: string, name?: string); + } + + /** + * Represents a language model response. + * + * @see {@link LanguageModelAccess.chatRequest} + */ + export interface LanguageModelChatResponse { + /** + * An async iterable that is a stream of text chunks forming the overall response. + * + * *Note* that this stream will error when during data receiving an error occurs. Consumers of + * the stream should handle the errors accordingly. + * + * @example + * ```ts + * try { + * // consume stream + * for await (const chunk of response.stream) { + * console.log(chunk); + * } + * + * } catch(e) { + * // stream ended with an error + * console.error(e); + * } + * ``` + * + * To cancel the stream, the consumer can {@link CancellationTokenSource.cancel cancel} the token that was used to make the request + * or break from the for-loop. + */ + text: AsyncIterable; + } + + /** + * Represents a language model for making chat requests. + * + * @see {@link lm.selectChatModels} + */ + export interface LanguageModelChat { + /** + * Human-readable name of the language model. + */ + readonly name: string; + + /** + * Opaque identifier of the language model. + */ + readonly id: string; + + /** + * A well-know identifier of the vendor of the language model, a sample is `copilot`, but + * values are defined by extensions contributing chat models and need to be looked up with them. + */ + readonly vendor: string; + + /** + * Opaque family-name of the language model. Values might be `gpt-3.5-turbo`, `gpt4`, `phi2`, or `llama` + * but they are defined by extensions contributing languages and subject to change. + */ + readonly family: string; + + /** + * Opaque version string of the model. This is defined by the extension contributing the language model + * and subject to change. + */ + readonly version: string; + + /** + * The maximum number of tokens that can be sent to the model in a single request. + */ + readonly maxInputTokens: number; + + /** + * Make a chat request using a language model. + * + * *Note* that language model use may be subject to access restrictions and user consent. Calling this function + * for the first time (for a extension) will show a consent dialog to the user and because of that this function + * must _only be called in response to a user action!_ Extension can use {@link LanguageModelAccessInformation.canSendRequest} + * to check if they have the necessary permissions to make a request. + * + * This function will return a rejected promise if making a request to the language model is not + * possible. Reasons for this can be: + * + * - user consent not given, see {@link LanguageModelError.NoPermissions `NoPermissions`} + * - model does not exist anymore, see {@link LanguageModelError.NotFound `NotFound`} + * - quota limits exceeded, see {@link LanguageModelError.Blocked `Blocked`} + * - other issues in which case extension must check {@link LanguageModelError.cause `LanguageModelError.cause`} + * + * @param messages An array of message instances. + * @param options Options that control the request. + * @param token A cancellation token which controls the request. See {@link CancellationTokenSource} for how to create one. + * @returns A thenable that resolves to a {@link LanguageModelChatResponse}. The promise will reject when the request couldn't be made. + */ + sendRequest( + messages: LanguageModelChatMessage[], + options?: LanguageModelChatRequestOptions, + token?: CancellationToken, + ): Thenable; + + /** + * Count the number of tokens in a message using the model specific tokenizer-logic. + + * @param text A string or a message instance. + * @param token Optional cancellation token. See {@link CancellationTokenSource} for how to create one. + * @returns A thenable that resolves to the number of tokens. + */ + countTokens(text: string | LanguageModelChatMessage, token?: CancellationToken): Thenable; + } + + /** + * Describes how to select language models for chat requests. + * + * @see {@link lm.selectChatModels} + */ + export interface LanguageModelChatSelector { + /** + * A vendor of language models. + * @see {@link LanguageModelChat.vendor} + */ + vendor?: string; + + /** + * A family of language models. + * @see {@link LanguageModelChat.family} + */ + family?: string; + + /** + * The version of a language model. + * @see {@link LanguageModelChat.version} + */ + version?: string; + + /** + * The identifier of a language model. + * @see {@link LanguageModelChat.id} + */ + id?: string; + } + + /** + * An error type for language model specific errors. + * + * Consumers of language models should check the code property to determine specific + * failure causes, like `if(someError.code === vscode.LanguageModelError.NotFound.name) {...}` + * for the case of referring to an unknown language model. For unspecified errors the `cause`-property + * will contain the actual error. + */ + export class LanguageModelError extends Error { + /** + * The requestor does not have permissions to use this + * language model + */ + static NoPermissions(message?: string): LanguageModelError; + + /** + * The requestor is blocked from using this language model. + */ + static Blocked(message?: string): LanguageModelError; + + /** + * The language model does not exist. + */ + static NotFound(message?: string): LanguageModelError; + + /** + * A code that identifies this error. + * + * Possible values are names of errors, like {@linkcode LanguageModelError.NotFound NotFound}, + * or `Unknown` for unspecified errors from the language model itself. In the latter case the + * `cause`-property will contain the actual error. + */ + readonly code: string; + } + + /** + * Options for making a chat request using a language model. + * + * @see {@link LanguageModelChat.sendRequest} + */ + export interface LanguageModelChatRequestOptions { + /** + * A human-readable message that explains why access to a language model is needed and what feature is enabled by it. + */ + justification?: string; + + /** + * A set of options that control the behavior of the language model. These options are specific to the language model + * and need to be lookup in the respective documentation. + */ + modelOptions?: { [name: string]: any }; + } + + /** + * Namespace for language model related functionality. + */ + export namespace lm { + /** + * An event that is fired when the set of available chat models changes. + */ + export const onDidChangeChatModels: Event; + + /** + * Select chat models by a {@link LanguageModelChatSelector selector}. This can yield in multiple or no chat models and + * extensions must handle these cases, esp. when no chat model exists, gracefully. + * + * ```ts + * + * const models = await vscode.lm.selectChatModels({family: 'gpt-3.5-turbo'})!; + * if (models.length > 0) { + * const [first] = models; + * const response = await first.sendRequest(...) + * // ... + * } else { + * // NO chat models available + * } + * ``` + * + * *Note* that extensions can hold-on to the results returned by this function and use them later. However, when the + * {@link onDidChangeChatModels}-event is fired the list of chat models might have changed and extensions should re-query. + * + * @param selector A chat model selector. When omitted all chat models are returned. + * @returns An array of chat models, can be empty! + */ + export function selectChatModels(selector?: LanguageModelChatSelector): Thenable; + } + + /** + * Represents extension specific information about the access to language models. + */ + export interface LanguageModelAccessInformation { + /** + * An event that fires when access information changes. + */ + onDidChange: Event; + + /** + * Checks if a request can be made to a language model. + * + * *Note* that calling this function will not trigger a consent UI but just checks for a persisted state. + * + * @param chat A language model chat object. + * @return `true` if a request can be made, `false` if not, `undefined` if the language + * model does not exist or consent hasn't been asked for. + */ + canSendRequest(chat: LanguageModelChat): boolean | undefined; + } + + export interface ExtensionContext { + /** + * An object that keeps information about how this extension can use language models. + * + * @see {@link lm.sendChatRequest} + */ + readonly languageModelAccessInformation: LanguageModelAccessInformation; + } +} diff --git a/src/@types/vsls.d.ts b/src/@types/vsls.d.ts index 3060b7597cce3..310c3b7d64dc5 100644 --- a/src/@types/vsls.d.ts +++ b/src/@types/vsls.d.ts @@ -74,7 +74,7 @@ export interface SharedServiceProxy { readonly onDidChangeIsServiceAvailable: Event; onNotify(name: string, handler: NotifyHandler): void; - request(name: string, args: any[], cancellation?: CancellationToken): Promise; + request(name: string, args: any[], cancellation?: CancellationToken): Promise; notify(name: string, args: object): void; } diff --git a/src/ai/aiProviderService.ts b/src/ai/aiProviderService.ts new file mode 100644 index 0000000000000..cc6e66a3cb262 --- /dev/null +++ b/src/ai/aiProviderService.ts @@ -0,0 +1,649 @@ +import type { CancellationToken, Disposable, MessageItem, ProgressOptions, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import type { AIModels, AIProviders, SupportedAIModels } from '../constants.ai'; +import type { AIGenerateDraftEvent, Sources, TelemetryEvents } from '../constants.telemetry'; +import type { Container } from '../container'; +import { CancellationError } from '../errors'; +import type { GitCommit } from '../git/models/commit'; +import { assertsCommitHasFullDetails, isCommit } from '../git/models/commit'; +import { uncommitted, uncommittedStaged } from '../git/models/constants'; +import type { GitRevisionReference } from '../git/models/reference'; +import type { Repository } from '../git/models/repository'; +import { isRepository } from '../git/models/repository'; +import { showAIModelPicker } from '../quickpicks/aiModelPicker'; +import { getSettledValue } from '../system/promise'; +import { configuration } from '../system/vscode/configuration'; +import type { Storage } from '../system/vscode/storage'; +import { supportedInVSCodeVersion } from '../system/vscode/utils'; +import type { TelemetryService } from '../telemetry/telemetry'; +import { AnthropicProvider } from './anthropicProvider'; +import { GeminiProvider } from './geminiProvider'; +import { OpenAIProvider } from './openaiProvider'; +import type { VSCodeAIModels } from './vscodeProvider'; +import { isVSCodeAIModel, VSCodeAIProvider } from './vscodeProvider'; + +export interface AIModel< + Provider extends AIProviders = AIProviders, + Model extends AIModels = AIModels, +> { + readonly id: Model; + readonly name: string; + readonly maxTokens: number; + readonly provider: { + id: Provider; + name: string; + }; + + readonly default?: boolean; + readonly hidden?: boolean; +} + +interface AIProviderConstructor { + new (container: Container): AIProvider; +} + +const _supportedProviderTypes = new Map([ + ...(supportedInVSCodeVersion('language-models') ? [['vscode', VSCodeAIProvider]] : ([] as any)), + ['openai', OpenAIProvider], + ['anthropic', AnthropicProvider], + ['gemini', GeminiProvider], +]); + +export interface AIProvider extends Disposable { + readonly id: Provider; + readonly name: string; + + getModels(): Promise>[]>; + + explainChanges( + model: AIModel>, + message: string, + diff: string, + reporting: TelemetryEvents['ai/explain'], + options?: { cancellation?: CancellationToken }, + ): Promise; + generateCommitMessage( + model: AIModel>, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise; + generateDraftMessage( + model: AIModel>, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { cancellation?: CancellationToken; context?: string; codeSuggestion?: boolean }, + ): Promise; +} + +export class AIProviderService implements Disposable { + private _provider: AIProvider | undefined; + private _model: AIModel | undefined; + + constructor(private readonly container: Container) {} + + dispose() { + this._provider?.dispose(); + } + + get currentProviderId() { + return this._provider?.id; + } + + private getConfiguredModel(): { provider: AIProviders; model: AIModels } | undefined { + const qualifiedModelId = configuration.get('ai.experimental.model') ?? undefined; + if (qualifiedModelId != null) { + let [providerId, modelId] = qualifiedModelId.split(':') as [AIProviders, AIModels]; + if (providerId != null && this.supports(providerId)) { + if (modelId != null) { + return { provider: providerId, model: modelId }; + } else if (providerId === 'vscode') { + modelId = configuration.get('ai.experimental.vscode.model') as VSCodeAIModels; + if (modelId != null) { + // Model ids are in the form of `vendor:family` + if (/^(.+):(.+)$/.test(modelId)) { + return { provider: providerId, model: modelId }; + } + } + } + } + } + return undefined; + } + + async getModels(): Promise { + const providers = [..._supportedProviderTypes.values()].map(p => new p(this.container)); + const models = await Promise.allSettled(providers.map(p => p.getModels())); + return models.flatMap(m => getSettledValue(m, [])); + } + + private async getModel(options?: { force?: boolean; silent?: boolean }): Promise { + const cfg = this.getConfiguredModel(); + if (!options?.force && cfg?.provider != null && cfg?.model != null) { + const model = await this.getOrUpdateModel(cfg.provider, cfg.model); + if (model != null) return model; + } + + if (options?.silent) return undefined; + + const pick = await showAIModelPicker(this.container, cfg); + if (pick == null) return undefined; + + return this.getOrUpdateModel(pick.model); + } + + private getOrUpdateModel(model: AIModel): Promise; + private getOrUpdateModel(providerId: T, modelId: AIModels): Promise; + private async getOrUpdateModel( + modelOrProviderId: AIModel | AIProviders, + modelId?: AIModels, + ): Promise { + let providerId: AIProviders; + let model: AIModel | undefined; + if (typeof modelOrProviderId === 'string') { + providerId = modelOrProviderId; + } else { + model = modelOrProviderId; + providerId = model.provider.id; + } + + let changed = false; + + if (providerId !== this._provider?.id) { + changed = true; + this._provider?.dispose(); + + const type = _supportedProviderTypes.get(providerId); + if (type == null) { + this._provider = undefined; + this._model = undefined; + + return undefined; + } + + this._provider = new type(this.container); + } + + if (model == null) { + if (modelId != null && modelId === this._model?.id) { + model = this._model; + } else { + changed = true; + + model = (await this._provider.getModels())?.find(m => m.id === modelId); + if (model == null) { + this._model = undefined; + + return undefined; + } + } + } else if (model.id !== this._model?.id) { + changed = true; + } + + if (changed) { + if (isVSCodeAIModel(model)) { + await configuration.updateEffective(`ai.experimental.model`, 'vscode'); + await configuration.updateEffective(`ai.experimental.vscode.model`, model.id); + } else { + await configuration.updateEffective( + `ai.experimental.model`, + `${model.provider.id}:${model.id}` as SupportedAIModels, + ); + } + } + + this._model = model; + return model; + } + + async generateCommitMessage( + changes: string[], + sourceContext: { source: Sources }, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, + ): Promise; + async generateCommitMessage( + repoPath: Uri, + sourceContext: { source: Sources }, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, + ): Promise; + async generateCommitMessage( + repository: Repository, + sourceContext: { source: Sources }, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, + ): Promise; + async generateCommitMessage( + changesOrRepoOrPath: string[] | Repository | Uri, + sourceContext: { source: Sources }, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, + ): Promise { + const changes: string | undefined = await this.getChanges(changesOrRepoOrPath); + if (changes == null) return undefined; + + const model = await this.getModel(); + if (model == null) return undefined; + + const provider = this._provider!; + + const payload: TelemetryEvents['ai/generate'] = { + type: 'commitMessage', + 'model.id': model.id, + 'model.provider.id': model.provider.id, + 'model.provider.name': model.provider.name, + 'retry.count': 0, + }; + const source: Parameters[2] = { source: sourceContext.source }; + + const confirmed = await confirmAIProviderToS(model, this.container.storage); + if (!confirmed) { + this.container.telemetry.sendEvent('ai/generate', { ...payload, 'failed.reason': 'user-declined' }, source); + + return undefined; + } + + if (options?.cancellation?.isCancellationRequested) { + this.container.telemetry.sendEvent( + 'ai/generate', + { ...payload, 'failed.reason': 'user-cancelled' }, + source, + ); + + return undefined; + } + + const promise = provider.generateCommitMessage(model, changes, payload, { + cancellation: options?.cancellation, + context: options?.context, + }); + + const start = Date.now(); + try { + const result = await (options?.progress != null + ? window.withProgress(options.progress, () => promise) + : promise); + + payload['output.length'] = result?.length; + this.container.telemetry.sendEvent('ai/generate', { ...payload, duration: Date.now() - start }, source); + + return result; + } catch (ex) { + this.container.telemetry.sendEvent( + 'ai/generate', + { + ...payload, + duration: Date.now() - start, + ...(ex instanceof CancellationError + ? { 'failed.reason': 'user-cancelled' } + : { 'failed.reason': 'error', 'failed.error': String(ex) }), + }, + source, + ); + + throw ex; + } + } + + async generateDraftMessage( + changesOrRepoOrPath: string[] | Repository | Uri, + sourceContext: { source: Sources; type: AIGenerateDraftEvent['draftType'] }, + options?: { + cancellation?: CancellationToken; + context?: string; + progress?: ProgressOptions; + codeSuggestion?: boolean; + }, + ): Promise { + const changes: string | undefined = await this.getChanges(changesOrRepoOrPath); + if (changes == null) return undefined; + + const model = await this.getModel(); + if (model == null) return undefined; + + const provider = this._provider!; + + const payload: TelemetryEvents['ai/generate'] = { + type: 'draftMessage', + draftType: sourceContext.type, + 'model.id': model.id, + 'model.provider.id': model.provider.id, + 'model.provider.name': model.provider.name, + 'retry.count': 0, + }; + const source: Parameters[2] = { source: sourceContext.source }; + + const confirmed = await confirmAIProviderToS(model, this.container.storage); + if (!confirmed) { + this.container.telemetry.sendEvent('ai/generate', { ...payload, 'failed.reason': 'user-declined' }, source); + + return undefined; + } + + if (options?.cancellation?.isCancellationRequested) { + this.container.telemetry.sendEvent( + 'ai/generate', + { ...payload, 'failed.reason': 'user-cancelled' }, + source, + ); + + return undefined; + } + + const promise = provider.generateDraftMessage(model, changes, payload, { + cancellation: options?.cancellation, + context: options?.context, + codeSuggestion: options?.codeSuggestion, + }); + + const start = Date.now(); + try { + const result = await (options?.progress != null + ? window.withProgress(options.progress, () => promise) + : promise); + + payload['output.length'] = result?.length; + this.container.telemetry.sendEvent('ai/generate', { ...payload, duration: Date.now() - start }, source); + + return result; + } catch (ex) { + this.container.telemetry.sendEvent( + 'ai/generate', + { + ...payload, + duration: Date.now() - start, + ...(ex instanceof CancellationError + ? { 'failed.reason': 'user-cancelled' } + : { 'failed.reason': 'error', 'failed.error': String(ex) }), + }, + source, + ); + + throw ex; + } + } + + private async getChanges( + changesOrRepoOrPath: string[] | Repository | Uri, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, + ): Promise { + let changes: string; + if (Array.isArray(changesOrRepoOrPath)) { + changes = changesOrRepoOrPath.join('\n'); + } else { + const repository = isRepository(changesOrRepoOrPath) + ? changesOrRepoOrPath + : this.container.git.getRepository(changesOrRepoOrPath); + if (repository == null) throw new Error('Unable to find repository'); + + let diff = await this.container.git.getDiff(repository.uri, uncommittedStaged); + if (!diff?.contents) { + diff = await this.container.git.getDiff(repository.uri, uncommitted); + if (!diff?.contents) throw new Error('No changes to generate a commit message from.'); + } + if (options?.cancellation?.isCancellationRequested) return undefined; + + changes = diff.contents; + } + + return changes; + } + + async explainCommit( + commitOrRevision: GitRevisionReference | GitCommit, + sourceContext: { source: Sources; type: TelemetryEvents['ai/explain']['changeType'] }, + options?: { cancellation?: CancellationToken; progress?: ProgressOptions }, + ): Promise { + const diff = await this.container.git.getDiff(commitOrRevision.repoPath, commitOrRevision.ref); + if (!diff?.contents) throw new Error('No changes found to explain.'); + + const model = await this.getModel(); + if (model == null) return undefined; + + const provider = this._provider!; + + const payload: TelemetryEvents['ai/explain'] = { + type: 'change', + changeType: sourceContext.type, + 'model.id': model.id, + 'model.provider.id': model.provider.id, + 'model.provider.name': model.provider.name, + 'retry.count': 0, + }; + const source: Parameters[2] = { source: sourceContext.source }; + + const confirmed = await confirmAIProviderToS(model, this.container.storage); + if (!confirmed) { + this.container.telemetry.sendEvent('ai/explain', { ...payload, 'failed.reason': 'user-declined' }, source); + + return undefined; + } + + const commit = isCommit(commitOrRevision) + ? commitOrRevision + : await this.container.git.getCommit(commitOrRevision.repoPath, commitOrRevision.ref); + if (commit == null) throw new Error('Unable to find commit'); + + if (!commit.hasFullDetails()) { + await commit.ensureFullDetails(); + assertsCommitHasFullDetails(commit); + } + + if (options?.cancellation?.isCancellationRequested) { + this.container.telemetry.sendEvent('ai/explain', { ...payload, 'failed.reason': 'user-cancelled' }, source); + + return undefined; + } + + const promise = provider.explainChanges(model, commit.message, diff.contents, payload, { + cancellation: options?.cancellation, + }); + + const start = Date.now(); + try { + const result = await (options?.progress != null + ? window.withProgress(options.progress, () => promise) + : promise); + + payload['output.length'] = result?.length; + this.container.telemetry.sendEvent('ai/explain', { ...payload, duration: Date.now() - start }, source); + + return result; + } catch (ex) { + this.container.telemetry.sendEvent( + 'ai/explain', + { + ...payload, + duration: Date.now() - start, + ...(ex instanceof CancellationError + ? { 'failed.reason': 'user-cancelled' } + : { 'failed.reason': 'error', 'failed.error': String(ex) }), + }, + source, + ); + + throw ex; + } + } + + async reset(all?: boolean) { + let { _provider: provider } = this; + if (provider == null) { + // If we have no provider, try to get the current model (which will load the provider) + await this.getModel({ silent: true }); + provider = this._provider; + } + + const resetCurrent: MessageItem = { title: `Reset Current` }; + const resetAll: MessageItem = { title: 'Reset All' }; + const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + + let result; + if (all) { + result = resetAll; + } else if (provider == null) { + result = await window.showInformationMessage( + `Do you want to reset all of the stored AI keys?`, + { modal: true }, + resetAll, + cancel, + ); + } else { + result = await window.showInformationMessage( + `Do you want to reset the stored key for the current provider (${provider.name}) or reset all of the stored AI keys?`, + { modal: true }, + resetCurrent, + resetAll, + cancel, + ); + } + + if (provider != null && result === resetCurrent) { + void env.clipboard.writeText((await this.container.storage.getSecret(`gitlens.${provider.id}.key`)) ?? ''); + void this.container.storage.deleteSecret(`gitlens.${provider.id}.key`); + + void this.container.storage.delete(`confirm:ai:tos:${provider.id}`); + void this.container.storage.deleteWorkspace(`confirm:ai:tos:${provider.id}`); + } else if (result === resetAll) { + const keys = []; + for (const [providerId] of _supportedProviderTypes) { + keys.push(await this.container.storage.getSecret(`gitlens.${providerId}.key`)); + } + void env.clipboard.writeText(keys.join('\n')); + + for (const [providerId] of _supportedProviderTypes) { + void this.container.storage.deleteSecret(`gitlens.${providerId}.key`); + } + + void this.container.storage.deleteWithPrefix(`confirm:ai:tos`); + void this.container.storage.deleteWorkspaceWithPrefix(`confirm:ai:tos`); + } + } + + supports(provider: AIProviders | string) { + return _supportedProviderTypes.has(provider as AIProviders); + } + + async switchModel() { + void (await this.getModel({ force: true })); + } +} + +async function confirmAIProviderToS( + model: AIModel>, + storage: Storage, +): Promise { + const confirmed = + storage.get(`confirm:ai:tos:${model.provider.id}`, false) || + storage.getWorkspace(`confirm:ai:tos:${model.provider.id}`, false); + if (confirmed) return true; + + const accept: MessageItem = { title: 'Continue' }; + const acceptWorkspace: MessageItem = { title: 'Always for this Workspace' }; + const acceptAlways: MessageItem = { title: 'Always' }; + const decline: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showInformationMessage( + `GitLens experimental AI features require sending a diff of the code changes to ${model.provider.name} for analysis. This may contain sensitive information.\n\nDo you want to continue?`, + { modal: true }, + accept, + acceptWorkspace, + acceptAlways, + decline, + ); + + if (result === accept) return true; + + if (result === acceptWorkspace) { + void storage.storeWorkspace(`confirm:ai:tos:${model.provider.id}`, true); + return true; + } + + if (result === acceptAlways) { + void storage.store(`confirm:ai:tos:${model.provider.id}`, true); + return true; + } + + return false; +} + +export function getMaxCharacters(model: AIModel, outputLength: number): number { + const tokensPerCharacter = 3.1; + const max = model.maxTokens * tokensPerCharacter - outputLength / tokensPerCharacter; + return Math.floor(max - max * 0.1); +} + +export async function getApiKey( + storage: Storage, + provider: { id: AIProviders; name: string; validator: (value: string) => boolean; url: string }, +): Promise { + let apiKey = await storage.getSecret(`gitlens.${provider.id}.key`); + if (!apiKey) { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: `Open the ${provider.name} API Key Page`, + }; + + apiKey = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(value => { + if (value && !provider.validator(value)) { + input.validationMessage = `Please enter a valid ${provider.name} API key`; + return; + } + input.validationMessage = undefined; + }), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value || !provider.validator(value)) { + input.validationMessage = `Please enter a valid ${provider.name} API key`; + return; + } + + resolve(value); + }), + input.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal(Uri.parse(provider.url)); + } + }), + ); + + input.password = true; + input.title = `Connect to ${provider.name}`; + input.placeholder = `Please enter your ${provider.name} API key to use this feature`; + input.prompt = `Enter your [${provider.name} API Key](${provider.url} "Get your ${provider.name} API key")`; + input.buttons = [infoButton]; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!apiKey) return undefined; + + void storage.storeSecret(`gitlens.${provider.id}.key`, apiKey); + } + + return apiKey; +} + +export function extractDraftMessage( + message: string, + splitter = '\n\n', +): { title: string; description: string | undefined } { + const firstBreak = message.indexOf(splitter) ?? 0; + const title = firstBreak > -1 ? message.substring(0, firstBreak) : message; + const description = firstBreak > -1 ? message.substring(firstBreak + splitter.length) : undefined; + + return { + title: title, + description: description, + }; +} diff --git a/src/ai/anthropicProvider.ts b/src/ai/anthropicProvider.ts new file mode 100644 index 0000000000000..9fd311028251c --- /dev/null +++ b/src/ai/anthropicProvider.ts @@ -0,0 +1,585 @@ +import { fetch } from '@env/fetch'; +import type { CancellationToken } from 'vscode'; +import { window } from 'vscode'; +import type { TelemetryEvents } from '../constants.telemetry'; +import type { Container } from '../container'; +import { CancellationError } from '../errors'; +import { sum } from '../system/iterable'; +import { configuration } from '../system/vscode/configuration'; +import type { Storage } from '../system/vscode/storage'; +import type { AIModel, AIProvider } from './aiProviderService'; +import { getApiKey as getApiKeyCore, getMaxCharacters } from './aiProviderService'; +import { cloudPatchMessageSystemPrompt, codeSuggestMessageSystemPrompt, commitMessageSystemPrompt } from './prompts'; + +const provider = { id: 'anthropic', name: 'Anthropic' } as const; +type LegacyModels = Extract; +type SupportedModels = Exclude; +type LegacyModel = AIModel; +type SupportedModel = AIModel; + +function isLegacyModel(model: AnthropicModel): model is LegacyModel { + return model.id === 'claude-instant-1' || model.id === 'claude-2'; +} + +function isSupportedModel(model: AnthropicModel): model is SupportedModel { + return !isLegacyModel(model); +} + +export type AnthropicModels = + | 'claude-instant-1' + | 'claude-2' + | 'claude-2.1' + | 'claude-3-opus-20240229' + | 'claude-3-sonnet-20240229' + | 'claude-3-5-sonnet-20240620' + | 'claude-3-haiku-20240307'; + +type AnthropicModel = AIModel; + +const models: AnthropicModel[] = [ + { + id: 'claude-3-opus-20240229', + name: 'Claude 3 Opus', + maxTokens: 200000, + provider: provider, + }, + { + id: 'claude-3-5-sonnet-20240620', + name: 'Claude 3.5 Sonnet', + maxTokens: 200000, + provider: provider, + }, + { + id: 'claude-3-sonnet-20240229', + name: 'Claude 3 Sonnet', + maxTokens: 200000, + provider: provider, + }, + { + id: 'claude-3-haiku-20240307', + name: 'Claude 3 Haiku', + maxTokens: 200000, + provider: provider, + default: true, + }, + { id: 'claude-2.1', name: 'Claude 2.1', maxTokens: 200000, provider: provider }, + { id: 'claude-2', name: 'Claude 2.0', maxTokens: 100000, provider: provider }, + { + id: 'claude-instant-1', + name: 'Claude Instant', + maxTokens: 100000, + provider: provider, + }, +]; + +export class AnthropicProvider implements AIProvider { + readonly id = provider.id; + readonly name = provider.name; + + constructor(private readonly container: Container) {} + + dispose() {} + + getModels(): Promise[]> { + return Promise.resolve(models); + } + + async generateMessage( + model: AnthropicModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + promptConfig: { + systemPrompt: string; + customPrompt: string; + contextName: string; + }, + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; + + try { + let result: string; + let maxCodeCharacters: number; + + if (!isSupportedModel(model)) { + [result, maxCodeCharacters] = await this.makeLegacyRequest( + model as LegacyModel, + apiKey, + (max, retries) => { + const code = diff.substring(0, max); + let prompt = `\n\nHuman: ${promptConfig.systemPrompt}\n\nHuman: Here is the code diff to use to generate the ${promptConfig.contextName}:\n\n${code}\n`; + if (options?.context) { + prompt += `\nHuman: Here is additional context which should be taken into account when generating the ${promptConfig.contextName}:\n\n${options.context}\n`; + } + if (promptConfig.customPrompt) { + prompt += `\nHuman: ${promptConfig.customPrompt}\n`; + } + prompt += '\nAssistant:'; + + reporting['retry.count'] = retries; + reporting['input.length'] = (reporting['input.length'] ?? 0) + prompt.length; + + return prompt; + }, + 4096, + options?.cancellation, + ); + } else { + [result, maxCodeCharacters] = await this.makeRequest( + model, + apiKey, + promptConfig.systemPrompt, + (max, retries) => { + const code = diff.substring(0, max); + const messages: Message[] = [ + { + role: 'user', + content: [ + { + type: 'text', + text: `Here is the code diff to use to generate the ${promptConfig.contextName}:`, + }, + { + type: 'text', + text: code, + }, + ...(options?.context + ? ([ + { + type: 'text', + text: `Here is additional context which should be taken into account when generating the ${promptConfig.contextName}:`, + }, + { + type: 'text', + text: options.context, + }, + ] satisfies Message['content']) + : []), + ...(promptConfig.customPrompt + ? ([ + { + type: 'text', + text: promptConfig.customPrompt, + }, + ] satisfies Message['content']) + : []), + ], + }, + ]; + + reporting['retry.count'] = retries; + reporting['input.length'] = + (reporting['input.length'] ?? 0) + + sum(messages, m => sum(m.content, c => (c.type === 'text' ? c.text.length : 0))); + + return messages; + }, + 4096, + options?.cancellation, + ); + } + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the Anthropic's limits.`, + ); + } + + return result; + } catch (ex) { + throw new Error(`Unable to generate ${promptConfig.contextName}: ${ex.message}`); + } + } + + async generateDraftMessage( + model: AnthropicModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { cancellation?: CancellationToken; context?: string; codeSuggestion?: boolean }, + ): Promise { + let customPrompt = + options?.codeSuggestion === true + ? configuration.get('experimental.generateCodeSuggestionMessagePrompt') + : configuration.get('experimental.generateCloudPatchMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + reporting, + { + systemPrompt: + options?.codeSuggestion === true ? codeSuggestMessageSystemPrompt : cloudPatchMessageSystemPrompt, + customPrompt: customPrompt, + contextName: + options?.codeSuggestion === true + ? 'code suggestion title and description' + : 'cloud patch title and description', + }, + options, + ); + } + + async generateCommitMessage( + model: AnthropicModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { + let customPrompt = configuration.get('experimental.generateCommitMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + reporting, + { + systemPrompt: commitMessageSystemPrompt, + customPrompt: customPrompt, + contextName: 'commit message', + }, + options, + ); + } + + async explainChanges( + model: AnthropicModel, + message: string, + diff: string, + reporting: TelemetryEvents['ai/explain'], + options?: { cancellation?: CancellationToken }, + ): Promise { + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; + + const systemPrompt = `You are an advanced AI programming assistant tasked with summarizing code changes into an explanation that is both easy to understand and meaningful. Construct an explanation that: +- Concisely synthesizes meaningful information from the provided code diff +- Incorporates any additional context provided by the user to understand the rationale behind the code changes +- Places the emphasis on the 'why' of the change, clarifying its benefits or addressing the problem that necessitated the change, beyond just detailing the 'what' has changed + +Do not make any assumptions or invent details that are not supported by the code diff or the user-provided context.`; + + try { + let result: string; + let maxCodeCharacters: number; + + if (!isSupportedModel(model)) { + [result, maxCodeCharacters] = await this.makeLegacyRequest( + model as LegacyModel, + apiKey, + (max, retries) => { + const code = diff.substring(0, max); + const prompt = `\n\nHuman: ${systemPrompt} + +Human: Here is additional context provided by the author of the changes, which should provide some explanation to why these changes where made. Please strongly consider this information when generating your explanation: + +${message} + +Human: Now, kindly explain the following code diff in a way that would be clear to someone reviewing or trying to understand these changes: + +${code} + +Human: Remember to frame your explanation in a way that is suitable for a reviewer to quickly grasp the essence of the changes, the issues they resolve, and their implications on the codebase. And please don't explain how you arrived at the explanation, just provide the explanation. +Assistant:`; + reporting['retry.count'] = retries; + reporting['input.length'] = (reporting['input.length'] ?? 0) + prompt.length; + + return prompt; + }, + 4096, + options?.cancellation, + ); + } else { + [result, maxCodeCharacters] = await this.makeRequest( + model, + apiKey, + systemPrompt, + (max, retries) => { + const code = diff.substring(0, max); + const messages: Message[] = [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Here is additional context provided by the author of the changes, which should provide some explanation to why these changes where made. Please strongly consider this information when generating your explanation:', + }, + { + type: 'text', + text: message, + }, + { + type: 'text', + text: 'Now, kindly explain the following code diff in a way that would be clear to someone reviewing or trying to understand these changes:', + }, + { + type: 'text', + text: code, + }, + { + type: 'text', + text: `Remember to frame your explanation in a way that is suitable for a reviewer to quickly grasp the essence of the changes, the issues they resolve, and their implications on the codebase. And please don't explain how you arrived at the explanation, just provide the explanation`, + }, + ], + }, + ]; + + reporting['retry.count'] = retries; + reporting['input.length'] = + (reporting['input.length'] ?? 0) + + sum(messages, m => sum(m.content, c => (c.type === 'text' ? c.text.length : 0))); + + return messages; + }, + 4096, + options?.cancellation, + ); + } + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the Anthropic's limits.`, + ); + } + + return result; + } catch (ex) { + throw new Error(`Unable to explain changes: ${ex.message}`); + } + } + + private fetch( + model: SupportedModel, + apiKey: string, + request: AnthropicMessageRequest, + cancellation: CancellationToken | undefined, + ): ReturnType; + private fetch( + model: LegacyModel, + apiKey: string, + request: AnthropicCompletionRequest, + cancellation: CancellationToken | undefined, + ): ReturnType; + private async fetch( + model: AnthropicModel, + apiKey: string, + request: AnthropicMessageRequest | AnthropicCompletionRequest, + cancellation: CancellationToken | undefined, + ): ReturnType { + let aborter: AbortController | undefined; + if (cancellation != null) { + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter?.abort()); + } + + try { + return await fetch(getUrl(model), { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'X-API-Key': apiKey, + 'anthropic-version': '2023-06-01', + }, + method: 'POST', + body: JSON.stringify(request), + }); + } catch (ex) { + if (ex.name === 'AbortError') throw new CancellationError(ex); + throw ex; + } + } + + private async makeRequest( + model: SupportedModel, + apiKey: string, + system: string, + messages: (maxCodeCharacters: number, retries: number) => Message[], + maxTokens: number, + cancellation: CancellationToken | undefined, + ): Promise<[result: string, maxCodeCharacters: number]> { + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 2600); + + while (true) { + const request: AnthropicMessageRequest = { + model: model.id, + messages: messages(maxCodeCharacters, retries), + system: system, + stream: false, + max_tokens: maxTokens, + }; + + const rsp = await this.fetch(model, apiKey, request, cancellation); + if (!rsp.ok) { + let json; + try { + json = (await rsp.json()) as AnthropicError | undefined; + } catch {} + + debugger; + + if ( + retries++ < 2 && + json?.error?.type === 'invalid_request_error' && + json?.error?.message?.includes('prompt is too long') + ) { + maxCodeCharacters -= 500 * retries; + continue; + } + + throw new Error(`(${this.name}:${rsp.status}) ${json?.error?.message || rsp.statusText})`); + } + + const data: AnthropicMessageResponse = await rsp.json(); + const result = data.content + .map(c => c.text) + .join('\n') + .trim(); + return [result, maxCodeCharacters]; + } + } + + private async makeLegacyRequest( + model: LegacyModel, + apiKey: string, + prompt: (maxCodeCharacters: number, retries: number) => string, + maxTokens: number, + cancellation: CancellationToken | undefined, + ): Promise<[result: string, maxCodeCharacters: number]> { + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 2600); + + while (true) { + const request: AnthropicCompletionRequest = { + model: model.id, + prompt: prompt(maxCodeCharacters, retries), + stream: false, + max_tokens_to_sample: maxTokens, + }; + const rsp = await this.fetch(model, apiKey, request, cancellation); + if (!rsp.ok) { + let json; + try { + json = (await rsp.json()) as AnthropicError | undefined; + } catch {} + + debugger; + + if ( + retries++ < 2 && + json?.error?.type === 'invalid_request_error' && + json?.error?.message?.includes('prompt is too long') + ) { + maxCodeCharacters -= 500 * retries; + continue; + } + + throw new Error(`(${this.name}:${rsp.status}) ${json?.error?.message || rsp.statusText})`); + } + + const data: AnthropicCompletionResponse = await rsp.json(); + const result = data.completion.trim(); + return [result, maxCodeCharacters]; + } + } +} + +async function getApiKey(storage: Storage): Promise { + return getApiKeyCore(storage, { + id: provider.id, + name: provider.name, + validator: v => /(?:sk-)?[a-zA-Z0-9-_]{32,}/.test(v), + url: 'https://console.anthropic.com/account/keys', + }); +} + +function getUrl(model: AnthropicModel): string { + return isLegacyModel(model) ? 'https://api.anthropic.com/v1/complete' : 'https://api.anthropic.com/v1/messages'; +} + +interface AnthropicError { + type: 'error'; + error: { + type: + | 'invalid_request_error' + | 'authentication_error' + | 'permission_error' + | 'not_found_error' + | 'rate_limit_error' + | 'api_error' + | 'overloaded_error'; + message: string; + }; +} + +interface AnthropicCompletionRequest { + model: Extract; + prompt: string; + stream: boolean; + + max_tokens_to_sample: number; + stop_sequences?: string[]; + + temperature?: number; + top_k?: number; + top_p?: number; + tags?: Record; +} + +interface AnthropicCompletionResponse { + completion: string; + stop: string | null; + stop_reason: 'stop_sequence' | 'max_tokens'; + truncated: boolean; + exception: string | null; + log_id: string; +} + +interface Message { + role: 'user' | 'assistant'; + content: ( + | { type: 'text'; text: string } + | { + type: 'image'; + source: { + type: 'base64'; + media_type: `image/${'jpeg' | 'png' | 'gif' | 'webp'}`; + data: string; + }; + } + )[]; +} + +interface AnthropicMessageRequest { + model: SupportedModels; + messages: Message[]; + system?: string; + + max_tokens: number; + metadata?: object; + stop_sequences?: string[]; + stream?: boolean; + temperature?: number; + top_p?: number; + top_k?: number; +} + +interface AnthropicMessageResponse { + id: string; + type: 'message'; + role: 'assistant'; + content: { type: 'text'; text: string }[]; + model: string; + stop_reason: 'end_turn' | 'max_tokens' | 'stop_sequence'; + stop_sequence: string | null; + usage: { + input_tokens: number; + output_tokens: number; + }; +} diff --git a/src/ai/geminiProvider.ts b/src/ai/geminiProvider.ts new file mode 100644 index 0000000000000..214e4db810679 --- /dev/null +++ b/src/ai/geminiProvider.ts @@ -0,0 +1,369 @@ +import { fetch } from '@env/fetch'; +import type { CancellationToken } from 'vscode'; +import { window } from 'vscode'; +import type { TelemetryEvents } from '../constants.telemetry'; +import type { Container } from '../container'; +import { CancellationError } from '../errors'; +import { sum } from '../system/iterable'; +import { configuration } from '../system/vscode/configuration'; +import type { Storage } from '../system/vscode/storage'; +import type { AIModel, AIProvider } from './aiProviderService'; +import { getApiKey as getApiKeyCore, getMaxCharacters } from './aiProviderService'; +import { cloudPatchMessageSystemPrompt, codeSuggestMessageSystemPrompt, commitMessageSystemPrompt } from './prompts'; + +const provider = { id: 'gemini', name: 'Google' } as const; + +export type GeminiModels = 'gemini-1.0-pro' | 'gemini-1.5-pro-latest' | 'gemini-1.5-flash-latest'; +type GeminiModel = AIModel; +const models: GeminiModel[] = [ + { + id: 'gemini-1.5-pro-latest', + name: 'Gemini 1.5 Pro', + maxTokens: 1048576, + provider: provider, + default: true, + }, + { + id: 'gemini-1.5-flash-latest', + name: 'Gemini 1.5 Flash', + maxTokens: 1048576, + provider: provider, + }, + { + id: 'gemini-1.0-pro', + name: 'Gemini 1.0 Pro', + maxTokens: 30720, + provider: provider, + }, +]; + +export class GeminiProvider implements AIProvider { + readonly id = provider.id; + readonly name = provider.name; + + constructor(private readonly container: Container) {} + + dispose() {} + + getModels(): Promise[]> { + return Promise.resolve(models); + } + + async generateMessage( + model: GeminiModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + promptConfig: { + systemPrompt: string; + customPrompt: string; + contextName: string; + }, + options?: { + cancellation?: CancellationToken | undefined; + context?: string | undefined; + }, + ): Promise { + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; + + const retries = 0; + const maxCodeCharacters = getMaxCharacters(model, 2600); + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const request: GenerateContentRequest = { + systemInstruction: { + parts: [ + { + text: promptConfig.systemPrompt, + }, + ], + }, + contents: [ + { + role: 'user', + parts: [ + { + text: `Here is the code diff to use to generate the ${promptConfig.contextName}:\n\n${code}`, + }, + ...(options?.context + ? [ + { + text: `Here is additional context which should be taken into account when generating the ${promptConfig.contextName}:\n\n${options.context}`, + }, + ] + : []), + { + text: promptConfig.customPrompt, + }, + ], + }, + ], + }; + + reporting['retry.count'] = retries; + reporting['input.length'] = + (reporting['input.length'] ?? 0) + + sum(request.systemInstruction?.parts, p => p.text.length) + + sum(request.contents, c => sum(c.parts, p => p.text.length)); + + const rsp = await this.fetch(model.id, apiKey, request, options?.cancellation); + if (!rsp.ok) { + let json; + try { + json = (await rsp.json()) as { error?: { code: string; message: string } } | undefined; + } catch {} + + debugger; + + // if (retries++ < 2 && json?.error?.code === 'context_length_exceeded') { + // maxCodeCharacters -= 500 * retries; + // continue; + // } + + throw new Error( + `Unable to generate ${promptConfig.contextName}: (${this.name}:${rsp.status}) ${ + json?.error?.message || rsp.statusText + }`, + ); + } + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the Gemini's limits.`, + ); + } + + const data: GenerateContentResponse = await rsp.json(); + const message = data.candidates[0].content.parts[0].text.trim(); + return message; + } + } + + async generateDraftMessage( + model: GeminiModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { + cancellation?: CancellationToken | undefined; + context?: string | undefined; + codeSuggestion?: boolean | undefined; + }, + ): Promise { + let customPrompt = + options?.codeSuggestion === true + ? configuration.get('experimental.generateCodeSuggestionMessagePrompt') + : configuration.get('experimental.generateCloudPatchMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + reporting, + { + systemPrompt: + options?.codeSuggestion === true ? codeSuggestMessageSystemPrompt : cloudPatchMessageSystemPrompt, + customPrompt: customPrompt, + contextName: + options?.codeSuggestion === true + ? 'code suggestion title and description' + : 'cloud patch title and description', + }, + options, + ); + } + + async generateCommitMessage( + model: GeminiModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { + let customPrompt = configuration.get('experimental.generateCommitMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + reporting, + { + systemPrompt: commitMessageSystemPrompt, + customPrompt: customPrompt, + contextName: 'commit message', + }, + options, + ); + } + + async explainChanges( + model: GeminiModel, + message: string, + diff: string, + reporting: TelemetryEvents['ai/explain'], + options?: { cancellation?: CancellationToken }, + ): Promise { + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; + + const retries = 0; + const maxCodeCharacters = getMaxCharacters(model, 3000); + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const request: GenerateContentRequest = { + systemInstruction: { + parts: [ + { + text: `You are an advanced AI programming assistant tasked with summarizing code changes into an explanation that is both easy to understand and meaningful. Construct an explanation that: +- Concisely synthesizes meaningful information from the provided code diff +- Incorporates any additional context provided by the user to understand the rationale behind the code changes +- Places the emphasis on the 'why' of the change, clarifying its benefits or addressing the problem that necessitated the change, beyond just detailing the 'what' has changed + +Do not make any assumptions or invent details that are not supported by the code diff or the user-provided context.`, + }, + ], + }, + contents: [ + { + role: 'user', + parts: [ + { + text: `Here is additional context provided by the author of the changes, which should provide some explanation to why these changes where made. Please strongly consider this information when generating your explanation:\n\n${message}`, + }, + { + text: `Now, kindly explain the following code diff in a way that would be clear to someone reviewing or trying to understand these changes:\n\n${code}`, + }, + { + text: `Remember to frame your explanation in a way that is suitable for a reviewer to quickly grasp the essence of the changes, the issues they resolve, and their implications on the codebase.`, + }, + ], + }, + ], + }; + + reporting['retry.count'] = retries; + reporting['input.length'] = + (reporting['input.length'] ?? 0) + + sum(request.systemInstruction?.parts, p => p.text.length) + + sum(request.contents, c => sum(c.parts, p => p.text.length)); + + const rsp = await this.fetch(model.id, apiKey, request, options?.cancellation); + if (!rsp.ok) { + let json; + try { + json = (await rsp.json()) as { error?: { code: string; message: string } } | undefined; + } catch {} + + debugger; + + // if (retries++ < 2 && json?.error?.code === 'context_length_exceeded') { + // maxCodeCharacters -= 500 * retries; + // continue; + // } + + throw new Error( + `Unable to explain changes: (${this.name}:${rsp.status}) ${json?.error?.message || rsp.statusText}`, + ); + } + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the Gemini's limits.`, + ); + } + + const data: GenerateContentResponse = await rsp.json(); + const summary = data.candidates[0].content.parts[0].text.trim(); + return summary; + } + } + + private async fetch( + model: GeminiModels, + apiKey: string, + request: GenerateContentRequest, + cancellation: CancellationToken | undefined, + ) { + let aborter: AbortController | undefined; + if (cancellation != null) { + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter?.abort()); + } + + try { + return await fetch(getUrl(model), { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'x-goog-api-key': apiKey, + }, + method: 'POST', + body: JSON.stringify(request), + signal: aborter?.signal, + }); + } catch (ex) { + if (ex.name === 'AbortError') throw new CancellationError(ex); + + throw ex; + } + } +} + +async function getApiKey(storage: Storage): Promise { + return getApiKeyCore(storage, { + id: provider.id, + name: provider.name, + validator: () => true, + url: 'https://aistudio.google.com/app/apikey', + }); +} + +function getUrl(model: GeminiModels): string { + return `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; +} + +interface Content { + parts: Part[]; + role?: 'model' | 'user'; +} + +type Part = TextPart; +interface TextPart { + text: string; +} + +interface GenerationConfig { + stopSequences?: string[]; + candidateCount?: number; + maxOutputTokens?: number; + temperature?: number; + topP?: number; + topK?: number; +} + +interface GenerateContentRequest { + contents: Content[]; + systemInstruction?: Content; + generationConfig?: GenerationConfig; +} + +interface Candidate { + content: Content; + finishReason?: 'FINISH_REASON_UNSPECIFIED' | 'STOP' | 'MAX_TOKENS' | 'SAFETY' | 'RECITATION' | 'OTHER'; + safetyRatings: any[]; + citationMetadata: any; + tokenCount: number; + index: number; +} + +interface GenerateContentResponse { + candidates: Candidate[]; + promptFeedback: { + blockReason: 'BLOCK_REASON_UNSPECIFIED' | 'SAFETY' | 'OTHER'; + safetyRatings: any[]; + }; +} diff --git a/src/ai/openaiProvider.ts b/src/ai/openaiProvider.ts new file mode 100644 index 0000000000000..10a6001abdfff --- /dev/null +++ b/src/ai/openaiProvider.ts @@ -0,0 +1,466 @@ +import { fetch } from '@env/fetch'; +import type { CancellationToken } from 'vscode'; +import { window } from 'vscode'; +import type { TelemetryEvents } from '../constants.telemetry'; +import type { Container } from '../container'; +import { CancellationError } from '../errors'; +import { sum } from '../system/iterable'; +import { configuration } from '../system/vscode/configuration'; +import type { Storage } from '../system/vscode/storage'; +import type { AIModel, AIProvider } from './aiProviderService'; +import { getApiKey as getApiKeyCore, getMaxCharacters } from './aiProviderService'; +import { cloudPatchMessageSystemPrompt, codeSuggestMessageSystemPrompt, commitMessageSystemPrompt } from './prompts'; + +const provider = { id: 'openai', name: 'OpenAI' } as const; + +export type OpenAIModels = + | 'gpt-4o' + | 'gpt-4o-mini' + | 'gpt-4-turbo' + | 'gpt-4-turbo-2024-04-09' + | 'gpt-4-turbo-preview' + | 'gpt-4-0125-preview' + | 'gpt-4-1106-preview' + | 'gpt-4' + | 'gpt-4-0613' + | 'gpt-4-32k' + | 'gpt-4-32k-0613' + | 'gpt-3.5-turbo' + | 'gpt-3.5-turbo-0125' + | 'gpt-3.5-turbo-1106' + | 'gpt-3.5-turbo-16k'; + +type OpenAIModel = AIModel; +const models: OpenAIModel[] = [ + { + id: 'gpt-4o', + name: 'GPT-4 Omni', + maxTokens: 128000, + provider: provider, + default: true, + }, + { + id: 'gpt-4o-mini', + name: 'GPT-4 Omni Mini', + maxTokens: 128000, + provider: provider, + }, + { + id: 'gpt-4-turbo', + name: 'GPT-4 Turbo with Vision', + maxTokens: 128000, + provider: provider, + }, + { + id: 'gpt-4-turbo-2024-04-09', + name: 'GPT-4 Turbo Preview (2024-04-09)', + maxTokens: 128000, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-turbo-preview', + name: 'GPT-4 Turbo Preview', + maxTokens: 128000, + provider: provider, + }, + { + id: 'gpt-4-0125-preview', + name: 'GPT-4 0125 Preview', + maxTokens: 128000, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-1106-preview', + name: 'GPT-4 1106 Preview', + maxTokens: 128000, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4', + name: 'GPT-4', + maxTokens: 8192, + provider: provider, + }, + { + id: 'gpt-4-0613', + name: 'GPT-4 0613', + maxTokens: 8192, + provider: provider, + hidden: true, + }, + { + id: 'gpt-4-32k', + name: 'GPT-4 32k', + maxTokens: 32768, + provider: provider, + }, + { + id: 'gpt-4-32k-0613', + name: 'GPT-4 32k 0613', + maxTokens: 32768, + provider: provider, + hidden: true, + }, + { + id: 'gpt-3.5-turbo', + name: 'GPT-3.5 Turbo', + maxTokens: 16385, + provider: provider, + }, + { + id: 'gpt-3.5-turbo-0125', + name: 'GPT-3.5 Turbo 0125', + maxTokens: 16385, + provider: provider, + hidden: true, + }, + { + id: 'gpt-3.5-turbo-1106', + name: 'GPT-3.5 Turbo 1106', + maxTokens: 16385, + provider: provider, + hidden: true, + }, + { + id: 'gpt-3.5-turbo-16k', + name: 'GPT-3.5 Turbo 16k', + maxTokens: 16385, + provider: provider, + hidden: true, + }, +]; + +export class OpenAIProvider implements AIProvider { + readonly id = provider.id; + readonly name = provider.name; + + constructor(private readonly container: Container) {} + + dispose() {} + + getModels(): Promise[]> { + return Promise.resolve(models); + } + + private get url(): string { + return configuration.get('ai.experimental.openai.url') || 'https://api.openai.com/v1/chat/completions'; + } + + async generateMessage( + model: OpenAIModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + promptConfig: { + systemPrompt: string; + customPrompt: string; + contextName: string; + }, + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; + + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 2600); + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const request: OpenAIChatCompletionRequest = { + model: model.id, + messages: [ + { + role: 'system', + content: promptConfig.systemPrompt, + }, + { + role: 'user', + content: `Here is the code diff to use to generate the ${promptConfig.contextName}:\n\n${code}`, + }, + ...(options?.context + ? [ + { + role: 'user' as const, + content: `Here is additional context which should be taken into account when generating the ${promptConfig.contextName}:\n\n${options.context}`, + }, + ] + : []), + { + role: 'user', + content: promptConfig.customPrompt, + }, + ], + }; + + reporting['retry.count'] = retries; + reporting['input.length'] = (reporting['input.length'] ?? 0) + sum(request.messages, m => m.content.length); + + const rsp = await this.fetch(apiKey, request, options?.cancellation); + if (!rsp.ok) { + if (rsp.status === 404) { + throw new Error( + `Unable to generate ${promptConfig.contextName}: Your API key doesn't seem to have access to the selected '${model.id}' model`, + ); + } + if (rsp.status === 429) { + throw new Error( + `Unable to generate ${promptConfig.contextName}: (${this.name}:${rsp.status}) Too many requests (rate limit exceeded) or your API key is associated with an expired trial`, + ); + } + + let json; + try { + json = (await rsp.json()) as { error?: { code: string; message: string } } | undefined; + } catch {} + + debugger; + + if (retries++ < 2 && json?.error?.code === 'context_length_exceeded') { + maxCodeCharacters -= 500 * retries; + continue; + } + + throw new Error( + `Unable to generate ${promptConfig.contextName}: (${this.name}:${rsp.status}) ${ + json?.error?.message || rsp.statusText + }`, + ); + } + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the OpenAI's limits.`, + ); + } + + const data: OpenAIChatCompletionResponse = await rsp.json(); + const message = data.choices[0].message.content.trim(); + return message; + } + } + + async generateDraftMessage( + model: OpenAIModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { + cancellation?: CancellationToken; + context?: string; + codeSuggestion?: boolean | undefined; + }, + ): Promise { + let customPrompt = + options?.codeSuggestion === true + ? configuration.get('experimental.generateCodeSuggestionMessagePrompt') + : configuration.get('experimental.generateCloudPatchMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + reporting, + { + systemPrompt: + options?.codeSuggestion === true ? codeSuggestMessageSystemPrompt : cloudPatchMessageSystemPrompt, + customPrompt: customPrompt, + contextName: + options?.codeSuggestion === true + ? 'code suggestion title and description' + : 'cloud patch title and description', + }, + options, + ); + } + + async generateCommitMessage( + model: OpenAIModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { + let customPrompt = configuration.get('experimental.generateCommitMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + reporting, + { + systemPrompt: commitMessageSystemPrompt, + customPrompt: customPrompt, + contextName: 'commit message', + }, + options, + ); + } + + async explainChanges( + model: OpenAIModel, + message: string, + diff: string, + reporting: TelemetryEvents['ai/explain'], + options?: { cancellation?: CancellationToken }, + ): Promise { + const apiKey = await getApiKey(this.container.storage); + if (apiKey == null) return undefined; + + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 3000); + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const request: OpenAIChatCompletionRequest = { + model: model.id, + messages: [ + { + role: 'system', + content: `You are an advanced AI programming assistant tasked with summarizing code changes into an explanation that is both easy to understand and meaningful. Construct an explanation that: +- Concisely synthesizes meaningful information from the provided code diff +- Incorporates any additional context provided by the user to understand the rationale behind the code changes +- Places the emphasis on the 'why' of the change, clarifying its benefits or addressing the problem that necessitated the change, beyond just detailing the 'what' has changed + +Do not make any assumptions or invent details that are not supported by the code diff or the user-provided context.`, + }, + { + role: 'user', + content: `Here is additional context provided by the author of the changes, which should provide some explanation to why these changes where made. Please strongly consider this information when generating your explanation:\n\n${message}`, + }, + { + role: 'user', + content: `Now, kindly explain the following code diff in a way that would be clear to someone reviewing or trying to understand these changes:\n\n${code}`, + }, + { + role: 'user', + content: + 'Remember to frame your explanation in a way that is suitable for a reviewer to quickly grasp the essence of the changes, the issues they resolve, and their implications on the codebase.', + }, + ], + }; + + reporting['retry.count'] = retries; + reporting['input.length'] = (reporting['input.length'] ?? 0) + sum(request.messages, m => m.content.length); + + const rsp = await this.fetch(apiKey, request, options?.cancellation); + if (!rsp.ok) { + if (rsp.status === 404) { + throw new Error( + `Unable to explain changes: Your API key doesn't seem to have access to the selected '${model.id}' model`, + ); + } + if (rsp.status === 429) { + throw new Error( + `Unable to explain changes: (${this.name}:${rsp.status}) Too many requests (rate limit exceeded) or your API key is associated with an expired trial`, + ); + } + + let json; + try { + json = (await rsp.json()) as { error?: { code: string; message: string } } | undefined; + } catch {} + + debugger; + + if (retries++ < 2 && json?.error?.code === 'context_length_exceeded') { + maxCodeCharacters -= 500 * retries; + continue; + } + + throw new Error( + `Unable to explain changes: (${this.name}:${rsp.status}) ${json?.error?.message || rsp.statusText}`, + ); + } + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within the OpenAI's limits.`, + ); + } + + const data: OpenAIChatCompletionResponse = await rsp.json(); + const summary = data.choices[0].message.content.trim(); + return summary; + } + } + + private async fetch( + apiKey: string, + request: OpenAIChatCompletionRequest, + cancellation: CancellationToken | undefined, + ) { + const url = this.url; + const isAzure = url.includes('.azure.com'); + + let aborter: AbortController | undefined; + if (cancellation != null) { + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter?.abort()); + } + + try { + return await fetch(url, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(isAzure ? { 'api-key': apiKey } : { Authorization: `Bearer ${apiKey}` }), + }, + method: 'POST', + body: JSON.stringify(request), + signal: aborter?.signal, + }); + } catch (ex) { + if (ex.name === 'AbortError') throw new CancellationError(ex); + + throw ex; + } + } +} + +async function getApiKey(storage: Storage): Promise { + return getApiKeyCore(storage, { + id: provider.id, + name: provider.name, + validator: v => /(?:sk-)?[a-zA-Z0-9]{32,}/.test(v), + url: 'https://platform.openai.com/account/api-keys', + }); +} + +interface OpenAIChatCompletionRequest { + model: OpenAIModels; + messages: { role: 'system' | 'user' | 'assistant'; content: string }[]; + temperature?: number; + top_p?: number; + n?: number; + stream?: boolean; + stop?: string | string[]; + max_tokens?: number; + presence_penalty?: number; + frequency_penalty?: number; + logit_bias?: Record; + user?: string; +} + +interface OpenAIChatCompletionResponse { + id: string; + object: 'chat.completion'; + created: number; + model: string; + choices: { + index: number; + message: { + role: 'system' | 'user' | 'assistant'; + content: string; + }; + finish_reason: string; + }[]; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts new file mode 100644 index 0000000000000..d04d582f39f38 --- /dev/null +++ b/src/ai/prompts.ts @@ -0,0 +1,29 @@ +export const commitMessageSystemPrompt = `You are an advanced AI programming assistant tasked with summarizing code changes into a concise and meaningful commit message. Compose a commit message that: +- Strictly synthesizes meaningful information from the provided code diff +- Utilizes any additional user-provided context to comprehend the rationale behind the code changes +- Is clear and brief, with an informal yet professional tone, and without superfluous descriptions +- Avoids unnecessary phrases such as "this commit", "this change", and the like +- Avoids direct mention of specific code identifiers, names, or file names, unless they are crucial for understanding the purpose of the changes +- Most importantly emphasizes the 'why' of the change, its benefits, or the problem it addresses rather than only the 'what' that changed + +Follow the user's instructions carefully, don't repeat yourself, don't include the code in the output, or make anything up!`; + +export const cloudPatchMessageSystemPrompt = `You are an advanced AI programming assistant tasked with summarizing code changes into a concise and meaningful title with an optional description. Compose a title with an optional description that: +- Strictly synthesizes meaningful information from the provided code diff +- Utilizes any additional user-provided context to comprehend the rationale behind the code changes +- Is clear and brief, with an informal yet professional tone, and without superfluous descriptions +- Avoids unnecessary phrases such as "this commit", "this change", and the like +- Avoids direct mention of specific code identifiers, names, or file names, unless they are crucial for understanding the purpose of the changes +- Most importantly emphasizes the 'why' of the change, its benefits, or the problem it addresses rather than only the 'what' that changed + +Follow the user's instructions carefully, don't repeat yourself, don't include the code in the output, or make anything up!`; + +export const codeSuggestMessageSystemPrompt = `You are an advanced AI programming assistant tasked with summarizing code changes into a concise and meaningful code review title with an optional description. Compose a code review title with an optional description that: +- Strictly synthesizes meaningful information from the provided code diff +- Utilizes any additional user-provided context to comprehend the rationale behind the code changes +- Is clear and brief, with an informal yet professional tone, and without superfluous descriptions +- Avoids unnecessary phrases such as "this commit", "this change", and the like +- Avoids direct mention of specific code identifiers, names, or file names, unless they are crucial for understanding the purpose of the changes +- Most importantly emphasizes the 'why' of the change, its benefits, or the problem it addresses rather than only the 'what' that changed + +Follow the user's instructions carefully, don't repeat yourself, don't include the code in the output, or make anything up!`; diff --git a/src/ai/vscodeProvider.ts b/src/ai/vscodeProvider.ts new file mode 100644 index 0000000000000..6c4ee88872c51 --- /dev/null +++ b/src/ai/vscodeProvider.ts @@ -0,0 +1,304 @@ +import type { CancellationToken, LanguageModelChat, LanguageModelChatSelector } from 'vscode'; +import { CancellationTokenSource, LanguageModelChatMessage, lm, window } from 'vscode'; +import type { TelemetryEvents } from '../constants.telemetry'; +import type { Container } from '../container'; +import { sum } from '../system/iterable'; +import { capitalize } from '../system/string'; +import { configuration } from '../system/vscode/configuration'; +import type { AIModel, AIProvider } from './aiProviderService'; +import { getMaxCharacters } from './aiProviderService'; +import { cloudPatchMessageSystemPrompt, codeSuggestMessageSystemPrompt, commitMessageSystemPrompt } from './prompts'; + +const provider = { id: 'vscode', name: 'VS Code Provided' } as const; + +export type VSCodeAIModels = `${string}:${string}`; +type VSCodeAIModel = AIModel & { vendor: string; selector: LanguageModelChatSelector }; +export function isVSCodeAIModel(model: AIModel): model is AIModel { + return model.provider.id === provider.id; +} + +export class VSCodeAIProvider implements AIProvider { + readonly id = provider.id; + + private _name: string | undefined; + get name() { + return this._name ?? provider.name; + } + + constructor(private readonly container: Container) {} + + dispose() {} + + async getModels(): Promise[]> { + const models = await lm.selectChatModels(); + return models.map(getModelFromChatModel); + } + + private async getChatModel(model: VSCodeAIModel): Promise { + const models = await lm.selectChatModels(model.selector); + return models?.[0]; + } + + async generateMessage( + model: VSCodeAIModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + promptConfig: { + systemPrompt: string; + customPrompt: string; + contextName: string; + }, + options?: { cancellation?: CancellationToken; context?: string }, + ): Promise { + const chatModel = await this.getChatModel(model); + if (chatModel == null) return undefined; + + let cancellation; + let cancellationSource; + if (options?.cancellation == null) { + cancellationSource = new CancellationTokenSource(); + cancellation = cancellationSource.token; + } else { + cancellation = options.cancellation; + } + + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 2600) - 1000; // TODO: Use chatModel.countTokens + + try { + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const messages: LanguageModelChatMessage[] = [ + LanguageModelChatMessage.User(promptConfig.systemPrompt), + LanguageModelChatMessage.User( + `Here is the code diff to use to generate the ${promptConfig.contextName}:\n\n${code}`, + ), + ...(options?.context + ? [ + LanguageModelChatMessage.User( + `Here is additional context which should be taken into account when generating the ${promptConfig.contextName}:\n\n${options.context}`, + ), + ] + : []), + LanguageModelChatMessage.User(promptConfig.customPrompt), + ]; + + reporting['retry.count'] = retries; + reporting['input.length'] = (reporting['input.length'] ?? 0) + sum(messages, m => m.content.length); + + try { + const rsp = await chatModel.sendRequest(messages, {}, cancellation); + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within ${getPossessiveForm( + model.provider.name, + )} limits.`, + ); + } + + let message = ''; + for await (const fragment of rsp.text) { + message += fragment; + } + + return message.trim(); + } catch (ex) { + debugger; + + let message = ex instanceof Error ? ex.message : String(ex); + + if (ex instanceof Error && 'cause' in ex && ex.cause instanceof Error) { + message += `\n${ex.cause.message}`; + + if (retries++ < 2 && ex.cause.message.includes('exceeds token limit')) { + maxCodeCharacters -= 500 * retries; + continue; + } + } + + throw new Error( + `Unable to generate commit message: (${getPossessiveForm(model.provider.name)}:${ + ex.code + }) ${message}`, + ); + } + } + } finally { + cancellationSource?.dispose(); + } + } + + async generateDraftMessage( + model: VSCodeAIModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { + cancellation?: CancellationToken | undefined; + context?: string | undefined; + codeSuggestion?: boolean | undefined; + }, + ): Promise { + let customPrompt = + options?.codeSuggestion === true + ? configuration.get('experimental.generateCodeSuggestionMessagePrompt') + : configuration.get('experimental.generateCloudPatchMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + reporting, + { + systemPrompt: + options?.codeSuggestion === true ? codeSuggestMessageSystemPrompt : cloudPatchMessageSystemPrompt, + customPrompt: customPrompt, + contextName: + options?.codeSuggestion === true + ? 'code suggestion title and description' + : 'cloud patch title and description', + }, + options != null + ? { + cancellation: options.cancellation, + context: options.context, + } + : undefined, + ); + } + + async generateCommitMessage( + model: VSCodeAIModel, + diff: string, + reporting: TelemetryEvents['ai/generate'], + options?: { + cancellation?: CancellationToken | undefined; + context?: string | undefined; + }, + ): Promise { + let customPrompt = configuration.get('experimental.generateCommitMessagePrompt'); + if (!customPrompt.endsWith('.')) { + customPrompt += '.'; + } + + return this.generateMessage( + model, + diff, + reporting, + { + systemPrompt: commitMessageSystemPrompt, + customPrompt: customPrompt, + contextName: 'commit message', + }, + options, + ); + } + + async explainChanges( + model: VSCodeAIModel, + message: string, + diff: string, + reporting: TelemetryEvents['ai/explain'], + options?: { cancellation?: CancellationToken }, + ): Promise { + const chatModel = await this.getChatModel(model); + if (chatModel == null) return undefined; + + let cancellation; + let cancellationSource; + if (options?.cancellation == null) { + cancellationSource = new CancellationTokenSource(); + cancellation = cancellationSource.token; + } else { + cancellation = options.cancellation; + } + + let retries = 0; + let maxCodeCharacters = getMaxCharacters(model, 3000) - 1000; + + try { + while (true) { + const code = diff.substring(0, maxCodeCharacters); + + const messages: LanguageModelChatMessage[] = [ + LanguageModelChatMessage.User(`You are an advanced AI programming assistant tasked with summarizing code changes into an explanation that is both easy to understand and meaningful. Construct an explanation that: +- Concisely synthesizes meaningful information from the provided code diff +- Incorporates any additional context provided by the user to understand the rationale behind the code changes +- Places the emphasis on the 'why' of the change, clarifying its benefits or addressing the problem that necessitated the change, beyond just detailing the 'what' has changed + +Do not make any assumptions or invent details that are not supported by the code diff or the user-provided context.`), + LanguageModelChatMessage.User( + `Here is additional context provided by the author of the changes, which should provide some explanation to why these changes where made. Please strongly consider this information when generating your explanation:\n\n${message}`, + ), + LanguageModelChatMessage.User( + `Now, kindly explain the following code diff in a way that would be clear to someone reviewing or trying to understand these changes:\n\n${code}`, + ), + LanguageModelChatMessage.User( + 'Remember to frame your explanation in a way that is suitable for a reviewer to quickly grasp the essence of the changes, the issues they resolve, and their implications on the codebase.', + ), + ]; + + reporting['retry.count'] = retries; + reporting['input.length'] = (reporting['input.length'] ?? 0) + sum(messages, m => m.content.length); + + try { + const rsp = await chatModel.sendRequest(messages, {}, cancellation); + + if (diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the changes had to be truncated to ${maxCodeCharacters} characters to fit within ${getPossessiveForm( + model.provider.name, + )} limits.`, + ); + } + + let summary = ''; + for await (const fragment of rsp.text) { + summary += fragment; + } + + return summary.trim(); + } catch (ex) { + debugger; + let message = ex instanceof Error ? ex.message : String(ex); + + if (ex instanceof Error && 'cause' in ex && ex.cause instanceof Error) { + message += `\n${ex.cause.message}`; + + if (retries++ < 2 && ex.cause.message.includes('exceeds token limit')) { + maxCodeCharacters -= 500 * retries; + continue; + } + } + + throw new Error( + `Unable to explain changes: (${getPossessiveForm(model.provider.name)}:${ex.code}) ${message}`, + ); + } + } + } finally { + cancellationSource?.dispose(); + } + } +} + +function getModelFromChatModel(model: LanguageModelChat): VSCodeAIModel { + return { + id: `${model.vendor}:${model.family}`, + name: `${capitalize(model.vendor)} ${model.name}`, + vendor: model.vendor, + selector: { + vendor: model.vendor, + family: model.family, + }, + maxTokens: model.maxInputTokens, + provider: { id: provider.id, name: capitalize(model.vendor) }, + }; +} + +function getPossessiveForm(name: string) { + return name.endsWith('s') ? `${name}'` : `${name}'s`; +} diff --git a/src/annotations/annotationProvider.ts b/src/annotations/annotationProvider.ts index 2d5e74ef33103..458559db1a3ef 100644 --- a/src/annotations/annotationProvider.ts +++ b/src/annotations/annotationProvider.ts @@ -1,26 +1,22 @@ -import type { - DecorationOptions, - Range, - TextDocument, - TextEditor, - TextEditorDecorationType, - TextEditorSelectionChangeEvent, - Uri, -} from 'vscode'; +import type { TextEditor, TextEditorDecorationType, TextEditorSelectionChangeEvent } from 'vscode'; import { Disposable, window } from 'vscode'; -import type { FileAnnotationType } from '../configuration'; -import { ContextKeys } from '../constants'; -import { setContext } from '../context'; -import { Logger } from '../logger'; -import type { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; - -export const enum AnnotationStatus { - Computing = 'computing', - Computed = 'computed', -} +import type { FileAnnotationType } from '../config'; +import type { Container } from '../container'; +import { Logger } from '../system/logger'; +import type { Deferred } from '../system/promise'; +import { defer } from '../system/promise'; +import type { TrackedGitDocument } from '../trackers/trackedDocument'; +import type { Decoration } from './annotations'; + +export type AnnotationStatus = 'computing' | 'computed'; export interface AnnotationContext { - selection?: { sha?: string; line?: undefined } | { sha?: undefined; line?: number } | false; + selection?: { sha?: string; line?: never } | { sha?: never; line?: number } | false; +} + +export interface AnnotationState { + recompute?: boolean; + restoring?: boolean; } export type TextEditorCorrelationKey = string; @@ -28,26 +24,23 @@ export function getEditorCorrelationKey(editor: TextEditor | undefined): TextEdi return `${editor?.document.uri.toString()}|${editor?.viewColumn}`; } +export type DidChangeStatusCallback = (e: { editor?: TextEditor; status?: AnnotationStatus }) => void; + export abstract class AnnotationProviderBase implements Disposable { - annotationContext: TContext | undefined; - correlationKey: TextEditorCorrelationKey; - document: TextDocument; - status: AnnotationStatus | undefined; - - private decorations: - | { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] | DecorationOptions[] }[] - | undefined; + private decorations: Decoration[] | undefined; protected disposable: Disposable; + private _computing: Deferred | undefined; constructor( + protected readonly container: Container, + protected readonly onDidChangeStatus: DidChangeStatusCallback, public readonly annotationType: FileAnnotationType, - public editor: TextEditor, - protected readonly trackedDocument: TrackedDocument, + editor: TextEditor, + protected readonly trackedDocument: TrackedGitDocument, ) { - this.correlationKey = getEditorCorrelationKey(this.editor); - this.document = this.editor.document; + this.editor = editor; this.disposable = Disposable.from( window.onDidChangeTextEditorSelection(this.onTextEditorSelectionChanged, this), @@ -55,41 +48,109 @@ export abstract class AnnotationProviderBase { + this._computing = defer(); + this.setStatus('computing', this.editor); + + try { + this.annotationContext = context; + + if (await this.onProvideAnnotation(context, state)) { + this.setStatus('computed', this.editor); + await this.selection?.( + state?.restoring ? { line: this.editor.selection.active.line } : context?.selection, + ); + + this._computing.fulfill(); + return true; + } + } catch (ex) { + Logger.error(ex); + } + + this.setStatus(undefined, this.editor); + this._computing.fulfill(); return false; } + protected abstract onProvideAnnotation(context?: TContext, state?: AnnotationState): Promise; + refresh(replaceDecorationTypes: Map) { if (this.editor == null || !this.decorations?.length) return; @@ -109,19 +170,19 @@ export abstract class AnnotationProviderBase { - this.status = AnnotationStatus.Computing; - try { - if (await this.onProvideAnnotation(context)) { - this.status = AnnotationStatus.Computed; - return true; - } - } catch (ex) { - Logger.error(ex); - } - - this.status = undefined; - return false; - } + selection?(selection?: TContext['selection']): Promise; + validate?(): boolean | Promise; - protected abstract onProvideAnnotation(context?: TContext): Promise; + protected setDecorations(decorations: Decoration[]) { + if (this.decorations?.length) { + // If we have no new decorations, just completely clear the old ones + if (!decorations?.length) { + void this.clear(); - abstract selection(selection?: TContext['selection']): Promise; + return; + } - protected setDecorations( - decorations: { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] | DecorationOptions[] }[], - ) { - if (this.decorations?.length) { - this.clear(); + // Only remove the decorations that are no longer needed + const remove = this.decorations.filter( + decoration => !decorations.some(d => d.decorationType.key === decoration.decorationType.key), + ); + for (const d of remove) { + try { + this.editor.setDecorations(d.decorationType, []); + if (d.dispose) { + d.decorationType.dispose(); + } + } catch {} + } } this.decorations = decorations; - if (this.decorations?.length) { - for (const d of this.decorations) { + if (decorations?.length) { + for (const d of decorations) { this.editor.setDecorations(d.decorationType, d.rangesOrOptions); } } } - - abstract validate(): Promise; } diff --git a/src/annotations/annotations.ts b/src/annotations/annotations.ts index 7410c6bb08a69..b43ea1222193c 100644 --- a/src/annotations/annotations.ts +++ b/src/annotations/annotations.ts @@ -7,15 +7,16 @@ import type { ThemableDecorationRenderOptions, } from 'vscode'; import { OverviewRulerLane, ThemeColor, Uri, window } from 'vscode'; -import { HeatmapLocations } from '../config'; -import type { Config } from '../configuration'; -import { configuration } from '../configuration'; -import { Colors, GlyphChars } from '../constants'; +import type { Config } from '../config'; +import { GlyphChars } from '../constants'; +import type { Colors } from '../constants.colors'; import type { CommitFormatOptions } from '../git/formatters/commitFormatter'; import { CommitFormatter } from '../git/formatters/commitFormatter'; import type { GitCommit } from '../git/models/commit'; import { scale, toRgba } from '../system/color'; import { getWidth, interpolate, pad } from '../system/string'; +import { configuration } from '../system/vscode/configuration'; +import type { BlameFontOptions } from './gutterBlameAnnotationProvider'; export interface ComputedHeatmap { coldThresholdTimestamp: number; @@ -24,6 +25,12 @@ export interface ComputedHeatmap { computeOpacity(date: Date): number; } +export type Decoration = { + decorationType: TextEditorDecorationType; + rangesOrOptions: T; + dispose?: boolean; +}; + interface RenderOptions extends DecorationInstanceRenderOptions, ThemableDecorationRenderOptions, @@ -93,14 +100,14 @@ export function addOrUpdateGutterHeatmapDecoration( date: Date, heatmap: ComputedHeatmap, range: Range, - map: Map, + map: Map>, ) { const [r, g, b, a] = getHeatmapColor(date, heatmap); const { fadeLines, locations } = configuration.get('heatmap'); - const gutter = locations.includes(HeatmapLocations.Gutter); - const line = locations.includes(HeatmapLocations.Line); - const scrollbar = locations.includes(HeatmapLocations.Scrollbar); + const gutter = locations.includes('gutter'); + const line = locations.includes('line'); + const scrollbar = locations.includes('overview'); const key = `${r},${g},${b},${a}`; let colorDecoration = map.get(key); @@ -113,7 +120,7 @@ export function addOrUpdateGutterHeatmapDecoration( gutterIconPath: gutter ? Uri.parse( `data:image/svg+xml,${encodeURIComponent( - ``, + ``, )}`, ) : undefined, @@ -122,6 +129,7 @@ export function addOrUpdateGutterHeatmapDecoration( overviewRulerColor: scrollbar ? `rgba(${r},${g},${b},${a * 0.7})` : undefined, }), rangesOrOptions: [range], + dispose: true, }; map.set(key, colorDecoration); } else { @@ -159,6 +167,7 @@ export function getGutterRenderOptions( avatars: boolean, format: string, options: CommitFormatOptions, + fontOptions: BlameFontOptions, ): RenderOptions { // Get the character count of all the tokens, assuming there there is a cap (bail if not) let chars = 0; @@ -192,7 +201,7 @@ export function getGutterRenderOptions( let width; if (chars >= 0) { - const spacing = configuration.getAny('editor.letterSpacing'); + const spacing = configuration.getCore('editor.letterSpacing'); if (spacing != null && spacing !== 0) { width = `calc(${chars}ch + ${Math.round(chars * spacing) + (avatars ? 13 : -6)}px)`; } else { @@ -201,19 +210,21 @@ export function getGutterRenderOptions( } return { - backgroundColor: new ThemeColor(Colors.GutterBackgroundColor), + backgroundColor: new ThemeColor('gitlens.gutterBackgroundColor' satisfies Colors), borderStyle: borderStyle, borderWidth: borderWidth, - color: new ThemeColor(Colors.GutterForegroundColor), - fontWeight: 'normal', - fontStyle: 'normal', + color: new ThemeColor('gitlens.gutterForegroundColor' satisfies Colors), + fontWeight: fontOptions.weight ?? 'normal', + fontStyle: fontOptions.style ?? 'normal', height: '100%', margin: '0 26px -1px 0', textDecoration: `${separateLines ? 'overline solid rgba(0, 0, 0, .2)' : 'none'};box-sizing: border-box${ avatars ? ';padding: 0 0 0 18px' : '' - }`, + }${fontOptions.family ? `;font-family: ${fontOptions.family}` : ''}${ + fontOptions.size ? `;font-size: ${fontOptions.size}px` : '' + };`, width: width, - uncommittedColor: new ThemeColor(Colors.GutterUncommittedForegroundColor), + uncommittedColor: new ThemeColor('gitlens.gutterUncommittedForegroundColor' satisfies Colors), }; } @@ -223,6 +234,7 @@ export function getInlineDecoration( // editorLine: number, format: string, formatOptions?: CommitFormatOptions, + fontOptions?: BlameFontOptions, scrollable: boolean = true, ): Partial { // TODO: Enable this once there is better caching @@ -240,13 +252,15 @@ export function getInlineDecoration( return { renderOptions: { after: { - backgroundColor: new ThemeColor(Colors.TrailingLineBackgroundColor), - color: new ThemeColor(Colors.TrailingLineForegroundColor), + backgroundColor: new ThemeColor('gitlens.trailingLineBackgroundColor' satisfies Colors), + color: new ThemeColor('gitlens.trailingLineForegroundColor' satisfies Colors), contentText: pad(message.replace(/ /g, GlyphChars.Space), 1, 1), - fontWeight: 'normal', - fontStyle: 'normal', + fontWeight: fontOptions?.weight ?? 'normal', + fontStyle: fontOptions?.style ?? 'normal', // Pull the decoration out of the document flow if we want to be scrollable - textDecoration: `none;${scrollable ? '' : ' position: absolute;'}`, + textDecoration: `none${scrollable ? '' : ';position: absolute'}${ + fontOptions?.family ? `;font-family: ${fontOptions.family}` : '' + }${fontOptions?.size ? `;font-size: ${fontOptions.size}px` : ''};`, }, }, }; diff --git a/src/annotations/autolinks.ts b/src/annotations/autolinks.ts index cad8419310fe3..36c22ad41ebd2 100644 --- a/src/annotations/autolinks.ts +++ b/src/annotations/autolinks.ts @@ -1,27 +1,30 @@ import type { ConfigurationChangeEvent } from 'vscode'; import { Disposable } from 'vscode'; -import type { AutolinkReference, AutolinkType } from '../configuration'; -import { configuration } from '../configuration'; +import type { AutolinkReference, AutolinkType } from '../config'; import { GlyphChars } from '../constants'; import type { Container } from '../container'; -import { IssueOrPullRequest } from '../git/models/issue'; +import type { IssueOrPullRequest } from '../git/models/issue'; +import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from '../git/models/issue'; import type { GitRemote } from '../git/models/remote'; -import type { RemoteProviderReference } from '../git/models/remoteProvider'; -import { Logger } from '../logger'; +import type { ProviderReference } from '../git/models/remoteProvider'; +import type { ResourceDescriptor } from '../plus/integrations/integration'; +import type { IntegrationId } from '../plus/integrations/providers/models'; +import { IssueIntegrationId } from '../plus/integrations/providers/models'; import { fromNow } from '../system/date'; import { debug } from '../system/decorators/log'; import { encodeUrl } from '../system/encoding'; import { join, map } from '../system/iterable'; -import type { PromiseCancelledErrorWithId } from '../system/promise'; -import { PromiseCancelledError, raceAll } from '../system/promise'; -import { encodeHtmlWeak, escapeMarkdown, escapeRegex, getSuperscript } from '../system/string'; +import { Logger } from '../system/logger'; +import type { MaybePausedResult } from '../system/promise'; +import { capitalize, encodeHtmlWeak, escapeMarkdown, escapeRegex, getSuperscript } from '../system/string'; +import { configuration } from '../system/vscode/configuration'; const emptyAutolinkMap = Object.freeze(new Map()); const numRegex = //g; export interface Autolink { - provider?: RemoteProviderReference; + provider?: ProviderReference; id: string; prefix: string; title?: string; @@ -29,8 +32,30 @@ export interface Autolink { type?: AutolinkType; description?: string; + + descriptor?: ResourceDescriptor; + tokenize?: + | (( + text: string, + outputFormat: 'html' | 'markdown' | 'plaintext', + tokenMapping: Map, + enrichedAutolinks?: Map, + prs?: Set, + footnotes?: Map, + ) => string) + | null; } +export type EnrichedAutolink = [ + issueOrPullRequest: Promise | undefined, + autolink: Autolink, +]; + +export type MaybeEnrichedAutolink = readonly [ + issueOrPullRequest: MaybePausedResult | undefined, + autolink: Autolink, +]; + export function serializeAutolink(value: Autolink): Autolink { const serialized: Autolink = { provider: value.provider @@ -47,6 +72,7 @@ export function serializeAutolink(value: Autolink): Autolink { url: value.url, type: value.type, description: value.description, + descriptor: value.descriptor, }; return serialized; } @@ -57,7 +83,8 @@ export interface CacheableAutolinkReference extends AutolinkReference { text: string, outputFormat: 'html' | 'markdown' | 'plaintext', tokenMapping: Map, - issuesOrPullRequests?: Map, + enrichedAutolinks?: Map, + prs?: Set, footnotes?: Map, ) => string) | null; @@ -73,13 +100,16 @@ export interface DynamicAutolinkReference { text: string, outputFormat: 'html' | 'markdown' | 'plaintext', tokenMapping: Map, - issuesOrPullRequests?: Map, + enrichedAutolinks?: Map, + prs?: Set, footnotes?: Map, ) => string) | null; parse: (text: string, autolinks: Map) => void; } +export const supportedAutolinkIntegrations = [IssueIntegrationId.Jira]; + function isDynamic(ref: AutolinkReference | DynamicAutolinkReference): ref is DynamicAutolinkReference { return !('prefix' in ref) && !('url' in ref); } @@ -120,116 +150,177 @@ export class Autolinks implements Disposable { ignoreCase: a.ignoreCase, type: a.type, description: a.description, + descriptor: a.descriptor, })) ?? []; } } + async getAutolinks(message: string, remote?: GitRemote): Promise>; + async getAutolinks( + message: string, + remote: GitRemote, + // eslint-disable-next-line @typescript-eslint/unified-signatures + options?: { excludeCustom?: boolean }, + ): Promise>; @debug({ args: { 0: '', 1: false, }, }) - getAutolinks(message: string, remote?: GitRemote): Map { - const provider = remote?.provider; - // If a remote is provided but there is no provider return an empty set - if (remote != null && remote.provider == null) return emptyAutolinkMap; + async getAutolinks( + message: string, + remote?: GitRemote, + options?: { excludeCustom?: boolean }, + ): Promise> { + const refsets: [ + ProviderReference | undefined, + (AutolinkReference | DynamicAutolinkReference)[] | CacheableAutolinkReference[], + ][] = []; + // Connected integration autolinks + await Promise.allSettled( + supportedAutolinkIntegrations.map(async integrationId => { + const integration = await this.container.integrations.get(integrationId); + const autoLinks = await integration.autolinks(); + if (autoLinks.length) { + refsets.push([integration, autoLinks]); + } + }), + ); + + // Remote-specific autolinks and remote integration autolinks + if (remote?.provider != null) { + const autoLinks = []; + const integrationAutolinks = await (await remote.getIntegration())?.autolinks(); + if (integrationAutolinks?.length) { + autoLinks.push(...integrationAutolinks); + } + if (remote?.provider?.autolinks.length) { + autoLinks.push(...remote.provider.autolinks); + } + + if (autoLinks.length) { + refsets.push([remote.provider, autoLinks]); + } + } + + // Custom-configured autolinks + if (this._references.length && (remote?.provider == null || !options?.excludeCustom)) { + refsets.push([undefined, this._references]); + } + if (refsets.length === 0) return emptyAutolinkMap; const autolinks = new Map(); let match; let num; - for (const ref of provider?.autolinks ?? this._references) { - if (!isCacheable(ref)) { - if (isDynamic(ref)) { - ref.parse(message, autolinks); + for (const [provider, refs] of refsets) { + for (const ref of refs) { + if (!isCacheable(ref)) { + if (isDynamic(ref)) { + ref.parse(message, autolinks); + } + continue; } - continue; - } - ensureCachedRegex(ref, 'plaintext'); + ensureCachedRegex(ref, 'plaintext'); - do { - match = ref.messageRegex.exec(message); - if (match == null) break; + do { + match = ref.messageRegex.exec(message); + if (match == null) break; - [, , num] = match; + [, , , num] = match; - autolinks.set(num, { - provider: provider, - id: num, - prefix: ref.prefix, - url: ref.url?.replace(numRegex, num), - title: ref.title?.replace(numRegex, num), + autolinks.set(num, { + provider: provider, + id: num, + prefix: ref.prefix, + url: ref.url?.replace(numRegex, num), + title: ref.title?.replace(numRegex, num), - type: ref.type, - description: ref.description?.replace(numRegex, num), - }); - } while (true); + type: ref.type, + description: ref.description?.replace(numRegex, num), + descriptor: ref.descriptor, + }); + } while (true); + } } return autolinks; } - async getLinkedIssuesAndPullRequests( - message: string, - remote: GitRemote, - options?: { autolinks?: Map; timeout?: never }, - ): Promise | undefined>; - async getLinkedIssuesAndPullRequests( + getAutolinkEnrichableId(autolink: Autolink): string { + switch (autolink.provider?.id) { + case IssueIntegrationId.Jira: + return `${autolink.prefix}${autolink.id}`; + default: + return autolink.id; + } + } + + async getEnrichedAutolinks( message: string, - remote: GitRemote, - options: { autolinks?: Map; timeout: number }, - ): Promise< - | Map>> - | undefined - >; - @debug({ + remote: GitRemote | undefined, + ): Promise | undefined>; + async getEnrichedAutolinks( + autolinks: Map, + remote: GitRemote | undefined, + ): Promise | undefined>; + @debug({ args: { - 0: '', - 1: false, - 2: options => `autolinks=${options?.autolinks != null}, timeout=${options?.timeout}`, + 0: messageOrAutolinks => + typeof messageOrAutolinks === 'string' ? '' : `autolinks=${messageOrAutolinks.size}`, + 1: remote => remote?.remoteKey, }, }) - async getLinkedIssuesAndPullRequests( - message: string, - remote: GitRemote, - options?: { autolinks?: Map; timeout?: number }, - ) { - if (!remote.hasRichProvider()) return undefined; - - const { provider } = remote; - const connected = provider.maybeConnected ?? (await provider.isConnected()); - if (!connected) return undefined; - - let autolinks = options?.autolinks; - if (autolinks == null) { - autolinks = this.getAutolinks(message, remote); + async getEnrichedAutolinks( + messageOrAutolinks: string | Map, + remote: GitRemote | undefined, + ): Promise | undefined> { + if (typeof messageOrAutolinks === 'string') { + messageOrAutolinks = await this.getAutolinks(messageOrAutolinks, remote); } + if (messageOrAutolinks.size === 0) return undefined; - if (autolinks.size === 0) return undefined; - - const issuesOrPullRequests = await raceAll( - autolinks.keys(), - id => provider.getIssueOrPullRequest(id), - options?.timeout, - ); + let integration = await remote?.getIntegration(); + if (integration != null) { + const connected = integration.maybeConnected ?? (await integration.isConnected()); + if (!connected) { + integration = undefined; + } + } - // Remove any issues or pull requests that were not found - for (const [id, issueOrPullRequest] of issuesOrPullRequests) { - if (issueOrPullRequest == null) { - issuesOrPullRequests.delete(id); + const enrichedAutolinks = new Map(); + for (const [id, link] of messageOrAutolinks) { + let linkIntegration = link.provider + ? await this.container.integrations.get(link.provider.id as IntegrationId) + : undefined; + if (linkIntegration != null) { + const connected = linkIntegration.maybeConnected ?? (await linkIntegration.isConnected()); + if (!connected) { + linkIntegration = undefined; + } } + const issueOrPullRequestPromise = + remote?.provider != null && + integration != null && + link.provider?.id === integration.id && + link.provider?.domain === integration.domain + ? integration.getIssueOrPullRequest(link.descriptor ?? remote.provider.repoDesc, id) + : link.descriptor != null + ? linkIntegration?.getIssueOrPullRequest(link.descriptor, this.getAutolinkEnrichableId(link)) + : undefined; + enrichedAutolinks.set(id, [issueOrPullRequestPromise, link]); } - return issuesOrPullRequests.size !== 0 ? issuesOrPullRequests : undefined; + return enrichedAutolinks; } @debug({ args: { 0: '', 2: remotes => remotes?.length, - 3: issuesOrPullRequests => issuesOrPullRequests?.size, + 3: issuesAndPullRequests => issuesAndPullRequests?.size, 4: footnotes => footnotes?.size, }, }) @@ -237,7 +328,8 @@ export class Autolinks implements Disposable { text: string, outputFormat: 'html' | 'markdown' | 'plaintext', remotes?: GitRemote[], - issuesOrPullRequests?: Map, + enrichedAutolinks?: Map, + prs?: Set, footnotes?: Map, ): string { const includeFootnotesInText = outputFormat === 'plaintext' && footnotes == null; @@ -247,22 +339,44 @@ export class Autolinks implements Disposable { const tokenMapping = new Map(); - for (const ref of this._references) { - if (this.ensureAutolinkCached(ref)) { - if (ref.tokenize != null) { - text = ref.tokenize(text, outputFormat, tokenMapping, issuesOrPullRequests, footnotes); + if (enrichedAutolinks?.size) { + for (const [, [, link]] of enrichedAutolinks) { + if (this.ensureAutolinkCached(link)) { + if (link.tokenize != null) { + text = link.tokenize(text, outputFormat, tokenMapping, enrichedAutolinks, prs, footnotes); + } + } + } + } else { + for (const ref of this._references) { + if (this.ensureAutolinkCached(ref)) { + if (ref.tokenize != null) { + text = ref.tokenize(text, outputFormat, tokenMapping, enrichedAutolinks, prs, footnotes); + } } } - } - - if (remotes != null && remotes.length !== 0) { - for (const r of remotes) { - if (r.provider == null) continue; - for (const ref of r.provider.autolinks) { - if (this.ensureAutolinkCached(ref)) { - if (ref.tokenize != null) { - text = ref.tokenize(text, outputFormat, tokenMapping, issuesOrPullRequests, footnotes); + if (remotes != null && remotes.length !== 0) { + remotes = [...remotes].sort((a, b) => { + const aConnected = a.maybeIntegrationConnected; + const bConnected = b.maybeIntegrationConnected; + return aConnected !== bConnected ? (aConnected ? -1 : bConnected ? 1 : 0) : 0; + }); + for (const r of remotes) { + if (r.provider == null) continue; + + for (const ref of r.provider.autolinks) { + if (this.ensureAutolinkCached(ref)) { + if (ref.tokenize != null) { + text = ref.tokenize( + text, + outputFormat, + tokenMapping, + enrichedAutolinks, + prs, + footnotes, + ); + } } } } @@ -285,8 +399,8 @@ export class Autolinks implements Disposable { } private ensureAutolinkCached( - ref: CacheableAutolinkReference | DynamicAutolinkReference, - ): ref is CacheableAutolinkReference | DynamicAutolinkReference { + ref: CacheableAutolinkReference | DynamicAutolinkReference | Autolink, + ): ref is CacheableAutolinkReference | DynamicAutolinkReference | Autolink { if (isDynamic(ref)) return true; if (!ref.prefix || !ref.url) return false; if (ref.tokenize !== undefined || ref.tokenize === null) return true; @@ -296,7 +410,8 @@ export class Autolinks implements Disposable { text: string, outputFormat: 'html' | 'markdown' | 'plaintext', tokenMapping: Map, - issuesOrPullRequests?: Map, + enrichedAutolinks?: Map, + prs?: Set, footnotes?: Map, ) => { let footnoteIndex: number; @@ -304,114 +419,177 @@ export class Autolinks implements Disposable { switch (outputFormat) { case 'markdown': ensureCachedRegex(ref, outputFormat); - return text.replace(ref.messageMarkdownRegex, (_: string, linkText: string, num: string) => { - const url = encodeUrl(ref.url.replace(numRegex, num)); - - let title = ''; - if (ref.title) { - title = ` "${ref.title.replace(numRegex, num)}`; - - const issue = issuesOrPullRequests?.get(num); - if (issue != null) { - if (issue instanceof PromiseCancelledError) { - title += `\n${GlyphChars.Dash.repeat(2)}\nDetails timed out`; - } else { - const issueTitle = escapeMarkdown(issue.title.trim()); - - if (footnotes != null) { - footnoteIndex = footnotes.size + 1; - footnotes.set( - footnoteIndex, - `[${IssueOrPullRequest.getMarkdownIcon( - issue, - )} **${issueTitle}**](${url}${title}")\\\n${GlyphChars.Space.repeat( - 5, - )}${linkText} ${issue.closed ? 'closed' : 'opened'} ${fromNow( - issue.closedDate ?? issue.date, - )}`, - ); + return text.replace( + ref.messageMarkdownRegex, + (_: string, prefix: string, linkText: string, num: string) => { + const url = encodeUrl(ref.url.replace(numRegex, num)); + + let title = ''; + if (ref.title) { + title = ` "${ref.title.replace(numRegex, num)}`; + + const issueResult = enrichedAutolinks?.get(num)?.[0]; + if (issueResult?.value != null) { + if (issueResult.paused) { + if (footnotes != null && !prs?.has(num)) { + let name = ref.description?.replace(numRegex, num); + if (name == null) { + name = `Custom Autolink ${ref.prefix}${num}`; + } + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} ${name} $(loading~spin)](${url}${title}")`, + ); + } + + title += `\n${GlyphChars.Dash.repeat(2)}\nLoading...`; + } else { + const issue = issueResult.value; + const issueTitle = escapeMarkdown(issue.title.trim()); + const issueTitleQuoteEscaped = issueTitle.replace(/"/g, '\\"'); + + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon( + issue, + )} **${issueTitle}**](${url}${title}")\\\n${GlyphChars.Space.repeat( + 5, + )}${linkText} ${issue.state} ${fromNow( + issue.closedDate ?? issue.createdDate, + )}`, + ); + } + + title += `\n${GlyphChars.Dash.repeat( + 2, + )}\n${issueTitleQuoteEscaped}\n${capitalize(issue.state)}, ${fromNow( + issue.closedDate ?? issue.createdDate, + )}`; } - - title += `\n${GlyphChars.Dash.repeat(2)}\n${issueTitle}\n${ - issue.closed ? 'Closed' : 'Opened' - }, ${fromNow(issue.closedDate ?? issue.date)}`; + } else if (footnotes != null && !prs?.has(num)) { + let name = ref.description?.replace(numRegex, num); + if (name == null) { + name = `Custom Autolink ${ref.prefix}${num}`; + } + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} ${name}](${url}${title}")`, + ); } + title += '"'; } - title += '"'; - } - const token = `\x00${tokenMapping.size}\x00`; - tokenMapping.set(token, `[${linkText}](${url}${title})`); - return token; - }); + const token = `\x00${tokenMapping.size}\x00`; + tokenMapping.set(token, `[${linkText}](${url}${title})`); + return `${prefix}${token}`; + }, + ); case 'html': ensureCachedRegex(ref, outputFormat); - return text.replace(ref.messageHtmlRegex, (_: string, linkText: string, num: string) => { - const url = encodeUrl(ref.url.replace(numRegex, num)); - - let title = ''; - if (ref.title) { - title = `"${encodeHtmlWeak(ref.title.replace(numRegex, num))}`; - - const issue = issuesOrPullRequests?.get(num); - if (issue != null) { - if (issue instanceof PromiseCancelledError) { - title += `\n${GlyphChars.Dash.repeat(2)}\nDetails timed out`; - } else { - const issueTitle = encodeHtmlWeak(issue.title.trim()); - - if (footnotes != null) { - footnoteIndex = footnotes.size + 1; - footnotes.set( - footnoteIndex, - `${IssueOrPullRequest.getHtmlIcon( - issue, - )} ${issueTitle}
${GlyphChars.Space.repeat( - 5, - )}${linkText} ${issue.closed ? 'closed' : 'opened'} ${fromNow( - issue.closedDate ?? issue.date, - )}`, - ); + return text.replace( + ref.messageHtmlRegex, + (_: string, prefix: string, linkText: string, num: string) => { + const url = encodeUrl(ref.url.replace(numRegex, num)); + + let title = ''; + if (ref.title) { + title = `"${encodeHtmlWeak(ref.title.replace(numRegex, num))}`; + + const issueResult = enrichedAutolinks?.get(num)?.[0]; + if (issueResult?.value != null) { + if (issueResult.paused) { + if (footnotes != null && !prs?.has(num)) { + let name = ref.description?.replace(numRegex, num); + if (name == null) { + name = `Custom Autolink ${ref.prefix}${num}`; + } + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `${getIssueOrPullRequestHtmlIcon()} ${name}`, + ); + } + + title += `\n${GlyphChars.Dash.repeat(2)}\nLoading...`; + } else { + const issue = issueResult.value; + const issueTitle = encodeHtmlWeak(issue.title.trim()); + const issueTitleQuoteEscaped = issueTitle.replace(/"/g, '"'); + + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `${getIssueOrPullRequestHtmlIcon( + issue, + )} ${issueTitle}
${GlyphChars.Space.repeat( + 5, + )}${linkText} ${issue.state} ${fromNow( + issue.closedDate ?? issue.createdDate, + )}`, + ); + } + + title += `\n${GlyphChars.Dash.repeat( + 2, + )}\n${issueTitleQuoteEscaped}\n${capitalize(issue.state)}, ${fromNow( + issue.closedDate ?? issue.createdDate, + )}`; } - - title += `\n${GlyphChars.Dash.repeat(2)}\n${issueTitle}\n${ - issue.closed ? 'Closed' : 'Opened' - }, ${fromNow(issue.closedDate ?? issue.date)}`; + } else if (footnotes != null && !prs?.has(num)) { + let name = ref.description?.replace(numRegex, num); + if (name == null) { + name = `Custom Autolink ${ref.prefix}${num}`; + } + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `${getIssueOrPullRequestHtmlIcon()} ${name}`, + ); } + title += '"'; } - title += '"'; - } - const token = `\x00${tokenMapping.size}\x00`; - tokenMapping.set(token, `${linkText}`); - return token; - }); + const token = `\x00${tokenMapping.size}\x00`; + tokenMapping.set(token, `${linkText}`); + return `${prefix}${token}`; + }, + ); default: ensureCachedRegex(ref, outputFormat); - return text.replace(ref.messageRegex, (_: string, linkText: string, num: string) => { - const issue = issuesOrPullRequests?.get(num); - if (issue == null) return linkText; - - if (footnotes != null) { - footnoteIndex = footnotes.size + 1; - footnotes.set( - footnoteIndex, - `${linkText}: ${ - issue instanceof PromiseCancelledError - ? 'Details timed out' - : `${issue.title} ${GlyphChars.Dot} ${ - issue.closed ? 'Closed' : 'Opened' - }, ${fromNow(issue.closedDate ?? issue.date)}` - }`, - ); - } + return text.replace( + ref.messageRegex, + (_: string, prefix: string, linkText: string, num: string) => { + const issueResult = enrichedAutolinks?.get(num)?.[0]; + if (issueResult?.value == null) return linkText; + + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `${linkText}: ${ + issueResult.paused + ? 'Loading...' + : `${issueResult.value.title} ${GlyphChars.Dot} ${capitalize( + issueResult.value.state, + )}, ${fromNow( + issueResult.value.closedDate ?? issueResult.value.createdDate, + )}` + }`, + ); + } - const token = `\x00${tokenMapping.size}\x00`; - tokenMapping.set(token, `${linkText}${getSuperscript(footnoteIndex)}`); - return token; - }); + const token = `\x00${tokenMapping.size}\x00`; + tokenMapping.set(token, `${linkText}${getSuperscript(footnoteIndex)}`); + return `${prefix}${token}`; + }, + ); } }; } catch (ex) { @@ -443,21 +621,19 @@ function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html' if (outputFormat === 'markdown' && ref.messageMarkdownRegex == null) { // Extra `\\\\` in `\\\\\\[` is because the markdown is escaped ref.messageMarkdownRegex = new RegExp( - `(?<=^|\\s|\\(|\\[|\\{)(${escapeRegex(encodeHtmlWeak(escapeMarkdown(ref.prefix)))}(${ + `(^|\\s|\\(|\\[|\\{)(${escapeRegex(encodeHtmlWeak(escapeMarkdown(ref.prefix)))}(${ ref.alphanumeric ? '\\w' : '\\d' }+))\\b`, ref.ignoreCase ? 'gi' : 'g', ); } else if (outputFormat === 'html' && ref.messageHtmlRegex == null) { ref.messageHtmlRegex = new RegExp( - `(?<=^|\\s|\\(|\\[|\\{)(${escapeRegex(encodeHtmlWeak(ref.prefix))}(${ - ref.alphanumeric ? '\\w' : '\\d' - }+))\\b`, + `(^|\\s|\\(|\\[|\\{)(${escapeRegex(encodeHtmlWeak(ref.prefix))}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`, ref.ignoreCase ? 'gi' : 'g', ); } else if (ref.messageRegex == null) { ref.messageRegex = new RegExp( - `(?<=^|\\s|\\(|\\[|\\{)(${escapeRegex(ref.prefix)}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`, + `(^|\\s|\\(|\\[|\\{)(${escapeRegex(ref.prefix)}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`, ref.ignoreCase ? 'gi' : 'g', ); } diff --git a/src/annotations/blameAnnotationProvider.ts b/src/annotations/blameAnnotationProvider.ts index beb2b5108f5ce..d87fcd8b3f4fc 100644 --- a/src/annotations/blameAnnotationProvider.ts +++ b/src/annotations/blameAnnotationProvider.ts @@ -1,33 +1,35 @@ import type { CancellationToken, Disposable, Position, TextDocument, TextEditor } from 'vscode'; import { Hover, languages, Range } from 'vscode'; import type { FileAnnotationType } from '../config'; -import { configuration } from '../configuration'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import type { GitBlame } from '../git/models/blame'; import type { GitCommit } from '../git/models/commit'; import { changesMessage, detailsMessage } from '../hovers/hovers'; import { log } from '../system/decorators/log'; -import type { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; +import { configuration } from '../system/vscode/configuration'; +import type { TrackedGitDocument } from '../trackers/trackedDocument'; +import type { DidChangeStatusCallback } from './annotationProvider'; import { AnnotationProviderBase } from './annotationProvider'; import type { ComputedHeatmap } from './annotations'; import { getHeatmapColors } from './annotations'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase { protected blame: Promise; protected hoverProviderDisposable: Disposable | undefined; constructor( + container: Container, + onDidChangeStatus: DidChangeStatusCallback, annotationType: FileAnnotationType, editor: TextEditor, - trackedDocument: TrackedDocument, - protected readonly container: Container, + trackedDocument: TrackedGitDocument, ) { - super(annotationType, editor, trackedDocument); + super(container, onDidChangeStatus, annotationType, editor, trackedDocument); - this.blame = this.container.git.getBlame(this.trackedDocument.uri, editor.document); + this.blame = container.git.getBlame(this.trackedDocument.uri, editor.document); if (editor.document.isDirty) { trackedDocument.setForceDirtyStateChangeOnNextDocumentChange(); @@ -39,17 +41,20 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase this.hoverProviderDisposable.dispose(); this.hoverProviderDisposable = undefined; } - super.clear(); + return super.clear(); } - async validate(): Promise { + override async validate(): Promise { const blame = await this.blame; - return blame != null && blame.lines.length !== 0; + return Boolean(blame?.lines.length); } - protected async getBlame(): Promise { + protected async getBlame(force?: boolean): Promise { + if (force) { + this.blame = this.container.git.getBlame(this.trackedDocument.uri, this.editor.document); + } const blame = await this.blame; - if (blame == null || blame.lines.length === 0) return undefined; + if (!blame?.lines.length) return undefined; return blame; } @@ -106,10 +111,10 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase Array.isArray(lookupTable) ? lookupTable : unified - ? lookupTable.hot.concat(lookupTable.cold) - : date.getTime() < coldThresholdTimestamp - ? lookupTable.cold - : lookupTable.hot; + ? lookupTable.hot.concat(lookupTable.cold) + : date.getTime() < coldThresholdTimestamp + ? lookupTable.cold + : lookupTable.hot; const computeRelativeAge = (date: Date, lookup: number[]) => { const time = date.getTime(); @@ -141,8 +146,9 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase return; } + this.hoverProviderDisposable?.dispose(); this.hoverProviderDisposable = languages.registerHoverProvider( - { pattern: this.document.uri.fsPath }, + { pattern: this.editor.document.uri.fsPath }, { provideHover: (document: TextDocument, position: Position, token: CancellationToken) => this.provideHover(providers, document, position, token), @@ -158,7 +164,7 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase ): Promise { if (configuration.get('hovers.annotations.over') !== 'line' && position.character !== 0) return undefined; - if (this.document.uri.toString() !== document.uri.toString()) return undefined; + if (this.editor.document.uri.toString() !== document.uri.toString()) return undefined; const blame = await this.getBlame(); if (blame == null) return undefined; @@ -172,7 +178,13 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase await Promise.all([ providers.details ? this.getDetailsHoverMessage(commit, document) : undefined, providers.changes - ? changesMessage(commit, await GitUri.fromUri(document.uri), position.line, document) + ? changesMessage( + this.container, + commit, + await GitUri.fromUri(document.uri), + position.line, + document, + ) : undefined, ]) ).filter((m?: T): m is T => Boolean(m)); @@ -190,19 +202,13 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase editorLine = commitLine.originalLine - 1; const cfg = configuration.get('hovers'); - return detailsMessage( - commit, - await GitUri.fromUri(document.uri), - editorLine, - cfg.detailsMarkdownFormat, - configuration.get('defaultDateFormat'), - { - autolinks: cfg.autolinks.enabled, - pullRequests: { - enabled: cfg.pullRequests.enabled, - }, - }, - ); + return detailsMessage(this.container, commit, await GitUri.fromUri(document.uri), editorLine, { + autolinks: cfg.autolinks.enabled, + dateFormat: configuration.get('defaultDateFormat'), + format: cfg.detailsMarkdownFormat, + pullRequests: cfg.pullRequests.enabled, + timeout: 250, + }); } } diff --git a/src/annotations/fileAnnotationController.ts b/src/annotations/fileAnnotationController.ts index 7286aa844a139..c17e5aa297ede 100644 --- a/src/annotations/fileAnnotationController.ts +++ b/src/annotations/fileAnnotationController.ts @@ -20,47 +20,37 @@ import { window, workspace, } from 'vscode'; -import { - AnnotationsToggleMode, - BlameHighlightLocations, - ChangesLocations, - configuration, - FileAnnotationType, -} from '../configuration'; -import { Colors, ContextKeys } from '../constants'; +import type { AnnotationsToggleMode, FileAnnotationType } from '../config'; +import type { Colors, CoreColors } from '../constants.colors'; import type { Container } from '../container'; -import { setContext } from '../context'; -import type { KeyboardScope } from '../keyboard'; -import { Logger } from '../logger'; +import { debug, log } from '../system/decorators/log'; import { once } from '../system/event'; +import type { Deferrable } from '../system/function'; import { debounce } from '../system/function'; import { find } from '../system/iterable'; import { basename } from '../system/path'; -import { isTextEditor } from '../system/utils'; +import { registerCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; +import type { KeyboardScope } from '../system/vscode/keyboard'; +import { getResourceContextKeyValue, isTrackableTextEditor } from '../system/vscode/utils'; import type { DocumentBlameStateChangeEvent, + DocumentDirtyIdleTriggerEvent, DocumentDirtyStateChangeEvent, - GitDocumentState, -} from '../trackers/gitDocumentTracker'; -import type { AnnotationContext, AnnotationProviderBase, TextEditorCorrelationKey } from './annotationProvider'; -import { AnnotationStatus, getEditorCorrelationKey } from './annotationProvider'; -import { GutterBlameAnnotationProvider } from './gutterBlameAnnotationProvider'; +} from '../trackers/documentTracker'; +import type { + AnnotationContext, + AnnotationProviderBase, + AnnotationStatus, + TextEditorCorrelationKey, +} from './annotationProvider'; +import { getEditorCorrelationKey } from './annotationProvider'; import type { ChangesAnnotationContext } from './gutterChangesAnnotationProvider'; -import { GutterChangesAnnotationProvider } from './gutterChangesAnnotationProvider'; -import { GutterHeatmapBlameAnnotationProvider } from './gutterHeatmapBlameAnnotationProvider'; - -export const enum AnnotationClearReason { - User = 'User', - BlameabilityChanged = 'BlameabilityChanged', - ColumnChanged = 'ColumnChanged', - Disposing = 'Disposing', - DocumentChanged = 'DocumentChanged', - DocumentClosed = 'DocumentClosed', -} export const Decorations = { gutterBlameAnnotation: window.createTextEditorDecorationType({ - rangeBehavior: DecorationRangeBehavior.ClosedOpen, + rangeBehavior: DecorationRangeBehavior.OpenOpen, textDecoration: 'none', }), gutterBlameHighlight: undefined as TextEditorDecorationType | undefined, @@ -102,7 +92,6 @@ export class FileAnnotationController implements Disposable { Decorations.changesLineAddedAnnotation?.dispose(); Decorations.changesLineDeletedAnnotation?.dispose(); - this._annotationsDisposable?.dispose(); this._disposable?.dispose(); } @@ -117,27 +106,37 @@ export class FileAnnotationController implements Disposable { this.updateDecorations(false); } + if (configuration.changed(e, 'fileAnnotations.dismissOnEscape')) { + if (configuration.get('fileAnnotations.dismissOnEscape')) { + if (window.visibleTextEditors.some(e => this.getProvider(e))) { + void this.attachKeyboardHook(); + } + } else { + void this.detachKeyboardHook(); + } + } + let toggleMode; if (configuration.changed(e, 'blame.toggleMode')) { toggleMode = configuration.get('blame.toggleMode'); - this._toggleModes.set(FileAnnotationType.Blame, toggleMode); - if (!initializing && toggleMode === AnnotationsToggleMode.File) { + this._toggleModes.set('blame', toggleMode); + if (!initializing && toggleMode === 'file') { void this.clearAll(); } } if (configuration.changed(e, 'changes.toggleMode')) { toggleMode = configuration.get('changes.toggleMode'); - this._toggleModes.set(FileAnnotationType.Changes, toggleMode); - if (!initializing && toggleMode === AnnotationsToggleMode.File) { + this._toggleModes.set('changes', toggleMode); + if (!initializing && toggleMode === 'file') { void this.clearAll(); } } if (configuration.changed(e, 'heatmap.toggleMode')) { toggleMode = configuration.get('heatmap.toggleMode'); - this._toggleModes.set(FileAnnotationType.Heatmap, toggleMode); - if (!initializing && toggleMode === AnnotationsToggleMode.File) { + this._toggleModes.set('heatmap', toggleMode); + if (!initializing && toggleMode === 'file') { void this.clearAll(); } } @@ -160,7 +159,7 @@ export class FileAnnotationController implements Disposable { for (const provider of this._annotationProviders.values()) { if (provider == null) continue; - void this.show(provider.editor, provider.annotationType ?? FileAnnotationType.Blame); + void this.show(provider.editor, provider.annotationType ?? 'blame'); } } } @@ -170,7 +169,7 @@ export class FileAnnotationController implements Disposable { } private async onActiveTextEditorChanged(editor: TextEditor | undefined) { - if (editor != null && !isTextEditor(editor)) return; + if (editor != null && !isTrackableTextEditor(editor)) return; this._editor = editor; // Logger.log('AnnotationController.onActiveTextEditorChanged', editor && editor.document.uri.fsPath); @@ -183,29 +182,51 @@ export class FileAnnotationController implements Disposable { const provider = this.getProvider(editor); if (provider == null) { - void setContext(ContextKeys.AnnotationStatus, undefined); void this.detachKeyboardHook(); } else { - void setContext(ContextKeys.AnnotationStatus, provider.status); void this.attachKeyboardHook(); } } - private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { + private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { + const editor = window.activeTextEditor; + if (editor == null) return; + // Only care if we are becoming un-blameable - if (e.blameable) return; + if (e.blameable) { + if (configuration.get('fileAnnotations.preserveWhileEditing')) { + this.restore(editor); + } + + return; + } + + void this.clearCore(getEditorCorrelationKey(editor)); + } + + private async onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent) { + if (!configuration.get('fileAnnotations.preserveWhileEditing')) return; + + const status = await e.document.getStatus(); + if (!status.blameable) return; const editor = window.activeTextEditor; if (editor == null) return; - void this.clear(editor, AnnotationClearReason.BlameabilityChanged); + this.restore(editor); } - private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { + private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { for (const [key, p] of this._annotationProviders) { - if (!e.document.is(p.document)) continue; + if (!e.document.is(p.editor.document)) continue; - void this.clearCore(key, AnnotationClearReason.DocumentChanged); + if (configuration.get('fileAnnotations.preserveWhileEditing')) { + if (!e.dirty) { + this.restore(e.editor); + } + } else if (e.dirty) { + void this.clearCore(key); + } } } @@ -213,9 +234,9 @@ export class FileAnnotationController implements Disposable { if (!this.container.git.isTrackable(document.uri)) return; for (const [key, p] of this._annotationProviders) { - if (p.document !== document) continue; + if (p.editor.document !== document) continue; - void this.clearCore(key, AnnotationClearReason.DocumentClosed); + void this.clearCore(key); } } @@ -230,72 +251,173 @@ export class FileAnnotationController implements Disposable { ); if (fuzzyProvider == null) return; - void this.clearCore(fuzzyProvider.correlationKey, AnnotationClearReason.ColumnChanged); + void this.clearCore(fuzzyProvider.correlationKey); return; } - void provider.restore(e.textEditor); + provider.restore(e.textEditor); } private onVisibleTextEditorsChanged(editors: readonly TextEditor[]) { for (const e of editors) { - void this.getProvider(e)?.restore(e); + this.getProvider(e)?.restore(e, false); } } isInWindowToggle(): boolean { - return this.getToggleMode(this._windowAnnotationType) === AnnotationsToggleMode.Window; + return this.getToggleMode(this._windowAnnotationType) === 'window'; } private getToggleMode(annotationType: FileAnnotationType | undefined): AnnotationsToggleMode { - if (annotationType == null) return AnnotationsToggleMode.File; + if (annotationType == null) return 'file'; - return this._toggleModes.get(annotationType) ?? AnnotationsToggleMode.File; + return this._toggleModes.get(annotationType) ?? 'file'; } - clear(editor: TextEditor, reason: AnnotationClearReason = AnnotationClearReason.User) { - if (this.isInWindowToggle()) { - return this.clearAll(); - } + @log({ args: { 0: e => e?.document.uri.toString(true) } }) + clear(editor: TextEditor) { + if (this.isInWindowToggle()) return this.clearAll(); - return this.clearCore(getEditorCorrelationKey(editor), reason); + return this.clearCore(getEditorCorrelationKey(editor), true); } + @log() async clearAll() { this._windowAnnotationType = undefined; + for (const [key] of this._annotationProviders) { - await this.clearCore(key, AnnotationClearReason.Disposing); + await this.clearCore(key, true); } + + this.unsubscribe(); } async getAnnotationType(editor: TextEditor | undefined): Promise { const provider = this.getProvider(editor); if (provider == null) return undefined; - const trackedDocument = await this.container.tracker.get(editor!.document); - if (trackedDocument == null || !trackedDocument.isBlameable) return undefined; + const trackedDocument = await this.container.documentTracker.get(editor!.document); + const status = await trackedDocument?.getStatus(); + if (!status?.blameable) return undefined; return provider.annotationType; } getProvider(editor: TextEditor | undefined): AnnotationProviderBase | undefined { - if (editor == null || editor.document == null) return undefined; + if (editor?.document == null) return undefined; return this._annotationProviders.get(getEditorCorrelationKey(editor)); } + private debouncedRestores = new WeakMap>(); + + private restore(editor: TextEditor, recompute?: boolean) { + const provider = this.getProvider(editor); + if (provider == null) return; + + let debouncedRestore = this.debouncedRestores.get(editor); + if (debouncedRestore == null) { + debouncedRestore = debounce((editor: TextEditor) => { + this.debouncedRestores.delete(editor); + provider.restore(editor, recompute ?? true); + }, 500); + this.debouncedRestores.set(editor, debouncedRestore); + } + + debouncedRestore(editor); + } + + private readonly _annotatedUris = new Set(); + private readonly _computingUris = new Set(); + + async onProviderEditorStatusChanged(editor: TextEditor | undefined, status: AnnotationStatus | undefined) { + if (editor == null) return; + + let changed = false; + let windowStatus; + + if (this.isInWindowToggle()) { + windowStatus = status; + + changed = Boolean(this._annotatedUris.size || this._computingUris.size); + this._annotatedUris.clear(); + this._computingUris.clear(); + } else { + windowStatus = undefined; + + let key = getResourceContextKeyValue(editor.document.uri); + if (typeof key !== 'string') { + key = await key; + } + + switch (status) { + case 'computing': + if (!this._annotatedUris.has(key)) { + this._annotatedUris.add(key); + changed = true; + } + + if (!this._computingUris.has(key)) { + this._computingUris.add(key); + changed = true; + } + + break; + case 'computed': { + const provider = this.getProvider(editor); + if (provider == null) { + if (this._annotatedUris.has(key)) { + this._annotatedUris.delete(key); + changed = true; + } + } else if (!this._annotatedUris.has(key)) { + this._annotatedUris.add(key); + changed = true; + } + + if (this._computingUris.has(key)) { + this._computingUris.delete(key); + changed = true; + } + break; + } + default: + if (this._annotatedUris.has(key)) { + this._annotatedUris.delete(key); + changed = true; + } + + if (this._computingUris.has(key)) { + this._computingUris.delete(key); + changed = true; + } + break; + } + } + + if (!changed) return; + + await Promise.allSettled([ + setContext('gitlens:window:annotated', windowStatus), + setContext('gitlens:tabs:annotated:computing', [...this._computingUris]), + setContext('gitlens:tabs:annotated', [...this._annotatedUris]), + ]); + } + async show(editor: TextEditor | undefined, type: FileAnnotationType, context?: AnnotationContext): Promise; - async show( - editor: TextEditor | undefined, - type: FileAnnotationType.Changes, - context?: ChangesAnnotationContext, - ): Promise; + async show(editor: TextEditor | undefined, type: 'changes', context?: ChangesAnnotationContext): Promise; + @log({ + args: { + 0: e => e?.document.uri.toString(true), + 2: false, + }, + }) async show( editor: TextEditor | undefined, type: FileAnnotationType, context?: AnnotationContext | ChangesAnnotationContext, ): Promise { - if (this.getToggleMode(type) === AnnotationsToggleMode.Window) { + if (this.getToggleMode(type) === 'window') { let first = this._windowAnnotationType == null; const reset = !first && this._windowAnnotationType !== type; @@ -313,32 +435,35 @@ export class FileAnnotationController implements Disposable { void this.show(e, type); } } + + if (editor == null) { + this.subscribe(); + return false; + } } if (editor == null) return false; // || editor.viewColumn == null) return false; this._editor = editor; - const trackedDocument = await this.container.tracker.getOrAdd(editor.document); - if (!trackedDocument.isBlameable) return false; + const trackedDocument = await this.container.documentTracker.getOrAdd(editor.document); + const status = await trackedDocument?.getStatus(); + if (!status?.blameable) return false; const currentProvider = this.getProvider(editor); if (currentProvider?.annotationType === type) { await currentProvider.provideAnnotation(context); - await currentProvider.selection(context?.selection); return true; } const provider = await window.withProgress( { location: ProgressLocation.Window }, async (progress: Progress<{ message: string }>) => { - await setContext(ContextKeys.AnnotationStatus, AnnotationStatus.Computing); + void this.onProviderEditorStatusChanged(editor, 'computing'); const computingAnnotations = this.showAnnotationsCore(currentProvider, editor, type, context, progress); - const provider = await computingAnnotations; + void (await computingAnnotations); - if (editor === this._editor) { - await setContext(ContextKeys.AnnotationStatus, provider?.status); - } + void this.onProviderEditorStatusChanged(editor, 'computed'); return computingAnnotations; }, @@ -355,33 +480,47 @@ export class FileAnnotationController implements Disposable { ): Promise; async toggle( editor: TextEditor | undefined, - type: FileAnnotationType.Changes, + type: 'changes', context?: ChangesAnnotationContext, on?: boolean, ): Promise; + @log({ + args: { + 0: e => e?.document.uri.toString(true), + 2: false, + }, + }) async toggle( editor: TextEditor | undefined, type: FileAnnotationType, context?: AnnotationContext | ChangesAnnotationContext, on?: boolean, ): Promise { - if (editor != null && this._toggleModes.get(type) === AnnotationsToggleMode.File) { - const trackedDocument = await this.container.tracker.getOrAdd(editor.document); - if ((type === FileAnnotationType.Changes && !trackedDocument.isTracked) || !trackedDocument.isBlameable) { + if (editor != null && this._toggleModes.get(type) === 'file') { + const trackedDocument = await this.container.documentTracker.getOrAdd(editor.document); + const status = await trackedDocument?.getStatus(); + if ((type === 'changes' && !status?.tracked) || !status?.blameable) { return false; } } const provider = this.getProvider(editor); - if (provider == null) return this.show(editor, type, context); + if (provider == null) { + if (editor == null && this.isInWindowToggle()) { + await this.clearAll(); + return false; + } + + return this.show(editor, type, context); + } - const reopen = provider.annotationType !== type || provider.mustReopen(context); + const reopen = provider.annotationType !== type || !provider.canReuse(context); if (on === true && !reopen) return true; if (this.isInWindowToggle()) { await this.clearAll(); } else { - await this.clearCore(provider.correlationKey, AnnotationClearReason.User); + await this.clearCore(provider.correlationKey, true); } if (!reopen) return false; @@ -389,7 +528,21 @@ export class FileAnnotationController implements Disposable { return this.show(editor, type, context); } + @log() + nextChange() { + const provider = this.getProvider(window.activeTextEditor); + provider?.nextChange?.(); + } + + @log() + previousChange() { + const provider = this.getProvider(window.activeTextEditor); + provider?.previousChange?.(); + } + private async attachKeyboardHook() { + if (!configuration.get('fileAnnotations.dismissOnEscape')) return; + // Allows pressing escape to exit the annotations if (this._keyboardScope == null) { this._keyboardScope = await this.container.keyboard.beginScope({ @@ -398,7 +551,7 @@ export class FileAnnotationController implements Disposable { const e = this._editor; if (e == null) return undefined; - await this.clear(e, AnnotationClearReason.User); + await this.clear(e); return undefined; }, }, @@ -406,25 +559,25 @@ export class FileAnnotationController implements Disposable { } } - private async clearCore(key: TextEditorCorrelationKey, reason: AnnotationClearReason) { + @log() + private async clearCore(key: TextEditorCorrelationKey, force?: boolean) { const provider = this._annotationProviders.get(key); if (provider == null) return; - Logger.log(`${reason}:`, `Clear annotations for ${key}`); - this._annotationProviders.delete(key); provider.dispose(); - if (this._annotationProviders.size === 0 || key === getEditorCorrelationKey(this._editor)) { - await setContext(ContextKeys.AnnotationStatus, undefined); + if (!this._annotationProviders.size || key === getEditorCorrelationKey(this._editor)) { + if (this._editor != null) { + void this.onProviderEditorStatusChanged(this._editor, undefined); + } + await this.detachKeyboardHook(); } - if (this._annotationProviders.size === 0) { - Logger.log('Remove all listener registrations for annotations'); - - this._annotationsDisposable?.dispose(); - this._annotationsDisposable = undefined; + if (!this._annotationProviders.size && (force || !this.isInWindowToggle())) { + this._windowAnnotationType = undefined; + this.unsubscribe(); } this._onDidToggleAnnotations.fire(); @@ -447,15 +600,15 @@ export class FileAnnotationController implements Disposable { if (progress != null) { let annotationsLabel = 'annotations'; switch (type) { - case FileAnnotationType.Blame: + case 'blame': annotationsLabel = 'blame annotations'; break; - case FileAnnotationType.Changes: + case 'changes': annotationsLabel = 'changes annotations'; break; - case FileAnnotationType.Heatmap: + case 'heatmap': annotationsLabel = 'heatmap annotations'; break; } @@ -468,39 +621,55 @@ export class FileAnnotationController implements Disposable { // Allows pressing escape to exit the annotations await this.attachKeyboardHook(); - const trackedDocument = await this.container.tracker.getOrAdd(editor.document); + const trackedDocument = await this.container.documentTracker.getOrAdd(editor.document); let provider: AnnotationProviderBase | undefined = undefined; switch (type) { - case FileAnnotationType.Blame: - provider = new GutterBlameAnnotationProvider(editor, trackedDocument, this.container); + case 'blame': { + const { GutterBlameAnnotationProvider } = await import( + /* webpackChunkName: "annotations" */ './gutterBlameAnnotationProvider' + ); + provider = new GutterBlameAnnotationProvider( + this.container, + e => this.onProviderEditorStatusChanged(e.editor, e.status), + editor, + trackedDocument, + ); break; - - case FileAnnotationType.Changes: - provider = new GutterChangesAnnotationProvider(editor, trackedDocument, this.container); + } + case 'changes': { + const { GutterChangesAnnotationProvider } = await import( + /* webpackChunkName: "annotations" */ './gutterChangesAnnotationProvider' + ); + provider = new GutterChangesAnnotationProvider( + this.container, + e => this.onProviderEditorStatusChanged(e.editor, e.status), + editor, + trackedDocument, + ); break; - - case FileAnnotationType.Heatmap: - provider = new GutterHeatmapBlameAnnotationProvider(editor, trackedDocument, this.container); + } + case 'heatmap': { + const { GutterHeatmapBlameAnnotationProvider } = await import( + /* webpackChunkName: "annotations" */ './gutterHeatmapBlameAnnotationProvider' + ); + provider = new GutterHeatmapBlameAnnotationProvider( + this.container, + e => this.onProviderEditorStatusChanged(e.editor, e.status), + editor, + trackedDocument, + ); break; + } } - if (provider == null || !(await provider.validate())) return undefined; + if (provider == null || (await provider.validate?.()) === false) return undefined; if (currentProvider != null) { - await this.clearCore(currentProvider.correlationKey, AnnotationClearReason.User); + await this.clearCore(currentProvider.correlationKey, true); } - if (this._annotationsDisposable == null && this._annotationProviders.size === 0) { - Logger.log('Add listener registrations for annotations'); - - this._annotationsDisposable = Disposable.from( - window.onDidChangeActiveTextEditor(debounce(this.onActiveTextEditorChanged, 50), this), - window.onDidChangeTextEditorViewColumn(this.onTextEditorViewColumnChanged, this), - window.onDidChangeVisibleTextEditors(debounce(this.onVisibleTextEditorsChanged, 50), this), - workspace.onDidCloseTextDocument(this.onTextDocumentClosed, this), - this.container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this), - this.container.tracker.onDidChangeDirtyState(this.onDirtyStateChanged, this), - ); + if (this._annotationProviders.size === 0) { + this.subscribe(); } this._annotationProviders.set(provider.correlationKey, provider); @@ -509,11 +678,42 @@ export class FileAnnotationController implements Disposable { return provider; } - await this.clearCore(provider.correlationKey, AnnotationClearReason.Disposing); + await this.clearCore(provider.correlationKey, true); return undefined; } + @debug({ + singleLine: true, + if: function () { + return this._annotationsDisposable == null; + }, + }) + private subscribe() { + this._annotationsDisposable ??= Disposable.from( + window.onDidChangeActiveTextEditor(debounce(this.onActiveTextEditorChanged, 50), this), + window.onDidChangeTextEditorViewColumn(this.onTextEditorViewColumnChanged, this), + window.onDidChangeVisibleTextEditors(debounce(this.onVisibleTextEditorsChanged, 50), this), + workspace.onDidCloseTextDocument(this.onTextDocumentClosed, this), + this.container.documentTracker.onDidChangeBlameState(this.onBlameStateChanged, this), + this.container.documentTracker.onDidChangeDirtyState(this.onDirtyStateChanged, this), + this.container.documentTracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this), + registerCommand('gitlens.annotations.nextChange', () => this.nextChange()), + registerCommand('gitlens.annotations.previousChange', () => this.previousChange()), + ); + } + + @debug({ + singleLine: true, + if: function () { + return this._annotationsDisposable != null; + }, + }) + private unsubscribe() { + this._annotationsDisposable?.dispose(); + this._annotationsDisposable = undefined; + } + private updateDecorations(refresh: boolean) { const previous = refresh ? Object.entries(Decorations) : (undefined! as []); @@ -573,49 +773,45 @@ export class FileAnnotationController implements Disposable { } Decorations.changesLineAddedAnnotation = window.createTextEditorDecorationType({ - backgroundColor: locations.includes(ChangesLocations.Line) - ? `rgba(${addedColor.join(',')},0.4)` - : undefined, - isWholeLine: locations.includes(ChangesLocations.Line) ? true : undefined, - gutterIconPath: locations.includes(ChangesLocations.Gutter) + backgroundColor: locations.includes('line') ? `rgba(${addedColor.join(',')},0.4)` : undefined, + isWholeLine: locations.includes('line') ? true : undefined, + gutterIconPath: locations.includes('gutter') ? Uri.parse( `data:image/svg+xml,${encodeURIComponent( ``, + )})' x='13' y='0' width='3' height='18'/>`, )}`, ) : undefined, gutterIconSize: 'contain', overviewRulerLane: OverviewRulerLane.Left, - overviewRulerColor: locations.includes(ChangesLocations.Scrollbar) - ? new ThemeColor('editorOverviewRuler.addedForeground') + overviewRulerColor: locations.includes('overview') + ? new ThemeColor('editorOverviewRuler.addedForeground' satisfies CoreColors) : undefined, }); Decorations.changesLineChangedAnnotation = window.createTextEditorDecorationType({ - backgroundColor: locations.includes(ChangesLocations.Line) - ? `rgba(${changedColor.join(',')},0.4)` - : undefined, - isWholeLine: locations.includes(ChangesLocations.Line) ? true : undefined, - gutterIconPath: locations.includes(ChangesLocations.Gutter) + backgroundColor: locations.includes('line') ? `rgba(${changedColor.join(',')},0.4)` : undefined, + isWholeLine: locations.includes('line') ? true : undefined, + gutterIconPath: locations.includes('gutter') ? Uri.parse( `data:image/svg+xml,${encodeURIComponent( ``, + )})' x='13' y='0' width='3' height='18'/>`, )}`, ) : undefined, gutterIconSize: 'contain', overviewRulerLane: OverviewRulerLane.Left, - overviewRulerColor: locations.includes(ChangesLocations.Scrollbar) - ? new ThemeColor('editorOverviewRuler.modifiedForeground') + overviewRulerColor: locations.includes('overview') + ? new ThemeColor('editorOverviewRuler.modifiedForeground' satisfies CoreColors) : undefined, }); Decorations.changesLineDeletedAnnotation = window.createTextEditorDecorationType({ - gutterIconPath: locations.includes(ChangesLocations.Gutter) + gutterIconPath: locations.includes('gutter') ? Uri.parse( `data:image/svg+xml,${encodeURIComponent( ``, @@ -655,11 +851,11 @@ export class FileAnnotationController implements Disposable { gutterIconSize: 'contain', isWholeLine: true, overviewRulerLane: OverviewRulerLane.Right, - backgroundColor: locations.includes(BlameHighlightLocations.Line) - ? new ThemeColor(Colors.LineHighlightBackgroundColor) + backgroundColor: locations.includes('line') + ? new ThemeColor('gitlens.lineHighlightBackgroundColor' satisfies Colors) : undefined, - overviewRulerColor: locations.includes(BlameHighlightLocations.Scrollbar) - ? new ThemeColor(Colors.LineHighlightOverviewRulerColor) + overviewRulerColor: locations.includes('overview') + ? new ThemeColor('gitlens.lineHighlightOverviewRulerColor' satisfies Colors) : undefined, }); } diff --git a/src/annotations/gutterBlameAnnotationProvider.ts b/src/annotations/gutterBlameAnnotationProvider.ts index ef8df7dc84ce4..305c45d35672b 100644 --- a/src/annotations/gutterBlameAnnotationProvider.ts +++ b/src/annotations/gutterBlameAnnotationProvider.ts @@ -1,36 +1,46 @@ import type { DecorationOptions, TextEditor, ThemableDecorationAttachmentRenderOptions } from 'vscode'; import { Range } from 'vscode'; -import type { GravatarDefaultStyle } from '../configuration'; -import { configuration, FileAnnotationType } from '../configuration'; +import type { GravatarDefaultStyle } from '../config'; import { GlyphChars } from '../constants'; import type { Container } from '../container'; import type { CommitFormatOptions } from '../git/formatters/commitFormatter'; import { CommitFormatter } from '../git/formatters/commitFormatter'; -import type { GitBlame } from '../git/models/blame'; import type { GitCommit } from '../git/models/commit'; -import { getLogScope } from '../logScope'; import { filterMap } from '../system/array'; import { log } from '../system/decorators/log'; import { first } from '../system/iterable'; -import { Stopwatch } from '../system/stopwatch'; +import { getLogScope } from '../system/logger.scope'; +import { maybeStopWatch } from '../system/stopwatch'; import type { TokenOptions } from '../system/string'; import { getTokensFromTemplate, getWidth } from '../system/string'; -import type { GitDocumentState } from '../trackers/gitDocumentTracker'; -import type { TrackedDocument } from '../trackers/trackedDocument'; -import type { AnnotationContext } from './annotationProvider'; +import { configuration } from '../system/vscode/configuration'; +import type { TrackedGitDocument } from '../trackers/trackedDocument'; +import type { AnnotationContext, AnnotationState, DidChangeStatusCallback } from './annotationProvider'; import { applyHeatmap, getGutterDecoration, getGutterRenderOptions } from './annotations'; import { BlameAnnotationProviderBase } from './blameAnnotationProvider'; import { Decorations } from './fileAnnotationController'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) + +export interface BlameFontOptions { + family: string; + size: number; + style: string; + weight: string; +} export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { - constructor(editor: TextEditor, trackedDocument: TrackedDocument, container: Container) { - super(FileAnnotationType.Blame, editor, trackedDocument, container); + constructor( + container: Container, + onDidChangeStatus: DidChangeStatusCallback, + editor: TextEditor, + trackedDocument: TrackedGitDocument, + ) { + super(container, onDidChangeStatus, 'blame', editor, trackedDocument); } - override clear() { - super.clear(); + override async clear() { + await super.clear(); if (Decorations.gutterBlameHighlight != null) { try { @@ -40,25 +50,24 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { } @log() - async onProvideAnnotation(context?: AnnotationContext, _type?: FileAnnotationType): Promise { + override async onProvideAnnotation(_context?: AnnotationContext, state?: AnnotationState): Promise { const scope = getLogScope(); - this.annotationContext = context; - - const blame = await this.getBlame(); + const blame = await this.getBlame(state?.recompute); if (blame == null) return false; - const sw = new Stopwatch(scope); + using sw = maybeStopWatch(scope); const cfg = configuration.get('blame'); // Precalculate the formatting options so we don't need to do it on each iteration - const tokenOptions = getTokensFromTemplate(cfg.format).reduce<{ - [token: string]: TokenOptions | undefined; - }>((map, token) => { - map[token.key] = token.options; - return map; - }, Object.create(null)); + const tokenOptions = getTokensFromTemplate(cfg.format).reduce>( + (map, token) => { + map[token.key] = token.options; + return map; + }, + Object.create(null), + ); let getBranchAndTagTips; if (CommitFormatter.has(cfg.format, 'tips')) { @@ -71,10 +80,24 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { tokenOptions: tokenOptions, }; + const fontOptions: BlameFontOptions = { + family: configuration.get('blame.fontFamily'), + size: configuration.get('blame.fontSize'), + style: configuration.get('blame.fontStyle'), + weight: configuration.get('blame.fontWeight'), + }; + const avatars = cfg.avatars; const gravatarDefault = configuration.get('defaultGravatarsStyle'); const separateLines = cfg.separateLines; - const renderOptions = getGutterRenderOptions(separateLines, cfg.heatmap, cfg.avatars, cfg.format, options); + const renderOptions = getGutterRenderOptions( + separateLines, + cfg.heatmap, + cfg.avatars, + cfg.format, + options, + fontOptions, + ); const decorationOptions = []; const decorationsMap = new Map(); @@ -90,6 +113,8 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { computedHeatmap = this.getComputedHeatmap(blame); } + let emptyLine: string | undefined; + for (const l of blame.lines) { // editor lines are 0-based const editorLine = l.line - 1; @@ -101,17 +126,24 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { gutter = { ...gutter }; if (cfg.compact && !compacted) { + // Since the line length is the same just generate a single new empty line + if (emptyLine == null) { + emptyLine = GlyphChars.Space.repeat(getWidth(gutter.renderOptions!.before!.contentText!)); + } + // Since we are wiping out the contextText make sure to copy the objects gutter.renderOptions = { before: { ...gutter.renderOptions!.before, - contentText: GlyphChars.Space.repeat(getWidth(gutter.renderOptions!.before!.contentText!)), + contentText: emptyLine, }, }; if (separateLines) { gutter.renderOptions.before!.textDecoration = `none;box-sizing: border-box${ avatars ? ';padding: 0 0 0 18px' : '' + }${fontOptions.family ? `;font-family: ${fontOptions.family}` : ''}${ + fontOptions.size ? `;font-size: ${fontOptions.size}px` : '' }`; } @@ -160,14 +192,14 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { decorationsMap.set(l.sha, gutter); } - sw.restart({ suffix: ' to compute gutter blame annotations' }); + sw?.restart({ suffix: ' to compute gutter blame annotations' }); if (decorationOptions.length) { this.setDecorations([ { decorationType: Decorations.gutterBlameAnnotation, rangesOrOptions: decorationOptions }, ]); - sw.stop({ suffix: ' to apply all gutter blame annotations' }); + sw?.stop({ suffix: ' to apply all gutter blame annotations' }); } this.registerHoverProviders(configuration.get('hovers.annotations')); @@ -175,13 +207,11 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { } @log({ args: false }) - async selection(selection?: AnnotationContext['selection'], blame?: GitBlame): Promise { + override async selection(selection?: AnnotationContext['selection']): Promise { if (selection === false || Decorations.gutterBlameHighlight == null) return; - if (blame == null) { - blame = await this.blame; - if (!blame?.lines.length) return; - } + const blame = await this.blame; + if (!blame?.lines.length) return; let sha: string | undefined = undefined; if (selection?.sha != null) { diff --git a/src/annotations/gutterChangesAnnotationProvider.ts b/src/annotations/gutterChangesAnnotationProvider.ts index c68f53e26c46d..63fd2eb93d7d0 100644 --- a/src/annotations/gutterChangesAnnotationProvider.ts +++ b/src/annotations/gutterChangesAnnotationProvider.ts @@ -1,26 +1,21 @@ -import type { - CancellationToken, - DecorationOptions, - Disposable, - TextDocument, - TextEditor, - TextEditorDecorationType, -} from 'vscode'; +import type { CancellationToken, DecorationOptions, Disposable, TextDocument, TextEditor } from 'vscode'; import { Hover, languages, Position, Range, Selection, TextEditorRevealType } from 'vscode'; -import { configuration, FileAnnotationType } from '../configuration'; import type { Container } from '../container'; import type { GitCommit } from '../git/models/commit'; -import type { GitDiff } from '../git/models/diff'; +import type { GitDiffFile } from '../git/models/diff'; import { localChangesMessage } from '../hovers/hovers'; -import { getLogScope } from '../logScope'; import { log } from '../system/decorators/log'; -import { Stopwatch } from '../system/stopwatch'; -import type { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; -import type { AnnotationContext } from './annotationProvider'; +import { getLogScope } from '../system/logger.scope'; +import { getSettledValue } from '../system/promise'; +import { maybeStopWatch } from '../system/stopwatch'; +import { configuration } from '../system/vscode/configuration'; +import type { TrackedGitDocument } from '../trackers/trackedDocument'; +import type { AnnotationContext, AnnotationState, DidChangeStatusCallback } from './annotationProvider'; import { AnnotationProviderBase } from './annotationProvider'; +import type { Decoration } from './annotations'; import { Decorations } from './fileAnnotationController'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) export interface ChangesAnnotationContext extends AnnotationContext { sha?: string; @@ -28,19 +23,21 @@ export interface ChangesAnnotationContext extends AnnotationContext { } export class GutterChangesAnnotationProvider extends AnnotationProviderBase { - private state: { commit: GitCommit | undefined; diffs: GitDiff[] } | undefined; private hoverProviderDisposable: Disposable | undefined; + private sortedHunkStarts: number[] | undefined; + private state: { commit: GitCommit | undefined; diffs: GitDiffFile[] } | undefined; constructor( + container: Container, + onDidChangeStatus: DidChangeStatusCallback, editor: TextEditor, - trackedDocument: TrackedDocument, - private readonly container: Container, + trackedDocument: TrackedGitDocument, ) { - super(FileAnnotationType.Changes, editor, trackedDocument); + super(container, onDidChangeStatus, 'changes', editor, trackedDocument); } - override mustReopen(context?: ChangesAnnotationContext): boolean { - return this.annotationContext?.sha !== context?.sha || this.annotationContext?.only !== context?.only; + override canReuse(context?: ChangesAnnotationContext): boolean { + return !(this.annotationContext?.sha !== context?.sha || this.annotationContext?.only !== context?.only); } override clear() { @@ -49,26 +46,61 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase { - return Promise.resolve(); - } + override nextChange() { + if (this.sortedHunkStarts == null) return; - validate(): Promise { - return Promise.resolve(true); + let nextLine = -1; + const currentLine = this.editor.selection.active.line; + for (const line of this.sortedHunkStarts) { + if (line > currentLine) { + nextLine = line; + break; + } + } + + if (nextLine === -1) { + nextLine = this.sortedHunkStarts[0]; + } + + if (nextLine > 0) { + this.editor.selection = new Selection(nextLine, 0, nextLine, 0); + this.editor.revealRange( + new Range(nextLine, 0, nextLine, 0), + TextEditorRevealType.InCenterIfOutsideViewport, + ); + } } - @log() - async onProvideAnnotation(context?: ChangesAnnotationContext): Promise { - const scope = getLogScope(); + override previousChange() { + if (this.sortedHunkStarts == null) return; + + let previousLine = -1; + const currentLine = this.editor.selection.active.line; + for (const line of this.sortedHunkStarts) { + if (line >= currentLine) break; + + previousLine = line; + } - if (this.mustReopen(context)) { - this.clear(); + if (previousLine === -1) { + previousLine = this.sortedHunkStarts[this.sortedHunkStarts.length - 1]; } - this.annotationContext = context; + if (previousLine > 0) { + this.editor.selection = new Selection(previousLine, 0, previousLine, 0); + this.editor.revealRange( + new Range(previousLine, 0, previousLine, 0), + TextEditorRevealType.InCenterIfOutsideViewport, + ); + } + } + + @log() + override async onProvideAnnotation(context?: ChangesAnnotationContext, state?: AnnotationState): Promise { + const scope = getLogScope(); let ref1 = this.trackedDocument.uri.sha; let ref2 = context?.sha != null && context.sha !== ref1 ? `${context.sha}^` : undefined; @@ -129,18 +161,19 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase(d?: T): d is T => Boolean(d)); + ) + .map(d => getSettledValue(d)) + .filter((d?: T): d is T => Boolean(d)); if (!diffs?.length) return false; - const sw = new Stopwatch(scope); + using sw = maybeStopWatch(scope); - const decorationsMap = new Map< - string, - { decorationType: TextEditorDecorationType; rangesOrOptions: DecorationOptions[] } - >(); + const decorationsMap = new Map>(); // If we want to only show changes from the specified sha, get the blame so we can compare with "visible" shas const blame = @@ -170,6 +202,8 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase hunk.current.count) { - state = 'removed'; - } else { - count--; - continue; - } - } else { - count--; - continue; - } - } else if (hunkLine.current?.state === 'added') { - if (hunkLine.previous?.state === 'removed') { - state = 'changed'; - } else { - state = 'added'; - } - } else if (hunkLine?.current.state === 'removed') { - // Check if there are more deleted lines than added lines show a deleted indicator - if (hunk.previous.count > hunk.current.count) { - state = 'removed'; - } else { - count--; - continue; - } - } else { - state = 'changed'; - } - - let decoration = decorationsMap.get(state); + let decoration = decorationsMap.get(hunkLine.state); if (decoration == null) { decoration = { - decorationType: (state === 'added' + decorationType: (hunkLine.state === 'added' ? Decorations.changesLineAddedAnnotation - : state === 'removed' - ? Decorations.changesLineDeletedAnnotation - : Decorations.changesLineChangedAnnotation)!, + : hunkLine.state === 'removed' + ? Decorations.changesLineDeletedAnnotation + : Decorations.changesLineChangedAnnotation)!, rangesOrOptions: [{ range: range }], }; - decorationsMap.set(state, decoration); + decorationsMap.set(hunkLine.state, decoration); } else { decoration.rangesOrOptions.push({ range: range }); } @@ -258,14 +256,16 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase a - b); + + sw?.restart({ suffix: ' to compute recent changes annotations' }); if (decorationsMap.size) { this.setDecorations([...decorationsMap.values()]); - sw.stop({ suffix: ' to apply all recent changes annotations' }); + sw?.stop({ suffix: ' to apply all recent changes annotations' }); - if (selection != null && context?.selection !== false) { + if (selection != null && context?.selection !== false && !state?.restoring) { this.editor.selection = selection; this.editor.revealRange(selection, TextEditorRevealType.InCenterIfOutsideViewport); } @@ -280,8 +280,9 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase this.provideHover(document, position, token), @@ -302,7 +303,7 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase hunk.current.count; + const hasMoreDeletedLines = /*hunk.state === 'changed' &&*/ hunk.previous.count > hunk.current.count; if ( position.line >= hunk.current.position.start - 1 && position.line <= hunk.current.position.end - (hasMoreDeletedLines ? 0 : 1) diff --git a/src/annotations/gutterHeatmapBlameAnnotationProvider.ts b/src/annotations/gutterHeatmapBlameAnnotationProvider.ts index b8c0dd07434b7..457b34809230e 100644 --- a/src/annotations/gutterHeatmapBlameAnnotationProvider.ts +++ b/src/annotations/gutterHeatmapBlameAnnotationProvider.ts @@ -1,37 +1,36 @@ -import type { TextEditor, TextEditorDecorationType } from 'vscode'; +import type { TextEditor } from 'vscode'; import { Range } from 'vscode'; -import { FileAnnotationType } from '../configuration'; import type { Container } from '../container'; import type { GitCommit } from '../git/models/commit'; -import { getLogScope } from '../logScope'; import { log } from '../system/decorators/log'; -import { Stopwatch } from '../system/stopwatch'; -import type { GitDocumentState } from '../trackers/gitDocumentTracker'; -import type { TrackedDocument } from '../trackers/trackedDocument'; -import type { AnnotationContext } from './annotationProvider'; +import { getLogScope } from '../system/logger.scope'; +import { maybeStopWatch } from '../system/stopwatch'; +import type { TrackedGitDocument } from '../trackers/trackedDocument'; +import type { AnnotationContext, AnnotationState, DidChangeStatusCallback } from './annotationProvider'; +import type { Decoration } from './annotations'; import { addOrUpdateGutterHeatmapDecoration } from './annotations'; import { BlameAnnotationProviderBase } from './blameAnnotationProvider'; export class GutterHeatmapBlameAnnotationProvider extends BlameAnnotationProviderBase { - constructor(editor: TextEditor, trackedDocument: TrackedDocument, container: Container) { - super(FileAnnotationType.Heatmap, editor, trackedDocument, container); + constructor( + container: Container, + onDidChangeStatus: DidChangeStatusCallback, + editor: TextEditor, + trackedDocument: TrackedGitDocument, + ) { + super(container, onDidChangeStatus, 'heatmap', editor, trackedDocument); } @log() - async onProvideAnnotation(context?: AnnotationContext, _type?: FileAnnotationType): Promise { + override async onProvideAnnotation(_context?: AnnotationContext, state?: AnnotationState): Promise { const scope = getLogScope(); - this.annotationContext = context; - - const blame = await this.getBlame(); + const blame = await this.getBlame(state?.recompute); if (blame == null) return false; - const sw = new Stopwatch(scope); + using sw = maybeStopWatch(scope); - const decorationsMap = new Map< - string, - { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] } - >(); + const decorationsMap = new Map>(); const computedHeatmap = this.getComputedHeatmap(blame); let commit: GitCommit | undefined; @@ -50,19 +49,15 @@ export class GutterHeatmapBlameAnnotationProvider extends BlameAnnotationProvide ); } - sw.restart({ suffix: ' to compute heatmap annotations' }); + sw?.restart({ suffix: ' to compute heatmap annotations' }); if (decorationsMap.size) { this.setDecorations([...decorationsMap.values()]); - sw.stop({ suffix: ' to apply all heatmap annotations' }); + sw?.stop({ suffix: ' to apply all heatmap annotations' }); } // this.registerHoverProviders(configuration.get('hovers.annotations')); return true; } - - selection(_selection?: AnnotationContext['selection']): Promise { - return Promise.resolve(); - } } diff --git a/src/annotations/lineAnnotationController.ts b/src/annotations/lineAnnotationController.ts index 97ad86ef8961c..44b6ffb1b63fd 100644 --- a/src/annotations/lineAnnotationController.ts +++ b/src/annotations/lineAnnotationController.ts @@ -1,38 +1,31 @@ -import type { - CancellationToken, - ConfigurationChangeEvent, - DecorationOptions, - TextEditor, - TextEditorDecorationType, -} from 'vscode'; +import type { ConfigurationChangeEvent, DecorationOptions, TextEditor, TextEditorDecorationType } from 'vscode'; import { CancellationTokenSource, DecorationRangeBehavior, Disposable, Range, window } from 'vscode'; -import { configuration } from '../configuration'; -import { GlyphChars } from '../constants'; +import { GlyphChars, Schemes } from '../constants'; import type { Container } from '../container'; import { CommitFormatter } from '../git/formatters/commitFormatter'; -import type { GitCommit } from '../git/models/commit'; import type { PullRequest } from '../git/models/pullRequest'; -import { RichRemoteProviders } from '../git/remotes/remoteProviderConnections'; -import { Logger } from '../logger'; -import type { LogScope } from '../logScope'; -import { getLogScope } from '../logScope'; +import { detailsMessage } from '../hovers/hovers'; import { debug, log } from '../system/decorators/log'; import { once } from '../system/event'; -import { count, every, filter } from '../system/iterable'; -import type { PromiseCancelledErrorWithId } from '../system/promise'; -import { PromiseCancelledError, raceAll } from '../system/promise'; -import { isTextEditor } from '../system/utils'; -import type { LinesChangeEvent } from '../trackers/gitLineTracker'; +import { debounce } from '../system/function'; +import { Logger } from '../system/logger'; +import { getLogScope, setLogScopeExit } from '../system/logger.scope'; +import type { MaybePausedResult } from '../system/promise'; +import { getSettledValue, pauseOnCancelOrTimeoutMap } from '../system/promise'; +import { configuration } from '../system/vscode/configuration'; +import { isTrackableTextEditor } from '../system/vscode/utils'; +import type { LinesChangeEvent, LineState } from '../trackers/lineTracker'; import { getInlineDecoration } from './annotations'; +import type { BlameFontOptions } from './gutterBlameAnnotationProvider'; const annotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType({ after: { margin: '0 0 0 3em', textDecoration: 'none', }, - rangeBehavior: DecorationRangeBehavior.ClosedOpen, + rangeBehavior: DecorationRangeBehavior.OpenOpen, }); -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) export class LineAnnotationController implements Disposable { private _cancellation: CancellationTokenSource | undefined; @@ -45,7 +38,9 @@ export class LineAnnotationController implements Disposable { once(container.onReady)(this.onReady, this), configuration.onDidChange(this.onConfigurationChanged, this), container.fileAnnotations.onDidToggleAnnotations(this.onFileAnnotationsToggled, this), - RichRemoteProviders.onDidChangeConnectionState(() => void this.refresh(window.activeTextEditor)), + container.integrations.onDidChangeConnectionState( + debounce(() => void this.refresh(window.activeTextEditor), 250), + ), ); } @@ -155,46 +150,40 @@ export class LineAnnotationController implements Disposable { editor.setDecorations(annotationDecoration, []); } - private async getPullRequests( + private getPullRequestsForLines( repoPath: string, - lines: [number, GitCommit][], - { timeout }: { timeout?: number } = {}, - ) { - if (lines.length === 0) return undefined; + lines: Map, + ): Map> { + const prs = new Map>(); + if (lines.size === 0) return prs; - const remote = await this.container.git.getBestRemoteWithRichProvider(repoPath); - if (remote?.provider == null) return undefined; + const remotePromise = this.container.git.getBestRemoteWithIntegration(repoPath); - const refs = new Set(); + for (const [, state] of lines) { + if (state.commit.isUncommitted) continue; - for (const [, commit] of lines) { - refs.add(commit.ref); + let pr = prs.get(state.commit.ref); + if (pr == null) { + pr = remotePromise.then(remote => state.commit.getAssociatedPullRequest(remote)); + prs.set(state.commit.ref, pr); + } } - if (refs.size === 0) return undefined; - - const { provider } = remote; - const prs = await raceAll( - refs.values(), - ref => this.container.git.getPullRequestForCommit(ref, provider), - timeout, - ); - if (prs.size === 0 || every(prs.values(), pr => pr == null)) return undefined; - return prs; } - @debug({ args: false }) - private async refresh(editor: TextEditor | undefined, options?: { prs?: Map }) { + @debug() + private async refresh(editor: TextEditor | undefined) { if (editor == null && this._editor == null) return; const scope = getLogScope(); const selections = this.container.lineTracker.selections; - if (editor == null || selections == null || !isTextEditor(editor)) { - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} Skipped because there is no valid editor or no valid selections`; - } + if (editor == null || selections == null || !isTrackableTextEditor(editor)) { + setLogScopeExit( + scope, + ` ${GlyphChars.Dot} Skipped because there is no valid editor or no valid selections`, + ); this.clear(this._editor); return; @@ -209,23 +198,21 @@ export class LineAnnotationController implements Disposable { const cfg = configuration.get('currentLine'); if (this.suspended) { - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} Skipped because the controller is suspended`; - } + setLogScopeExit(scope, ` ${GlyphChars.Dot} Skipped because the controller is suspended`); this.clear(editor); return; } - const trackedDocument = await this.container.tracker.getOrAdd(editor.document); - if (!trackedDocument.isBlameable && this.suspended) { - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} Skipped because the ${ - this.suspended - ? 'controller is suspended' - : `document(${trackedDocument.uri.toString(true)}) is not blameable` - }`; - } + const trackedDocument = await this.container.documentTracker.getOrAdd(editor.document); + const status = await trackedDocument?.getStatus(); + if (!status?.blameable && this.suspended) { + setLogScopeExit( + scope, + ` ${GlyphChars.Dot} Skipped because the ${ + this.suspended ? 'controller is suspended' : 'document is not blameable' + }`, + ); this.clear(editor); return; @@ -233,40 +220,58 @@ export class LineAnnotationController implements Disposable { // Make sure the editor hasn't died since the await above and that we are still on the same line(s) if (editor.document == null || !this.container.lineTracker.includes(selections)) { - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} Skipped because the ${ + setLogScopeExit( + scope, + ` ${GlyphChars.Dot} Skipped because the ${ editor.document == null ? 'editor is gone' - : `selection(s)=${selections - .map(s => `[${s.anchor}-${s.active}]`) - .join()} are no longer current` - }`; - } + : `selection=${selections.map(s => `[${s.anchor}-${s.active}]`).join()} are no longer current` + }`, + ); return; } - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} selection(s)=${selections - .map(s => `[${s.anchor}-${s.active}]`) - .join()}`; - } + setLogScopeExit( + scope, + ` ${GlyphChars.Dot} selection=${selections.map(s => `[${s.anchor}-${s.active}]`).join()}`, + ); + + let uncommittedOnly = true; - const commitLines = new Map(); + const commitPromises = new Map>(); + const lines = new Map(); for (const selection of selections) { const state = this.container.lineTracker.getState(selection.active); if (state?.commit == null) { Logger.debug(scope, `Line ${selection.active} returned no commit`); continue; } - commitLines.set(selection.active, state.commit); + + if (state.commit.message == null && !commitPromises.has(state.commit.ref)) { + commitPromises.set(state.commit.ref, state.commit.ensureFullDetails()); + } + lines.set(selection.active, state); + if (!state.commit.isUncommitted) { + uncommittedOnly = false; + } } const repoPath = trackedDocument.uri.repoPath; - // TODO: Make this configurable? - const timeout = 100; - const [getBranchAndTagTips, prs] = await Promise.all([ - CommitFormatter.has(cfg.format, 'tips') ? this.container.git.getBranchesAndTagsTipsFn(repoPath) : undefined, + let hoverOptions: RequireSome[4], 'autolinks' | 'pullRequests'> | undefined; + // Live Share (vsls schemes) don't support `languages.registerHoverProvider` so we'll need to add them to the decoration directly + if (editor.document.uri.scheme === Schemes.Vsls || editor.document.uri.scheme === Schemes.VslsScc) { + const hoverCfg = configuration.get('hovers'); + hoverOptions = { + autolinks: hoverCfg.autolinks.enabled, + dateFormat: configuration.get('defaultDateFormat'), + format: hoverCfg.detailsMarkdownFormat, + pullRequests: hoverCfg.pullRequests.enabled, + }; + } + + const getPullRequests = + !uncommittedOnly && repoPath != null && cfg.pullRequests.enabled && CommitFormatter.has( @@ -276,44 +281,117 @@ export class LineAnnotationController implements Disposable { 'pullRequestAgoOrDate', 'pullRequestDate', 'pullRequestState', - ) - ? options?.prs ?? - this.getPullRequests(repoPath, [...filter(commitLines, ([, commit]) => !commit.isUncommitted)], { + ); + + this._cancellation?.cancel(); + this._cancellation = new CancellationTokenSource(); + const cancellation = this._cancellation.token; + + const getBranchAndTagTipsPromise = CommitFormatter.has(cfg.format, 'tips') + ? this.container.git.getBranchesAndTagsTipsFn(repoPath) + : undefined; + + async function updateDecorations( + container: Container, + editor: TextEditor, + getBranchAndTagTips: Awaited | undefined, + prs: Map> | undefined, + timeout?: number, + ) { + const fontOptions: BlameFontOptions = { + family: cfg.fontFamily, + size: cfg.fontSize, + style: cfg.fontStyle, + weight: cfg.fontWeight, + }; + + const decorations = []; + + for (const [l, state] of lines) { + const commit = state.commit; + if (commit == null || (commit.isUncommitted && cfg.uncommittedChangesFormat === '')) continue; + + const pr = prs?.get(commit.ref); + + const decoration = getInlineDecoration( + commit, + // await GitUri.fromUri(editor.document.uri), + // l, + commit.isUncommitted ? cfg.uncommittedChangesFormat ?? cfg.format : cfg.format, + { + dateFormat: cfg.dateFormat === null ? configuration.get('defaultDateFormat') : cfg.dateFormat, + getBranchAndTagTips: getBranchAndTagTips, + pullRequest: pr?.value, + pullRequestPendingMessage: `PR ${GlyphChars.Ellipsis}`, + }, + fontOptions, + cfg.scrollable, + ) as DecorationOptions; + decoration.range = editor.document.validateRange(new Range(l, maxSmallIntegerV8, l, maxSmallIntegerV8)); + + if (hoverOptions != null) { + decoration.hoverMessage = await detailsMessage(container, commit, trackedDocument.uri, l, { + ...hoverOptions, + pullRequest: pr?.value, timeout: timeout, - }) - : undefined, - ]); + }); + } - if (prs != null) { - this._cancellation?.cancel(); - this._cancellation = new CancellationTokenSource(); - void this.waitForAnyPendingPullRequests(editor, prs, this._cancellation.token, timeout, scope); - } + decorations.push(decoration); + } - const decorations = []; - - for (const [l, commit] of commitLines) { - if (commit.isUncommitted && cfg.uncommittedChangesFormat === '') continue; - - const decoration = getInlineDecoration( - commit, - // await GitUri.fromUri(editor.document.uri), - // l, - commit.isUncommitted ? cfg.uncommittedChangesFormat ?? cfg.format : cfg.format, - { - dateFormat: cfg.dateFormat === null ? configuration.get('defaultDateFormat') : cfg.dateFormat, - getBranchAndTagTips: getBranchAndTagTips, - pullRequestOrRemote: prs?.get(commit.ref), - pullRequestPendingMessage: `PR ${GlyphChars.Ellipsis}`, - }, - cfg.scrollable, - ) as DecorationOptions; - decoration.range = editor.document.validateRange(new Range(l, maxSmallIntegerV8, l, maxSmallIntegerV8)); - - decorations.push(decoration); + editor.setDecorations(annotationDecoration, decorations); } - editor.setDecorations(annotationDecoration, decorations); + // TODO: Make this configurable? + const timeout = 100; + const prsResult = getPullRequests + ? await pauseOnCancelOrTimeoutMap( + this.getPullRequestsForLines(repoPath, lines), + true, + cancellation, + timeout, + async result => { + if ( + result.reason !== 'timedout' || + cancellation.isCancellationRequested || + editor !== this._editor + ) { + return; + } + + // If the PRs are taking too long, refresh the decorations once they complete + + Logger.debug( + scope, + `${GlyphChars.Dot} pull request queries took too long (over ${timeout} ms)`, + ); + + const [getBranchAndTagTipsResult, prsResult] = await Promise.allSettled([ + getBranchAndTagTipsPromise, + result.value, + ]); + + if (cancellation.isCancellationRequested || editor !== this._editor) return; + + const prs = getSettledValue(prsResult); + const getBranchAndTagTips = getSettledValue(getBranchAndTagTipsResult); + + Logger.debug(scope, `${GlyphChars.Dot} pull request queries completed; updating...`); + + void updateDecorations(this.container, editor, getBranchAndTagTips, prs); + }, + ) + : undefined; + + const [getBranchAndTagTipsResult] = await Promise.allSettled([ + getBranchAndTagTipsPromise, + ...commitPromises.values(), + ]); + + if (cancellation.isCancellationRequested) return; + + await updateDecorations(this.container, editor, getSettledValue(getBranchAndTagTipsResult), prsResult, 100); } private setLineTracker(enabled: boolean) { @@ -330,32 +408,4 @@ export class LineAnnotationController implements Disposable { this.container.lineTracker.unsubscribe(this); } - - private async waitForAnyPendingPullRequests( - editor: TextEditor, - prs: Map< - string, - PullRequest | PromiseCancelledErrorWithId> | undefined - >, - cancellationToken: CancellationToken, - timeout: number, - scope: LogScope | undefined, - ) { - // If there are any PRs that timed out, refresh the annotation(s) once they complete - const prCount = count(prs.values(), pr => pr instanceof PromiseCancelledError); - if (cancellationToken.isCancellationRequested || prCount === 0) return; - - Logger.debug(scope, `${GlyphChars.Dot} ${prCount} pull request queries took too long (over ${timeout} ms)`); - - const resolved = new Map(); - for (const [key, value] of prs) { - resolved.set(key, value instanceof PromiseCancelledError ? await value.promise : value); - } - - if (cancellationToken.isCancellationRequested || editor !== this._editor) return; - - Logger.debug(scope, `${GlyphChars.Dot} ${prCount} pull request queries completed; refreshing...`); - - void this.refresh(editor, { prs: resolved }); - } } diff --git a/src/api/actionRunners.ts b/src/api/actionRunners.ts index a092afa57d330..351d333ca0b96 100644 --- a/src/api/actionRunners.ts +++ b/src/api/actionRunners.ts @@ -1,17 +1,16 @@ import type { Event, QuickPickItem } from 'vscode'; import { Disposable, EventEmitter, window } from 'vscode'; -import type { Config } from '../configuration'; -import { configuration } from '../configuration'; -import { Commands, ContextKeys } from '../constants'; +import type { Config } from '../config'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { setContext } from '../context'; -import { registerCommand } from '../system/command'; +import { getScopedCounter } from '../system/counter'; import { sortCompare } from '../system/string'; -import { getQuickPickIgnoreFocusOut } from '../system/utils'; +import { registerCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; +import { getQuickPickIgnoreFocusOut } from '../system/vscode/utils'; import type { Action, ActionContext, ActionRunner } from './gitlens'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) - type Actions = ActionContext['type']; const actions: Actions[] = ['createPullRequest', 'openPullRequest', 'hover.commands']; @@ -28,7 +27,10 @@ export const builtInActionRunnerName = 'Built In'; class ActionRunnerQuickPickItem implements QuickPickItem { private readonly _label: string; - constructor(public readonly runner: RegisteredActionRunner, context: ActionContext) { + constructor( + public readonly runner: RegisteredActionRunner, + context: ActionContext, + ) { this._label = typeof runner.label === 'string' ? runner.label : runner.label(context); } @@ -53,16 +55,7 @@ class NoActionRunnersQuickPickItem implements QuickPickItem { } } -let runnerId = 0; -function nextRunnerId() { - if (runnerId === maxSmallIntegerV8) { - runnerId = 1; - } else { - runnerId++; - } - - return runnerId; -} +const runnerIdGenerator = getScopedCounter(); class RegisteredActionRunner implements ActionRunner, Disposable { readonly id: number; @@ -72,7 +65,7 @@ class RegisteredActionRunner implements private readonly runner: ActionRunner, private readonly unregister: () => void, ) { - this.id = nextRunnerId(); + this.id = runnerIdGenerator.next(); } dispose() { @@ -195,13 +188,13 @@ export class ActionRunners implements Disposable { const runnersMap = this._actionRunners; const registeredRunner = new RegisteredActionRunner(type, runner, function (this: RegisteredActionRunner) { - if (runners!.length === 1) { + if (runners.length === 1) { runnersMap.delete(action); onChanged(action); } else { - const index = runners!.indexOf(this); + const index = runners.indexOf(this); if (index !== -1) { - runners!.splice(index, 1); + runners.splice(index, 1); } } }); @@ -326,7 +319,7 @@ export class ActionRunners implements Disposable { } private async _updateContextKeys(action: Actions) { - await setContext(`${ContextKeys.ActionPrefix}${action}`, this.count(action)); + await setContext(`gitlens:action:${action}`, this.count(action)); } private async _updateAllContextKeys() { diff --git a/src/api/api.ts b/src/api/api.ts index 01632b9e3cd33..b2ea2c6e18dfa 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -32,7 +32,8 @@ export class Api implements GitLensApi { } export function preview() { - return (target: any, key: string, descriptor: PropertyDescriptor) => { + return (_target: any, _key: string, descriptor: PropertyDescriptor) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type let fn: Function | undefined; if (typeof descriptor.value === 'function') { fn = descriptor.value; @@ -43,7 +44,7 @@ export function preview() { descriptor.value = function (this: any, ...args: any[]) { // eslint-disable-next-line @typescript-eslint/no-unsafe-return - if (Container.instance.prereleaseOrDebugging) return fn!.apply(this, args); + if (Container.instance.prereleaseOrDebugging) return fn.apply(this, args); console.error('GitLens preview APIs are only available in the pre-release edition'); return emptyDisposable; diff --git a/src/avatars.ts b/src/avatars.ts index 5db485d322270..fa1cf6e259d98 100644 --- a/src/avatars.ts +++ b/src/avatars.ts @@ -1,18 +1,21 @@ -import { EventEmitter, Uri } from 'vscode'; import { md5 } from '@env/crypto'; -import { GravatarDefaultStyle } from './config'; -import { configuration } from './configuration'; -import { ContextKeys } from './constants'; +import { EventEmitter, Uri } from 'vscode'; +import type { GravatarDefaultStyle } from './config'; +import type { StoredAvatar } from './constants.storage'; import { Container } from './container'; -import { getContext } from './context'; +import type { CommitAuthor } from './git/models/author'; import { getGitHubNoReplyAddressParts } from './git/remotes/github'; -import type { StoredAvatar } from './storage'; import { debounce } from './system/function'; import { filterMap } from './system/iterable'; import { base64, equalsIgnoreCase } from './system/string'; +import { configuration } from './system/vscode/configuration'; +import { getContext } from './system/vscode/context'; import type { ContactPresenceStatus } from './vsls/vsls'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) + +let avatarCache: Map | undefined; +const avatarQueue = new Map>(); const _onDidFetchAvatar = new EventEmitter<{ email: string }>(); _onDidFetchAvatar.event( @@ -37,9 +40,7 @@ _onDidFetchAvatar.event( }, 1000), ); -export namespace Avatars { - export const onDidFetch = _onDidFetchAvatar.event; -} +export const onDidFetchAvatar = _onDidFetchAvatar.event; interface Avatar { uri?: Uri; @@ -48,9 +49,6 @@ interface Avatar { retries: number; } -let avatarCache: Map | undefined; -const avatarQueue = new Map>(); - const missingGravatarHash = '00000000000000000000000000000000'; const presenceCache = new Map(); @@ -128,7 +126,13 @@ function getAvatarUriCore( const avatar = createOrUpdateAvatar(key, email, size, hash, options?.defaultStyle); if (avatar.uri != null) return avatar.uri; - if (!options?.cached && repoPathOrCommit != null && getContext(ContextKeys.HasConnectedRemotes)) { + if ( + !options?.cached && + repoPathOrCommit != null && + getContext('gitlens:repos:withHostingIntegrationsConnected')?.includes( + typeof repoPathOrCommit === 'string' ? repoPathOrCommit : repoPathOrCommit.repoPath, + ) + ) { let query = avatarQueue.get(key); if (query == null && hasAvatarExpired(avatar)) { query = getAvatarUriFromRemoteProvider(avatar, key, email, repoPathOrCommit, { size: size }).then( @@ -209,7 +213,7 @@ function getAvatarUriFromGitHubNoReplyAddress(email: string, size: number = 16): async function getAvatarUriFromRemoteProvider( avatar: Avatar, - key: string, + _key: string, email: string, repoPathOrCommit: string | { ref: string; repoPath: string }, { size = 16 }: { size?: number } = {}, @@ -217,14 +221,20 @@ async function getAvatarUriFromRemoteProvider( ensureAvatarCache(avatarCache); try { - let account; + let account: CommitAuthor | undefined; // if (typeof repoPathOrCommit === 'string') { // const remote = await Container.instance.git.getRichRemoteProvider(repoPathOrCommit); // account = await remote?.provider.getAccountForEmail(email, { avatarSize: size }); // } else { if (typeof repoPathOrCommit !== 'string') { - const remote = await Container.instance.git.getBestRemoteWithRichProvider(repoPathOrCommit.repoPath); - account = await remote?.provider.getAccountForCommit(repoPathOrCommit.ref, { avatarSize: size }); + const remote = await Container.instance.git.getBestRemoteWithIntegration(repoPathOrCommit.repoPath); + if (remote?.hasIntegration()) { + account = await ( + await remote.getIntegration() + )?.getAccountForCommit(remote.provider.repoDesc, repoPathOrCommit.ref, { + avatarSize: size, + }); + } } if (account?.avatarUrl == null) { @@ -307,7 +317,7 @@ export function resetAvatarCache(reset: 'all' | 'failed' | 'fallback') { let defaultGravatarsStyle: GravatarDefaultStyle | undefined = undefined; function getDefaultGravatarStyle() { if (defaultGravatarsStyle == null) { - defaultGravatarsStyle = configuration.get('defaultGravatarsStyle', undefined, GravatarDefaultStyle.Robot); + defaultGravatarsStyle = configuration.get('defaultGravatarsStyle', undefined, 'robohash'); } return defaultGravatarsStyle; } diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000000000..29ec62d3e40e7 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,299 @@ +// import type { EnrichedAutolink } from './annotations/autolinks'; +import type { Disposable } from './api/gitlens'; +import type { Container } from './container'; +import type { Account } from './git/models/author'; +import type { DefaultBranch } from './git/models/defaultBranch'; +import type { IssueOrPullRequest } from './git/models/issue'; +import type { PullRequest } from './git/models/pullRequest'; +import type { RepositoryMetadata } from './git/models/repositoryMetadata'; +import type { HostingIntegration, IntegrationBase, ResourceDescriptor } from './plus/integrations/integration'; +import { isPromise } from './system/promise'; + +type Caches = { + defaultBranch: { key: `repo:${string}`; value: DefaultBranch }; + // enrichedAutolinksBySha: { key: `sha:${string}:${string}`; value: Map }; + issuesOrPrsById: { key: `id:${string}:${string}`; value: IssueOrPullRequest }; + issuesOrPrsByIdAndRepo: { key: `id:${string}:${string}:${string}`; value: IssueOrPullRequest }; + prByBranch: { key: `branch:${string}:${string}`; value: PullRequest }; + prsById: { key: `id:${string}:${string}`; value: PullRequest }; + prsBySha: { key: `sha:${string}:${string}`; value: PullRequest }; + repoMetadata: { key: `repo:${string}`; value: RepositoryMetadata }; + currentAccount: { key: `id:${string}`; value: Account }; +}; + +type Cache = keyof Caches; +type CacheKey = Caches[T]['key']; +type CacheValue = Caches[T]['value']; +type CacheResult = Promise | T | undefined; + +type Cacheable = () => { value: CacheResult; expiresAt?: number }; +type Cached = + | { + value: T | undefined; + cachedAt: number; + expiresAt?: number; + etag?: string; + } + | { + value: Promise; + cachedAt: number; + expiresAt?: never; // Don't set an expiration on promises as they will resolve to a value with the desired expiration + etag?: string; + }; + +export class CacheProvider implements Disposable { + private readonly _cache = new Map<`${Cache}:${CacheKey}`, Cached>>>(); + + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor(_container: Container) {} + + dispose() { + this._cache.clear(); + } + + delete(cache: T, key: CacheKey) { + this._cache.delete(`${cache}:${key}`); + } + + get( + cache: T, + key: CacheKey, + etag: string | undefined, + cacheable: Cacheable>, + options?: { expiryOverride?: boolean | number }, + ): CacheResult> { + const item = this._cache.get(`${cache}:${key}`); + + // Allow the caller to override the expiry + let expiry; + if (item != null) { + if (typeof options?.expiryOverride === 'number' && options.expiryOverride > 0) { + expiry = item.cachedAt + options.expiryOverride; + } else { + expiry = item.expiresAt; + } + } + + if ( + item == null || + options?.expiryOverride === true || + (expiry != null && expiry > 0 && expiry < Date.now()) || + (item.etag != null && item.etag !== etag) + ) { + const { value, expiresAt } = cacheable(); + return this.set(cache, key, value, etag, expiresAt)?.value as CacheResult>; + } + + return item.value as CacheResult>; + } + + getCurrentAccount( + integration: IntegrationBase, + cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, + ): CacheResult { + const { key, etag } = getIntegrationKeyAndEtag(integration); + return this.get('currentAccount', `id:${key}`, etag, cacheable, options); + } + + // getEnrichedAutolinks( + // sha: string, + // remoteOrProvider: Integration, + // cacheable: Cacheable>, + // options?: { force?: boolean }, + // ): CacheResult> { + // const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); + // return this.get('enrichedAutolinksBySha', `sha:${sha}:${key}`, etag, cacheable, options); + // } + + getIssueOrPullRequest( + id: string, + resource: ResourceDescriptor, + integration: IntegrationBase | undefined, + cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, + ): CacheResult { + const { key, etag } = getResourceKeyAndEtag(resource, integration); + + if (resource == null) { + return this.get('issuesOrPrsById', `id:${id}:${key}`, etag, cacheable, options); + } + return this.get( + 'issuesOrPrsByIdAndRepo', + `id:${id}:${key}:${JSON.stringify(resource)}}`, + etag, + cacheable, + options, + ); + } + + getPullRequest( + id: string, + resource: ResourceDescriptor, + integration: IntegrationBase | undefined, + cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, + ): CacheResult { + const { key, etag } = getResourceKeyAndEtag(resource, integration); + + if (resource == null) { + return this.get('prsById', `id:${id}:${key}`, etag, cacheable, options); + } + return this.get('prsById', `id:${id}:${key}:${JSON.stringify(resource)}}`, etag, cacheable, options); + } + + getPullRequestForBranch( + branch: string, + repo: ResourceDescriptor, + integration: HostingIntegration | undefined, + cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, + ): CacheResult { + const { key, etag } = getResourceKeyAndEtag(repo, integration); + // Wrap the cacheable so we can also add the result to the issuesOrPrsById cache + return this.get( + 'prByBranch', + `branch:${branch}:${key}`, + etag, + this.wrapPullRequestCacheable(cacheable, key, etag), + options, + ); + } + + getPullRequestForSha( + sha: string, + repo: ResourceDescriptor, + integration: HostingIntegration | undefined, + cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, + ): CacheResult { + const { key, etag } = getResourceKeyAndEtag(repo, integration); + // Wrap the cacheable so we can also add the result to the issuesOrPrsById cache + return this.get( + 'prsBySha', + `sha:${sha}:${key}`, + etag, + this.wrapPullRequestCacheable(cacheable, key, etag), + options, + ); + } + + getRepositoryDefaultBranch( + repo: ResourceDescriptor, + integration: HostingIntegration | undefined, + cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, + ): CacheResult { + const { key, etag } = getResourceKeyAndEtag(repo, integration); + return this.get('defaultBranch', `repo:${key}`, etag, cacheable, options); + } + + getRepositoryMetadata( + repo: ResourceDescriptor, + integration: HostingIntegration | undefined, + cacheable: Cacheable, + options?: { expiryOverride?: boolean | number }, + ): CacheResult { + const { key, etag } = getResourceKeyAndEtag(repo, integration); + return this.get('repoMetadata', `repo:${key}`, etag, cacheable, options); + } + + private set( + cache: T, + key: CacheKey, + value: CacheResult>, + etag: string | undefined, + expiresAt?: number, + ): Cached>> { + let item: Cached>>; + if (isPromise(value)) { + void value.then( + v => { + this.set(cache, key, v, etag, expiresAt); + }, + () => { + this.delete(cache, key); + }, + ); + + item = { value: value, etag: etag, cachedAt: Date.now() }; + } else { + item = { + value: value, + etag: etag, + cachedAt: Date.now(), + expiresAt: expiresAt ?? getExpiresAt(cache, value), + }; + } + + this._cache.set(`${cache}:${key}`, item); + return item; + } + + private wrapPullRequestCacheable( + cacheable: Cacheable, + key: string, + etag: string | undefined, + ): Cacheable { + return () => { + const item = cacheable(); + if (isPromise(item.value)) { + void item.value.then(v => { + if (v != null) { + this.set('issuesOrPrsById', `id:${v.id}:${key}`, v, etag); + } + }); + } + + return item; + }; + } +} + +function getExpiresAt(cache: T, value: CacheValue | undefined): number { + const now = Date.now(); + const defaultExpiresAt = now + 60 * 60 * 1000; // 1 hour + + switch (cache) { + case 'defaultBranch': + case 'repoMetadata': + case 'currentAccount': + return 0; // Never expires + case 'issuesOrPrsById': + case 'issuesOrPrsByIdAndRepo': { + if (value == null) return 0; // Never expires + + // Open issues expire after 1 hour, but closed issues expire after 12 hours unless recently updated and then expire in 1 hour + + const issueOrPr = value as CacheValue<'issuesOrPrsById'>; + if (!issueOrPr.closed) return defaultExpiresAt; + + const updatedAgo = now - (issueOrPr.closedDate ?? issueOrPr.updatedDate).getTime(); + return now + (updatedAgo > 14 * 24 * 60 * 60 * 1000 ? 12 : 1) * 60 * 60 * 1000; + } + case 'prByBranch': + case 'prsById': + case 'prsBySha': { + if (value == null) return cache === 'prByBranch' ? defaultExpiresAt : 0 /* Never expires */; + + // Open prs expire after 1 hour, but closed/merge prs expire after 12 hours unless recently updated and then expire in 1 hour + + const pr = value as CacheValue<'prByBranch' | 'prsById' | 'prsBySha'>; + if (pr.state === 'opened') return defaultExpiresAt; + + const updatedAgo = now - (pr.closedDate ?? pr.mergedDate ?? pr.updatedDate).getTime(); + return now + (updatedAgo > 14 * 24 * 60 * 60 * 1000 ? 12 : 1) * 60 * 60 * 1000; + } + // case 'enrichedAutolinksBySha': + default: + return value == null ? 0 /* Never expires */ : defaultExpiresAt; + } +} + +function getResourceKeyAndEtag(resource: ResourceDescriptor, integration?: HostingIntegration | IntegrationBase) { + return { key: resource.key, etag: `${resource.key}:${integration?.maybeConnected ?? false}` }; +} + +function getIntegrationKeyAndEtag(integration: IntegrationBase) { + return { key: integration.id, etag: `${integration.id}:${integration.maybeConnected ?? false}` }; +} diff --git a/src/codelens/codeLensController.ts b/src/codelens/codeLensController.ts index 495bacbf4d89b..402ab55de04da 100644 --- a/src/codelens/codeLensController.ts +++ b/src/codelens/codeLensController.ts @@ -1,17 +1,14 @@ import type { ConfigurationChangeEvent } from 'vscode'; import { Disposable, languages } from 'vscode'; -import { configuration } from '../configuration'; -import { ContextKeys } from '../constants'; import type { Container } from '../container'; -import { setContext } from '../context'; -import { Logger } from '../logger'; +import { log } from '../system/decorators/log'; import { once } from '../system/event'; -import type { - DocumentBlameStateChangeEvent, - DocumentDirtyIdleTriggerEvent, - GitDocumentState, -} from '../trackers/gitDocumentTracker'; -import { GitCodeLensProvider } from './codeLensProvider'; +import { getLoggableName, Logger } from '../system/logger'; +import { getLogScope, setLogScopeExit, startLogScope } from '../system/logger.scope'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; +import type { DocumentBlameStateChangeEvent, DocumentDirtyIdleTriggerEvent } from '../trackers/documentTracker'; +import type { GitCodeLensProvider } from './codeLensProvider'; export class GitCodeLensController implements Disposable { private _canToggle: boolean = false; @@ -36,46 +33,59 @@ export class GitCodeLensController implements Disposable { } private onConfigurationChanged(e?: ConfigurationChangeEvent) { + using scope = startLogScope(`${getLoggableName(this)}.onConfigurationChanged`, false); + if (configuration.changed(e, ['codeLens', 'defaultDateFormat', 'defaultDateSource', 'defaultDateStyle'])) { if (e != null) { - Logger.log('CodeLens config changed; resetting CodeLens provider'); + Logger.log(scope, 'resetting CodeLens provider'); } const cfg = configuration.get('codeLens'); if (cfg.enabled && (cfg.recentChange.enabled || cfg.authors.enabled)) { - this.ensureProvider(); + void this.ensureProvider(); } else { this._providerDisposable?.dispose(); this._provider = undefined; } this._canToggle = cfg.recentChange.enabled || cfg.authors.enabled; - void setContext(ContextKeys.DisabledToggleCodeLens, !this._canToggle); + void setContext('gitlens:disabledToggleCodeLens', !this._canToggle); } } - private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { + private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { // Only reset if we have saved, since the CodeLens won't naturally be re-rendered if (this._provider == null || !e.blameable) return; - Logger.log('Blame state changed; resetting CodeLens provider'); - this._provider.reset('saved'); + using scope = startLogScope(`${getLoggableName(this)}.onBlameStateChanged`, false); + + Logger.log(scope, 'resetting CodeLens provider'); + this._provider.reset(); } - private onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent) { - if (this._provider == null || !e.document.isBlameable) return; + private async onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent) { + if (this._provider == null) return; - const maxLines = configuration.get('advanced.blame.sizeThresholdAfterEdit'); - if (maxLines > 0 && e.document.lineCount > maxLines) return; + using scope = startLogScope(`${getLoggableName(this)}.onDirtyIdleTriggered`, false); - Logger.log('Dirty idle triggered; resetting CodeLens provider'); - this._provider.reset('idle'); + const status = await e.document.getStatus(); + if (!status.blameable) return; + + Logger.log(scope, 'resetting CodeLens provider'); + this._provider.reset(); } + @log() toggleCodeLens() { - if (!this._canToggle) return; + const scope = getLogScope(); + + if (!this._canToggle) { + if (scope != null) { + setLogScopeExit(scope, ' \u2022 skipped, disabled'); + } + return; + } - Logger.log('toggleCodeLens()'); if (this._provider != null) { this._providerDisposable?.dispose(); this._provider = undefined; @@ -83,10 +93,10 @@ export class GitCodeLensController implements Disposable { return; } - this.ensureProvider(); + void this.ensureProvider(); } - private ensureProvider() { + private async ensureProvider() { if (this._provider != null) { this._provider.reset(); @@ -95,11 +105,13 @@ export class GitCodeLensController implements Disposable { this._providerDisposable?.dispose(); + const { GitCodeLensProvider } = await import(/* webpackChunkName: "codelens" */ './codeLensProvider'); + this._provider = new GitCodeLensProvider(this.container); this._providerDisposable = Disposable.from( languages.registerCodeLensProvider(GitCodeLensProvider.selector, this._provider), - this.container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this), - this.container.tracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this), + this.container.documentTracker.onDidChangeBlameState(this.onBlameStateChanged, this), + this.container.documentTracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this), ); } } diff --git a/src/codelens/codeLensProvider.ts b/src/codelens/codeLensProvider.ts index 1bc28a04dc4f0..d12313a46df0b 100644 --- a/src/codelens/codeLensProvider.ts +++ b/src/codelens/codeLensProvider.ts @@ -9,30 +9,32 @@ import type { Uri, } from 'vscode'; import { CodeLens, EventEmitter, Location, Position, Range, SymbolInformation, SymbolKind } from 'vscode'; -import type { - DiffWithPreviousCommandArgs, - OpenOnRemoteCommandArgs, - ShowCommitsInViewCommandArgs, - ShowQuickCommitCommandArgs, - ShowQuickCommitFileCommandArgs, - ShowQuickFileHistoryCommandArgs, - ToggleFileChangesAnnotationCommandArgs, -} from '../commands'; -import type { CodeLensConfig, CodeLensLanguageScope } from '../configuration'; -import { CodeLensCommand, CodeLensScopes, configuration, FileAnnotationType } from '../configuration'; -import { Commands, CoreCommands, Schemes } from '../constants'; +import type { DiffWithPreviousCommandArgs } from '../commands/diffWithPrevious'; +import type { OpenOnRemoteCommandArgs } from '../commands/openOnRemote'; +import type { ShowCommitsInViewCommandArgs } from '../commands/showCommitsInView'; +import type { ShowQuickCommitCommandArgs } from '../commands/showQuickCommit'; +import type { ShowQuickCommitFileCommandArgs } from '../commands/showQuickCommitFile'; +import type { ShowQuickFileHistoryCommandArgs } from '../commands/showQuickFileHistory'; +import type { ToggleFileChangesAnnotationCommandArgs } from '../commands/toggleFileAnnotations'; +import type { CodeLensConfig, CodeLensLanguageScope } from '../config'; +import { CodeLensCommand } from '../config'; +import { trackableSchemes } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import type { GitUri } from '../git/gitUri'; import type { GitBlame, GitBlameLines } from '../git/models/blame'; import type { GitCommit } from '../git/models/commit'; import { RemoteResourceType } from '../git/models/remoteResource'; -import { Logger } from '../logger'; -import { asCommand, executeCoreCommand } from '../system/command'; import { is, once } from '../system/function'; import { filterMap, find, first, join, map } from '../system/iterable'; -import { isVirtualUri } from '../system/utils'; - -export class GitRecentChangeCodeLens extends CodeLens { +import { getLoggableName, Logger } from '../system/logger'; +import { startLogScope } from '../system/logger.scope'; +import { pluralize } from '../system/string'; +import { asCommand, executeCoreCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { isVirtualUri } from '../system/vscode/utils'; + +class GitRecentChangeCodeLens extends CodeLens { constructor( public readonly languageId: string, public readonly symbol: DocumentSymbol | SymbolInformation, @@ -53,7 +55,7 @@ export class GitRecentChangeCodeLens extends CodeLens { } } -export class GitAuthorsCodeLens extends CodeLens { +class GitAuthorsCodeLens extends CodeLens { constructor( public readonly languageId: string, public readonly symbol: DocumentSymbol | SymbolInformation, @@ -73,16 +75,7 @@ export class GitAuthorsCodeLens extends CodeLens { } export class GitCodeLensProvider implements CodeLensProvider { - static selector: DocumentSelector = [ - { scheme: Schemes.File }, - { scheme: Schemes.Git }, - { scheme: Schemes.GitLens }, - { scheme: Schemes.PRs }, - { scheme: Schemes.Vsls }, - { scheme: Schemes.VslsScc }, - { scheme: Schemes.Virtual }, - { scheme: Schemes.GitHub }, - ]; + static selector: DocumentSelector = [...map(trackableSchemes, scheme => ({ scheme: scheme }))]; private _onDidChangeCodeLenses = new EventEmitter(); get onDidChangeCodeLenses(): Event { @@ -91,7 +84,7 @@ export class GitCodeLensProvider implements CodeLensProvider { constructor(private readonly container: Container) {} - reset(_reason?: 'idle' | 'saved') { + reset() { this._onDidChangeCodeLenses.fire(); } @@ -99,26 +92,23 @@ export class GitCodeLensProvider implements CodeLensProvider { // Since we can't currently blame edited virtual documents, don't even attempt anything if dirty if (document.isDirty && isVirtualUri(document.uri)) return []; - const cfg = configuration.get('codeLens', document); - if (!cfg.enabled) return []; + using scope = startLogScope( + `${getLoggableName(this)}.provideCodeLenses(${Logger.toLoggable(document)})`, + false, + ); - const trackedDocument = await this.container.tracker.getOrAdd(document); - if (!trackedDocument.isBlameable) return []; + const trackedDocument = await this.container.documentTracker.getOrAdd(document); + const status = await trackedDocument.getStatus(); + if (!status.blameable) return []; let dirty = false; - if (document.isDirty) { - // Only allow dirty blames if we are idle - if (trackedDocument.isDirtyIdle) { - const maxLines = configuration.get('advanced.blame.sizeThresholdAfterEdit'); - if (maxLines > 0 && document.lineCount > maxLines) { - dirty = true; - } - } else { - dirty = true; - } + // Only allow dirty blames if we are idle + if (document.isDirty && !status.dirtyIdle) { + dirty = true; } - let languageScope = cfg.scopesByLanguage?.find(ll => ll.language?.toLowerCase() === document.languageId); + const cfg = configuration.get('codeLens', document); + let languageScope = { ...cfg.scopesByLanguage?.find(ll => ll.language?.toLowerCase() === document.languageId) }; if (languageScope == null) { languageScope = { language: document.languageId, @@ -145,27 +135,24 @@ export class GitCodeLensProvider implements CodeLensProvider { if (!dirty) { if (token.isCancellationRequested) return lenses; - if (languageScope.scopes.length === 1 && languageScope.scopes.includes(CodeLensScopes.Document)) { + if (languageScope.scopes.length === 1 && languageScope.scopes.includes('document')) { blame = await this.container.git.getBlame(gitUri, document); } else { [blame, symbols] = await Promise.all([ this.container.git.getBlame(gitUri, document), executeCoreCommand<[Uri], SymbolInformation[]>( - CoreCommands.ExecuteDocumentSymbolProvider, + 'vscode.executeDocumentSymbolProvider', document.uri, ), ]); } if (blame == null || blame?.lines.length === 0) return lenses; - } else if (languageScope.scopes.length !== 1 || !languageScope.scopes.includes(CodeLensScopes.Document)) { + } else if (languageScope.scopes.length !== 1 || !languageScope.scopes.includes('document')) { let tracked; [tracked, symbols] = await Promise.all([ this.container.git.isTracked(gitUri), - executeCoreCommand<[Uri], SymbolInformation[]>( - CoreCommands.ExecuteDocumentSymbolProvider, - document.uri, - ), + executeCoreCommand<[Uri], SymbolInformation[]>('vscode.executeDocumentSymbolProvider', document.uri), ]); if (!tracked) return lenses; @@ -181,7 +168,7 @@ export class GitCodeLensProvider implements CodeLensProvider { : undefined; if (symbols !== undefined) { - Logger.log('GitCodeLensProvider.provideCodeLenses:', `${symbols.length} symbol(s) found`); + Logger.log(scope, `${symbols.length} symbol(s) found`); for (const sym of symbols) { this.provideCodeLens( lenses, @@ -199,7 +186,7 @@ export class GitCodeLensProvider implements CodeLensProvider { } if ( - (languageScope.scopes.includes(CodeLensScopes.Document) || languageScope.symbolScopes.includes('file')) && + (languageScope.scopes.includes('document') || languageScope.symbolScopes.includes('file')) && !languageScope.symbolScopes.includes('!file') ) { // Check if we have a lens for the whole document -- if not add one @@ -275,10 +262,7 @@ export class GitCodeLensProvider implements CodeLensProvider { const symbolName = SymbolKind[symbol.kind].toLowerCase(); switch (symbol.kind) { case SymbolKind.File: - if ( - languageScope.scopes.includes(CodeLensScopes.Containers) || - languageScope.symbolScopes.includes(symbolName) - ) { + if (languageScope.scopes.includes('containers') || languageScope.symbolScopes.includes(symbolName)) { valid = !languageScope.symbolScopes.includes(`!${symbolName}`); } @@ -289,10 +273,7 @@ export class GitCodeLensProvider implements CodeLensProvider { break; case SymbolKind.Package: - if ( - languageScope.scopes.includes(CodeLensScopes.Containers) || - languageScope.symbolScopes.includes(symbolName) - ) { + if (languageScope.scopes.includes('containers') || languageScope.symbolScopes.includes(symbolName)) { valid = !languageScope.symbolScopes.includes(`!${symbolName}`); } @@ -310,10 +291,7 @@ export class GitCodeLensProvider implements CodeLensProvider { case SymbolKind.Module: case SymbolKind.Namespace: case SymbolKind.Struct: - if ( - languageScope.scopes.includes(CodeLensScopes.Containers) || - languageScope.symbolScopes.includes(symbolName) - ) { + if (languageScope.scopes.includes('containers') || languageScope.symbolScopes.includes(symbolName)) { range = getRangeFromSymbol(symbol); valid = !languageScope.symbolScopes.includes(`!${symbolName}`) && @@ -326,10 +304,7 @@ export class GitCodeLensProvider implements CodeLensProvider { case SymbolKind.Function: case SymbolKind.Method: case SymbolKind.Property: - if ( - languageScope.scopes.includes(CodeLensScopes.Blocks) || - languageScope.symbolScopes.includes(symbolName) - ) { + if (languageScope.scopes.includes('blocks') || languageScope.symbolScopes.includes(symbolName)) { range = getRangeFromSymbol(symbol); valid = !languageScope.symbolScopes.includes(`!${symbolName}`) && @@ -341,7 +316,7 @@ export class GitCodeLensProvider implements CodeLensProvider { if ( languageScope.symbolScopes.includes(symbolName) || // A special case for markdown files, SymbolKind.String seems to be returned for headers, so consider those containers - (languageScope.language === 'markdown' && languageScope.scopes.includes(CodeLensScopes.Containers)) + (languageScope.language === 'markdown' && languageScope.scopes.includes('containers')) ) { range = getRangeFromSymbol(symbol); valid = @@ -475,15 +450,16 @@ export class GitCodeLensProvider implements CodeLensProvider { resolveCodeLens(lens: CodeLens, token: CancellationToken): CodeLens | Promise { if (lens instanceof GitRecentChangeCodeLens) return this.resolveGitRecentChangeCodeLens(lens, token); if (lens instanceof GitAuthorsCodeLens) return this.resolveGitAuthorsCodeLens(lens, token); + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors return Promise.reject(undefined); } private resolveGitRecentChangeCodeLens(lens: GitRecentChangeCodeLens, _token: CancellationToken): CodeLens { const blame = lens.getBlame(); - if (blame == null) return lens; + if (blame == null) return applyCommandWithNoClickAction('Unknown, (Blame failed)', lens); const recentCommit = first(blame.commits.values()); - if (recentCommit == null) return lens; + if (recentCommit == null) return applyCommandWithNoClickAction('Unknown, (Blame failed)', lens); // TODO@eamodio This is FAR too expensive, but this accounts for commits that delete lines -- is there another way? // if (lens.uri != null) { @@ -515,50 +491,40 @@ export class GitCodeLensProvider implements CodeLensProvider { } if (lens.desiredCommand === false) { - return this.applyCommandWithNoClickAction(title, lens); + return applyCommandWithNoClickAction(title, lens); } switch (lens.desiredCommand) { case CodeLensCommand.CopyRemoteCommitUrl: - return this.applyCopyOrOpenCommitOnRemoteCommand( - title, - lens, - recentCommit, - true, - ); + return applyCopyOrOpenCommitOnRemoteCommand(title, lens, recentCommit, true); case CodeLensCommand.CopyRemoteFileUrl: - return this.applyCopyOrOpenFileOnRemoteCommand( - title, - lens, - recentCommit, - true, - ); + return applyCopyOrOpenFileOnRemoteCommand(title, lens, recentCommit, true); case CodeLensCommand.DiffWithPrevious: - return this.applyDiffWithPreviousCommand(title, lens, recentCommit); + return applyDiffWithPreviousCommand(title, lens, recentCommit); case CodeLensCommand.OpenCommitOnRemote: - return this.applyCopyOrOpenCommitOnRemoteCommand(title, lens, recentCommit); + return applyCopyOrOpenCommitOnRemoteCommand(title, lens, recentCommit); case CodeLensCommand.OpenFileOnRemote: - return this.applyCopyOrOpenFileOnRemoteCommand(title, lens, recentCommit); + return applyCopyOrOpenFileOnRemoteCommand(title, lens, recentCommit); case CodeLensCommand.RevealCommitInView: - return this.applyRevealCommitInViewCommand(title, lens, recentCommit); + return applyRevealCommitInViewCommand(title, lens, recentCommit); case CodeLensCommand.ShowCommitsInView: - return this.applyShowCommitsInViewCommand(title, lens, blame, recentCommit); + return applyShowCommitsInViewCommand(title, lens, blame, recentCommit); case CodeLensCommand.ShowQuickCommitDetails: - return this.applyShowQuickCommitDetailsCommand(title, lens, recentCommit); + return applyShowQuickCommitDetailsCommand(title, lens, recentCommit); case CodeLensCommand.ShowQuickCommitFileDetails: - return this.applyShowQuickCommitFileDetailsCommand(title, lens, recentCommit); + return applyShowQuickCommitFileDetailsCommand(title, lens, recentCommit); case CodeLensCommand.ShowQuickCurrentBranchHistory: - return this.applyShowQuickCurrentBranchHistoryCommand(title, lens); + return applyShowQuickCurrentBranchHistoryCommand(title, lens); case CodeLensCommand.ShowQuickFileHistory: - return this.applyShowQuickFileHistoryCommand(title, lens); + return applyShowQuickFileHistoryCommand(title, lens); case CodeLensCommand.ToggleFileBlame: - return this.applyToggleFileBlameCommand(title, lens); + return applyToggleFileBlameCommand(title, lens); case CodeLensCommand.ToggleFileChanges: - return this.applyToggleFileChangesCommand(title, lens, recentCommit); + return applyToggleFileChangesCommand(title, lens, recentCommit); case CodeLensCommand.ToggleFileChangesOnly: - return this.applyToggleFileChangesCommand(title, lens, recentCommit, true); + return applyToggleFileChangesCommand(title, lens, recentCommit, true); case CodeLensCommand.ToggleFileHeatmap: - return this.applyToggleFileHeatmapCommand(title, lens); + return applyToggleFileHeatmapCommand(title, lens); default: return lens; } @@ -566,12 +532,14 @@ export class GitCodeLensProvider implements CodeLensProvider { private resolveGitAuthorsCodeLens(lens: GitAuthorsCodeLens, _token: CancellationToken): CodeLens { const blame = lens.getBlame(); - if (blame == null) return lens; + if (blame == null) return applyCommandWithNoClickAction('? authors (Blame failed)', lens); const count = blame.authors.size; const author = first(blame.authors.values())?.name ?? 'Unknown'; + const andOthers = + count > 1 ? ` and ${pluralize('one other', count - 1, { only: true, plural: 'others' })}` : ''; - let title = `${count} ${count > 1 ? 'authors' : 'author'} (${author}${count > 1 ? ' and others' : ''})`; + let title = `${pluralize('author', count, { zero: '?' })} (${author}${andOthers})`; if (configuration.get('debug')) { title += ` [${lens.languageId}: ${SymbolKind[lens.symbol.kind]}(${lens.range.start.character}-${ lens.range.end.character @@ -586,288 +554,288 @@ export class GitCodeLensProvider implements CodeLensProvider { } if (lens.desiredCommand === false) { - return this.applyCommandWithNoClickAction(title, lens); + return applyCommandWithNoClickAction(title, lens); } const commit = find(blame.commits.values(), c => c.author.name === author) ?? first(blame.commits.values()); - if (commit == null) return lens; + if (commit == null) return applyCommandWithNoClickAction(title, lens); switch (lens.desiredCommand) { case CodeLensCommand.CopyRemoteCommitUrl: - return this.applyCopyOrOpenCommitOnRemoteCommand(title, lens, commit, true); + return applyCopyOrOpenCommitOnRemoteCommand(title, lens, commit, true); case CodeLensCommand.CopyRemoteFileUrl: - return this.applyCopyOrOpenFileOnRemoteCommand(title, lens, commit, true); + return applyCopyOrOpenFileOnRemoteCommand(title, lens, commit, true); case CodeLensCommand.DiffWithPrevious: - return this.applyDiffWithPreviousCommand(title, lens, commit); + return applyDiffWithPreviousCommand(title, lens, commit); case CodeLensCommand.OpenCommitOnRemote: - return this.applyCopyOrOpenCommitOnRemoteCommand(title, lens, commit); + return applyCopyOrOpenCommitOnRemoteCommand(title, lens, commit); case CodeLensCommand.OpenFileOnRemote: - return this.applyCopyOrOpenFileOnRemoteCommand(title, lens, commit); + return applyCopyOrOpenFileOnRemoteCommand(title, lens, commit); case CodeLensCommand.RevealCommitInView: - return this.applyRevealCommitInViewCommand(title, lens, commit); + return applyRevealCommitInViewCommand(title, lens, commit); case CodeLensCommand.ShowCommitsInView: - return this.applyShowCommitsInViewCommand(title, lens, blame); + return applyShowCommitsInViewCommand(title, lens, blame); case CodeLensCommand.ShowQuickCommitDetails: - return this.applyShowQuickCommitDetailsCommand(title, lens, commit); + return applyShowQuickCommitDetailsCommand(title, lens, commit); case CodeLensCommand.ShowQuickCommitFileDetails: - return this.applyShowQuickCommitFileDetailsCommand(title, lens, commit); + return applyShowQuickCommitFileDetailsCommand(title, lens, commit); case CodeLensCommand.ShowQuickCurrentBranchHistory: - return this.applyShowQuickCurrentBranchHistoryCommand(title, lens); + return applyShowQuickCurrentBranchHistoryCommand(title, lens); case CodeLensCommand.ShowQuickFileHistory: - return this.applyShowQuickFileHistoryCommand(title, lens); + return applyShowQuickFileHistoryCommand(title, lens); case CodeLensCommand.ToggleFileBlame: - return this.applyToggleFileBlameCommand(title, lens); + return applyToggleFileBlameCommand(title, lens); case CodeLensCommand.ToggleFileChanges: - return this.applyToggleFileChangesCommand(title, lens, commit); + return applyToggleFileChangesCommand(title, lens, commit); case CodeLensCommand.ToggleFileChangesOnly: - return this.applyToggleFileChangesCommand(title, lens, commit, true); + return applyToggleFileChangesCommand(title, lens, commit, true); case CodeLensCommand.ToggleFileHeatmap: - return this.applyToggleFileHeatmapCommand(title, lens); + return applyToggleFileHeatmapCommand(title, lens); default: return lens; } } - private applyDiffWithPreviousCommand( - title: string, - lens: T, - commit: GitCommit | undefined, - ): T { - lens.command = asCommand<[undefined, DiffWithPreviousCommandArgs]>({ - title: title, - command: Commands.DiffWithPrevious, - arguments: [ - undefined, - { - commit: commit, - uri: lens.uri!.toFileUri(), - }, - ], - }); - return lens; + private getDirtyTitle(cfg: CodeLensConfig) { + if (cfg.recentChange.enabled && cfg.authors.enabled) { + return configuration.get('strings.codeLens.unsavedChanges.recentChangeAndAuthors'); + } + if (cfg.recentChange.enabled) return configuration.get('strings.codeLens.unsavedChanges.recentChangeOnly'); + return configuration.get('strings.codeLens.unsavedChanges.authorsOnly'); } +} - private applyCopyOrOpenCommitOnRemoteCommand( - title: string, - lens: T, - commit: GitCommit, - clipboard: boolean = false, - ): T { - lens.command = asCommand<[OpenOnRemoteCommandArgs]>({ - title: title, - command: Commands.OpenOnRemote, - arguments: [ - { - resource: { - type: RemoteResourceType.Commit, - sha: commit.sha, - }, - repoPath: commit.repoPath, - clipboard: clipboard, - }, - ], - }); - return lens; - } +function applyDiffWithPreviousCommand( + title: string, + lens: T, + commit: GitCommit | undefined, +): T { + lens.command = asCommand<[undefined, DiffWithPreviousCommandArgs]>({ + title: title, + command: Commands.DiffWithPrevious, + arguments: [ + undefined, + { + commit: commit, + uri: lens.uri!.toFileUri(), + }, + ], + }); + return lens; +} - private applyCopyOrOpenFileOnRemoteCommand( - title: string, - lens: T, - commit: GitCommit, - clipboard: boolean = false, - ): T { - lens.command = asCommand<[OpenOnRemoteCommandArgs]>({ - title: title, - command: Commands.OpenOnRemote, - arguments: [ - { - resource: { - type: RemoteResourceType.Revision, - fileName: commit.file?.path ?? '', - sha: commit.sha, - }, - repoPath: commit.repoPath, - clipboard: clipboard, +function applyCopyOrOpenCommitOnRemoteCommand( + title: string, + lens: T, + commit: GitCommit, + clipboard: boolean = false, +): T { + lens.command = asCommand<[OpenOnRemoteCommandArgs]>({ + title: title, + command: Commands.OpenOnRemote, + arguments: [ + { + resource: { + type: RemoteResourceType.Commit, + sha: commit.sha, }, - ], - }); - return lens; - } + repoPath: commit.repoPath, + clipboard: clipboard, + }, + ], + }); + return lens; +} - private applyRevealCommitInViewCommand( - title: string, - lens: T, - commit: GitCommit | undefined, - ): T { - lens.command = asCommand<[Uri, ShowQuickCommitCommandArgs]>({ - title: title, - command: commit?.isUncommitted ? '' : CodeLensCommand.RevealCommitInView, - arguments: [ - lens.uri!.toFileUri(), - { - commit: commit, - sha: commit === undefined ? undefined : commit.sha, +function applyCopyOrOpenFileOnRemoteCommand( + title: string, + lens: T, + commit: GitCommit, + clipboard: boolean = false, +): T { + lens.command = asCommand<[OpenOnRemoteCommandArgs]>({ + title: title, + command: Commands.OpenOnRemote, + arguments: [ + { + resource: { + type: RemoteResourceType.Revision, + fileName: commit.file?.path ?? '', + sha: commit.sha, }, - ], - }); - return lens; - } + repoPath: commit.repoPath, + clipboard: clipboard, + }, + ], + }); + return lens; +} - private applyShowCommitsInViewCommand( - title: string, - lens: T, - blame: GitBlameLines, - commit?: GitCommit, - ): T { - let refs; - if (commit === undefined) { - refs = [...filterMap(blame.commits.values(), c => (c.isUncommitted ? undefined : c.ref))]; - } else { - refs = [commit.ref]; - } +function applyRevealCommitInViewCommand( + title: string, + lens: T, + commit: GitCommit | undefined, +): T { + lens.command = asCommand<[Uri, ShowQuickCommitCommandArgs]>({ + title: title, + command: commit?.isUncommitted ? '' : CodeLensCommand.RevealCommitInView, + arguments: [ + lens.uri!.toFileUri(), + { + commit: commit, + sha: commit === undefined ? undefined : commit.sha, + }, + ], + }); + return lens; +} - lens.command = asCommand<[ShowCommitsInViewCommandArgs]>({ - title: title, - command: refs.length === 0 ? '' : Commands.ShowCommitsInView, - arguments: [ - { - repoPath: blame.repoPath, - refs: refs, - }, - ], - }); - return lens; +function applyShowCommitsInViewCommand( + title: string, + lens: T, + blame: GitBlameLines, + commit?: GitCommit, +): T { + let refs; + if (commit === undefined) { + refs = [...filterMap(blame.commits.values(), c => (c.isUncommitted ? undefined : c.ref))]; + } else { + refs = [commit.ref]; } - private applyShowQuickCommitDetailsCommand( - title: string, - lens: T, - commit: GitCommit | undefined, - ): T { - lens.command = asCommand<[Uri, ShowQuickCommitCommandArgs]>({ - title: title, - command: commit?.isUncommitted ? '' : CodeLensCommand.ShowQuickCommitDetails, - arguments: [ - lens.uri!.toFileUri(), - { - commit: commit, - sha: commit === undefined ? undefined : commit.sha, - }, - ], - }); - return lens; - } + lens.command = asCommand<[ShowCommitsInViewCommandArgs]>({ + title: title, + command: refs.length === 0 ? '' : Commands.ShowCommitsInView, + arguments: [ + { + repoPath: blame.repoPath, + refs: refs, + }, + ], + }); + return lens; +} - private applyShowQuickCommitFileDetailsCommand( - title: string, - lens: T, - commit: GitCommit | undefined, - ): T { - lens.command = asCommand<[Uri, ShowQuickCommitFileCommandArgs]>({ - title: title, - command: commit?.isUncommitted ? '' : CodeLensCommand.ShowQuickCommitFileDetails, - arguments: [ - lens.uri!.toFileUri(), - { - commit: commit, - sha: commit === undefined ? undefined : commit.sha, - }, - ], - }); - return lens; - } +function applyShowQuickCommitDetailsCommand( + title: string, + lens: T, + commit: GitCommit | undefined, +): T { + lens.command = asCommand<[Uri, ShowQuickCommitCommandArgs]>({ + title: title, + command: commit?.isUncommitted ? '' : CodeLensCommand.ShowQuickCommitDetails, + arguments: [ + lens.uri!.toFileUri(), + { + commit: commit, + sha: commit === undefined ? undefined : commit.sha, + }, + ], + }); + return lens; +} - private applyShowQuickCurrentBranchHistoryCommand( - title: string, - lens: T, - ): T { - lens.command = asCommand<[Uri]>({ - title: title, - command: CodeLensCommand.ShowQuickCurrentBranchHistory, - arguments: [lens.uri!.toFileUri()], - }); - return lens; - } +function applyShowQuickCommitFileDetailsCommand( + title: string, + lens: T, + commit: GitCommit | undefined, +): T { + lens.command = asCommand<[Uri, ShowQuickCommitFileCommandArgs]>({ + title: title, + command: commit?.isUncommitted ? '' : CodeLensCommand.ShowQuickCommitFileDetails, + arguments: [ + lens.uri!.toFileUri(), + { + commit: commit, + sha: commit === undefined ? undefined : commit.sha, + }, + ], + }); + return lens; +} - private applyShowQuickFileHistoryCommand( - title: string, - lens: T, - ): T { - lens.command = asCommand<[Uri, ShowQuickFileHistoryCommandArgs]>({ - title: title, - command: CodeLensCommand.ShowQuickFileHistory, - arguments: [ - lens.uri!.toFileUri(), - { - range: lens.isFullRange ? undefined : lens.blameRange, - }, - ], - }); - return lens; - } +function applyShowQuickCurrentBranchHistoryCommand( + title: string, + lens: T, +): T { + lens.command = asCommand<[Uri]>({ + title: title, + command: CodeLensCommand.ShowQuickCurrentBranchHistory, + arguments: [lens.uri!.toFileUri()], + }); + return lens; +} - private applyToggleFileBlameCommand( - title: string, - lens: T, - ): T { - lens.command = asCommand<[Uri]>({ - title: title, - command: Commands.ToggleFileBlame, - arguments: [lens.uri!.toFileUri()], - }); - return lens; - } +function applyShowQuickFileHistoryCommand( + title: string, + lens: T, +): T { + lens.command = asCommand<[Uri, ShowQuickFileHistoryCommandArgs]>({ + title: title, + command: CodeLensCommand.ShowQuickFileHistory, + arguments: [ + lens.uri!.toFileUri(), + { + range: lens.isFullRange ? undefined : lens.blameRange, + }, + ], + }); + return lens; +} - private applyToggleFileChangesCommand( - title: string, - lens: T, - commit: GitCommit, - only?: boolean, - ): T { - lens.command = asCommand<[Uri, ToggleFileChangesAnnotationCommandArgs]>({ - title: title, - command: Commands.ToggleFileChanges, - arguments: [ - lens.uri!.toFileUri(), - { - type: FileAnnotationType.Changes, - context: { sha: commit.sha, only: only, selection: false }, - }, - ], - }); - return lens; - } +function applyToggleFileBlameCommand( + title: string, + lens: T, +): T { + lens.command = asCommand<[Uri]>({ + title: title, + command: Commands.ToggleFileBlame, + arguments: [lens.uri!.toFileUri()], + }); + return lens; +} - private applyToggleFileHeatmapCommand( - title: string, - lens: T, - ): T { - lens.command = asCommand<[Uri]>({ - title: title, - command: Commands.ToggleFileHeatmap, - arguments: [lens.uri!.toFileUri()], - }); - return lens; - } +function applyToggleFileChangesCommand( + title: string, + lens: T, + commit: GitCommit, + only?: boolean, +): T { + lens.command = asCommand<[Uri, ToggleFileChangesAnnotationCommandArgs]>({ + title: title, + command: Commands.ToggleFileChanges, + arguments: [ + lens.uri!.toFileUri(), + { + type: 'changes', + context: { sha: commit.sha, only: only, selection: false }, + }, + ], + }); + return lens; +} - private applyCommandWithNoClickAction( - title: string, - lens: T, - ): T { - lens.command = { - title: title, - command: '', - }; - return lens; - } +function applyToggleFileHeatmapCommand( + title: string, + lens: T, +): T { + lens.command = asCommand<[Uri]>({ + title: title, + command: Commands.ToggleFileHeatmap, + arguments: [lens.uri!.toFileUri()], + }); + return lens; +} - private getDirtyTitle(cfg: CodeLensConfig) { - if (cfg.recentChange.enabled && cfg.authors.enabled) { - return configuration.get('strings.codeLens.unsavedChanges.recentChangeAndAuthors'); - } - if (cfg.recentChange.enabled) return configuration.get('strings.codeLens.unsavedChanges.recentChangeOnly'); - return configuration.get('strings.codeLens.unsavedChanges.authorsOnly'); - } +function applyCommandWithNoClickAction( + title: string, + lens: T, +): T { + lens.command = { + title: title, + command: '', + }; + return lens; } function getRangeFromSymbol(symbol: DocumentSymbol | SymbolInformation) { diff --git a/src/commands.ts b/src/commands.ts index 07b9d8894a0a1..7c7943e495219 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,63 +1,71 @@ -export * from './commands/addAuthors'; -export * from './commands/browseRepoAtRevision'; -export * from './commands/closeUnchangedFiles'; -export * from './commands/compareWith'; -export * from './commands/copyCurrentBranch'; -export * from './commands/copyDeepLink'; -export * from './commands/copyMessageToClipboard'; -export * from './commands/copyShaToClipboard'; -export * from './commands/createPullRequestOnRemote'; -export * from './commands/openDirectoryCompare'; -export * from './commands/diffLineWithPrevious'; -export * from './commands/diffLineWithWorking'; -export * from './commands/diffWith'; -export * from './commands/diffWithNext'; -export * from './commands/diffWithPrevious'; -export * from './commands/diffWithRevision'; -export * from './commands/diffWithRevisionFrom'; -export * from './commands/diffWithWorking'; -export * from './commands/externalDiff'; -export * from './commands/ghpr/openOrCreateWorktree'; -export * from './commands/gitCommands'; -export * from './commands/inviteToLiveShare'; -export * from './commands/logging'; -export * from './commands/openAssociatedPullRequestOnRemote'; -export * from './commands/openBranchesOnRemote'; -export * from './commands/openBranchOnRemote'; -export * from './commands/openCurrentBranchOnRemote'; -export * from './commands/openChangedFiles'; -export * from './commands/openCommitOnRemote'; -export * from './commands/openComparisonOnRemote'; -export * from './commands/openFileFromRemote'; -export * from './commands/openFileOnRemote'; -export * from './commands/openFileAtRevision'; -export * from './commands/openFileAtRevisionFrom'; -export * from './commands/openOnRemote'; -export * from './commands/openIssueOnRemote'; -export * from './commands/openPullRequestOnRemote'; -export * from './commands/openRepoOnRemote'; -export * from './commands/openRevisionFile'; -export * from './commands/openWorkingFile'; -export * from './commands/rebaseEditor'; -export * from './commands/refreshHover'; -export * from './commands/remoteProviders'; -export * from './commands/repositories'; -export * from './commands/resets'; -export * from './commands/setViewsLayout'; -export * from './commands/searchCommits'; -export * from './commands/showCommitsInView'; -export * from './commands/showLastQuickPick'; -export * from './commands/showQuickBranchHistory'; -export * from './commands/showQuickCommit'; -export * from './commands/showQuickCommitFile'; -export * from './commands/showQuickFileHistory'; -export * from './commands/showQuickRepoStatus'; -export * from './commands/showQuickStashList'; -export * from './commands/showView'; -export * from './commands/stashApply'; -export * from './commands/stashSave'; -export * from './commands/switchMode'; -export * from './commands/toggleCodeLens'; -export * from './commands/toggleFileAnnotations'; -export * from './commands/toggleLineBlame'; -export * from './commands/walkthroughs'; +import './commands/addAuthors'; +import './commands/browseRepoAtRevision'; +import './commands/closeUnchangedFiles'; +import './commands/cloudIntegrations'; +import './commands/compareWith'; +import './commands/copyCurrentBranch'; +import './commands/copyDeepLink'; +import './commands/copyMessageToClipboard'; +import './commands/copyShaToClipboard'; +import './commands/copyRelativePathToClipboard'; +import './commands/createPullRequestOnRemote'; +import './commands/openDirectoryCompare'; +import './commands/diffFolderWithRevision'; +import './commands/diffFolderWithRevisionFrom'; +import './commands/diffLineWithPrevious'; +import './commands/diffLineWithWorking'; +import './commands/diffWith'; +import './commands/diffWithNext'; +import './commands/diffWithPrevious'; +import './commands/diffWithRevision'; +import './commands/diffWithRevisionFrom'; +import './commands/diffWithWorking'; +import './commands/externalDiff'; +import './commands/generateCommitMessage'; +import './commands/ghpr/openOrCreateWorktree'; +import './commands/gitCommands'; +import './commands/inviteToLiveShare'; +import './commands/inspect'; +import './commands/logging'; +import './commands/openAssociatedPullRequestOnRemote'; +import './commands/openBranchesOnRemote'; +import './commands/openBranchOnRemote'; +import './commands/openCurrentBranchOnRemote'; +import './commands/openChangedFiles'; +import './commands/openCommitOnRemote'; +import './commands/openComparisonOnRemote'; +import './commands/openFileFromRemote'; +import './commands/openFileOnRemote'; +import './commands/openFileAtRevision'; +import './commands/openFileAtRevisionFrom'; +import './commands/openOnRemote'; +import './commands/openPullRequestOnRemote'; +import './commands/openRepoOnRemote'; +import './commands/openRevisionFile'; +import './commands/openWorkingFile'; +import './commands/patches'; +import './commands/rebaseEditor'; +import './commands/refreshHover'; +import './commands/remoteProviders'; +import './commands/repositories'; +import './commands/resets'; +import './commands/resetViewsLayout'; +import './commands/searchCommits'; +import './commands/showCommitsInView'; +import './commands/showLastQuickPick'; +import './commands/openOnlyChangedFiles'; +import './commands/showQuickBranchHistory'; +import './commands/showQuickCommit'; +import './commands/showQuickCommitFile'; +import './commands/showQuickFileHistory'; +import './commands/showQuickRepoStatus'; +import './commands/showQuickStashList'; +import './commands/showView'; +import './commands/stashApply'; +import './commands/stashSave'; +import './commands/switchAIModel'; +import './commands/switchMode'; +import './commands/toggleCodeLens'; +import './commands/toggleFileAnnotations'; +import './commands/toggleLineBlame'; +import './commands/walkthroughs'; diff --git a/src/commands/addAuthors.ts b/src/commands/addAuthors.ts index 0138a84dea5f9..9d92ce6ab17e1 100644 --- a/src/commands/addAuthors.ts +++ b/src/commands/addAuthors.ts @@ -1,8 +1,8 @@ import type { SourceControl } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { executeGitCommand } from '../git/actions'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; import { Command } from './base'; @command() diff --git a/src/commands/base.ts b/src/commands/base.ts index eb2dc9695e18b..40bd99adfd37b 100644 --- a/src/commands/base.ts +++ b/src/commands/base.ts @@ -9,21 +9,26 @@ import type { } from 'vscode'; import { commands, Disposable, Uri, window } from 'vscode'; import type { ActionContext } from '../api/gitlens'; -import type { Commands } from '../constants'; +import type { Commands } from '../constants.commands'; +import type { StoredNamedRef } from '../constants.storage'; import type { GitBranch } from '../git/models/branch'; import { isBranch } from '../git/models/branch'; import type { GitCommit, GitStashCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; -import { GitContributor } from '../git/models/contributor'; +import type { GitContributor } from '../git/models/contributor'; +import { isContributor } from '../git/models/contributor'; import type { GitFile } from '../git/models/file'; import type { GitReference } from '../git/models/reference'; -import { GitRemote } from '../git/models/remote'; +import type { GitRemote } from '../git/models/remote'; +import { isRemote } from '../git/models/remote'; import { Repository } from '../git/models/repository'; import type { GitTag } from '../git/models/tag'; import { isTag } from '../git/models/tag'; -import { registerCommand } from '../system/command'; +import { CloudWorkspace, LocalWorkspace } from '../plus/workspaces/models'; import { sequentialize } from '../system/function'; -import { ViewNode, ViewRefNode } from '../views/nodes/viewNode'; +import { registerCommand } from '../system/vscode/command'; +import { ViewNode } from '../views/nodes/abstract/viewNode'; +import { ViewRefFileNode, ViewRefNode } from '../views/nodes/abstract/viewRefNode'; export function getCommandUri(uri?: Uri, editor?: TextEditor): Uri | undefined { // Always use the editor.uri (if we have one), so we are correct for a split diff @@ -40,6 +45,12 @@ export interface CommandBaseContext { uri?: Uri; } +export interface CommandEditorLineContext extends CommandBaseContext { + readonly type: 'editorLine'; + readonly line: number; + readonly uri: Uri; +} + export interface CommandGitTimelineItemContext extends CommandBaseContext { readonly type: 'timeline-item:git'; readonly item: GitTimelineItem; @@ -89,6 +100,10 @@ export interface CommandViewNodesContext extends CommandBaseContext { readonly nodes: ViewNode[]; } +export function isCommandContextEditorLine(context: CommandContext): context is CommandEditorLineContext { + return context.type === 'editorLine'; +} + export function isCommandContextGitTimelineItem(context: CommandContext): context is CommandGitTimelineItemContext { return context.type === 'timeline-item:git'; } @@ -114,7 +129,7 @@ export function isCommandContextViewNodeHasContributor( ): context is CommandViewNodeContext & { node: ViewNode & { contributor: GitContributor } } { if (context.type !== 'viewItem') return false; - return GitContributor.is((context.node as ViewNode & { contributor: GitContributor }).contributor); + return isContributor((context.node as ViewNode & { contributor: GitContributor }).contributor); } export function isCommandContextViewNodeHasFile( @@ -149,10 +164,25 @@ export function isCommandContextViewNodeHasFileRefs(context: CommandContext): co ); } +export function isCommandContextViewNodeHasComparison(context: CommandContext): context is CommandViewNodeContext & { + node: ViewNode & { compareRef: StoredNamedRef; compareWithRef: StoredNamedRef }; +} { + if (context.type !== 'viewItem') return false; + + return ( + typeof (context.node as ViewNode & { compareRef: StoredNamedRef; compareWithRef: StoredNamedRef }).compareRef + ?.ref === 'string' && + typeof (context.node as ViewNode & { compareRef: StoredNamedRef; compareWithRef: StoredNamedRef }) + .compareWithRef?.ref === 'string' + ); +} + export function isCommandContextViewNodeHasRef( context: CommandContext, ): context is CommandViewNodeContext & { node: ViewNode & { ref: GitReference } } { - return context.type === 'viewItem' && context.node instanceof ViewRefNode; + return ( + context.type === 'viewItem' && (context.node instanceof ViewRefNode || context.node instanceof ViewRefFileNode) + ); } export function isCommandContextViewNodeHasRemote( @@ -160,7 +190,7 @@ export function isCommandContextViewNodeHasRemote( ): context is CommandViewNodeContext & { node: ViewNode & { remote: GitRemote } } { if (context.type !== 'viewItem') return false; - return GitRemote.is((context.node as ViewNode & { remote: GitRemote }).remote); + return isRemote((context.node as ViewNode & { remote: GitRemote }).remote); } export function isCommandContextViewNodeHasRepository( @@ -187,7 +217,16 @@ export function isCommandContextViewNodeHasTag( return isTag((context.node as ViewNode & { tag: GitTag }).tag); } +export function isCommandContextViewNodeHasWorkspace( + context: CommandContext, +): context is CommandViewNodeContext & { node: ViewNode & { workspace: CloudWorkspace | LocalWorkspace } } { + if (context.type !== 'viewItem') return false; + const workspace = (context.node as ViewNode & { workspace?: CloudWorkspace | LocalWorkspace }).workspace; + return workspace instanceof CloudWorkspace || workspace instanceof LocalWorkspace; +} + export type CommandContext = + | CommandEditorLineContext | CommandGitTimelineItemContext | CommandScmContext | CommandScmGroupsContext @@ -199,7 +238,7 @@ export type CommandContext = | CommandViewNodeContext | CommandViewNodesContext; -function isScm(scm: any): scm is SourceControl { +export function isScm(scm: any): scm is SourceControl { if (scm == null) return false; return ( @@ -249,7 +288,8 @@ export abstract class Command implements Disposable { command: Commands | `${Commands.ActionPrefix}${ActionContext['type']}`, args: T, ): string { - return `command:${command}?${encodeURIComponent(JSON.stringify(args))}`; + // Since we are using the command in a markdown link, we need to escape ()'s so they don't get interpreted as markdown + return `command:${command}?${encodeURIComponent(JSON.stringify(args)).replace(/([()])/g, '\\$1')}`; } protected readonly contextParsingOptions: CommandContextParsingOptions = { expectsEditor: false }; @@ -333,6 +373,20 @@ export function parseCommandContext( args = args.slice(1); } else if (editor == null) { + if (firstArg != null && typeof firstArg === 'object' && 'lineNumber' in firstArg && 'uri' in firstArg) { + const [, ...rest] = args; + return [ + { + command: command, + type: 'editorLine', + editor: undefined, + line: firstArg.lineNumber - 1, // convert to zero-based + uri: firstArg.uri, + }, + rest, + ]; + } + // If we are expecting an editor and we have no uri, then pass the active editor editor = window.activeTextEditor; } @@ -405,10 +459,6 @@ export function parseCommandContext( export abstract class ActiveEditorCommand extends Command { protected override readonly contextParsingOptions: CommandContextParsingOptions = { expectsEditor: true }; - constructor(command: Commands | Commands[]) { - super(command); - } - protected override preExecute(context: CommandContext, ...args: any[]): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return this.execute(context.editor, context.uri, ...args); @@ -427,10 +477,6 @@ export function getLastCommand() { } export abstract class ActiveEditorCachedCommand extends ActiveEditorCommand { - constructor(command: Commands | Commands[]) { - super(command); - } - protected override _execute(command: string, ...args: any[]): any { lastCommand = { command: command, @@ -469,7 +515,7 @@ export abstract class EditorCommand implements Disposable { this._disposable.dispose(); } - private executeCore(command: string, editor: TextEditor, edit: TextEditorEdit, ...args: any[]): any { + private executeCore(_command: string, editor: TextEditor, edit: TextEditorEdit, ...args: any[]): any { return this.execute(editor, edit, ...args); } diff --git a/src/commands/browseRepoAtRevision.ts b/src/commands/browseRepoAtRevision.ts index b995b8670223d..9d2abdc121540 100644 --- a/src/commands/browseRepoAtRevision.ts +++ b/src/commands/browseRepoAtRevision.ts @@ -1,12 +1,12 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands, CoreCommands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { command, executeCoreCommand } from '../system/command'; +import { Logger } from '../system/logger'; import { basename } from '../system/path'; -import { openWorkspace, OpenWorkspaceLocation } from '../system/utils'; +import { command, executeCoreCommand } from '../system/vscode/command'; +import { openWorkspace } from '../system/vscode/utils'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri } from './base'; @@ -65,12 +65,12 @@ export class BrowseRepoAtRevisionCommand extends ActiveEditorCommand { gitUri = GitUri.fromRevisionUri(uri); openWorkspace(uri, { - location: args.openInNewWindow ? OpenWorkspaceLocation.NewWindow : OpenWorkspaceLocation.AddToWorkspace, + location: args.openInNewWindow ? 'newWindow' : 'addToWorkspace', name: `${basename(gitUri.repoPath!)} @ ${gitUri.shortSha}`, }); if (!args.openInNewWindow) { - void executeCoreCommand(CoreCommands.FocusFilesExplorer); + void executeCoreCommand('workbench.files.action.focusFilesExplorer'); } } catch (ex) { Logger.error(ex, 'BrowseRepoAtRevisionCommand'); diff --git a/src/commands/closeUnchangedFiles.ts b/src/commands/closeUnchangedFiles.ts index 3397bfb6f27e6..0ff2662eac042 100644 --- a/src/commands/closeUnchangedFiles.ts +++ b/src/commands/closeUnchangedFiles.ts @@ -1,12 +1,12 @@ import type { Uri } from 'vscode'; import { TabInputCustom, TabInputNotebook, TabInputNotebookDiff, TabInputText, TabInputTextDiff, window } from 'vscode'; -import { UriComparer } from '../comparers'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; -import { command } from '../system/command'; +import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { UriComparer } from '../system/comparers'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; import { Command } from './base'; export interface CloseUnchangedFilesCommandArgs { @@ -24,7 +24,7 @@ export class CloseUnchangedFilesCommand extends Command { try { if (args.uris == null) { - const repository = await RepositoryPicker.getRepositoryOrShow('Close All Unchanged Files'); + const repository = await getRepositoryOrShowPicker('Close All Unchanged Files'); if (repository == null) return; const status = await this.container.git.getStatusForRepo(repository.uri); diff --git a/src/commands/cloudIntegrations.ts b/src/commands/cloudIntegrations.ts new file mode 100644 index 0000000000000..c0decb717c8bc --- /dev/null +++ b/src/commands/cloudIntegrations.ts @@ -0,0 +1,39 @@ +import { Commands } from '../constants.commands'; +import type { Source } from '../constants.telemetry'; +import type { Container } from '../container'; +import type { SupportedCloudIntegrationIds } from '../plus/integrations/authentication/models'; +import { command } from '../system/vscode/command'; +import { Command } from './base'; + +export interface ManageCloudIntegrationsCommandArgs extends Source {} + +export interface ConnectCloudIntegrationsCommandArgs extends Source { + integrationIds?: SupportedCloudIntegrationIds[]; +} + +@command() +export class ManageCloudIntegrationsCommand extends Command { + constructor(private readonly container: Container) { + super(Commands.PlusManageCloudIntegrations); + } + + async execute(args?: ManageCloudIntegrationsCommandArgs) { + await this.container.integrations.manageCloudIntegrations( + args?.source ? { source: args.source, detail: args?.detail } : undefined, + ); + } +} + +@command() +export class ConnectCloudIntegrationsCommand extends Command { + constructor(private readonly container: Container) { + super(Commands.PlusConnectCloudIntegrations); + } + + async execute(args?: ConnectCloudIntegrationsCommandArgs) { + await this.container.integrations.connectCloudIntegrations( + args?.integrationIds ? { integrationIds: args.integrationIds } : undefined, + args?.source ? { source: args.source, detail: args?.detail } : undefined, + ); + } +} diff --git a/src/commands/compareWith.ts b/src/commands/compareWith.ts index 7e53a01ae5e91..176cd1bb23a4a 100644 --- a/src/commands/compareWith.ts +++ b/src/commands/compareWith.ts @@ -1,10 +1,10 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; -import { command } from '../system/command'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri } from './base'; @@ -68,7 +68,7 @@ export class CompareWithCommand extends ActiveEditorCommand { break; } - const repoPath = (await RepositoryPicker.getBestRepositoryOrShow(uri, editor, title))?.path; + const repoPath = (await getBestRepositoryOrShowPicker(uri, editor, title))?.path; if (!repoPath) return; if (args.ref1 != null && args.ref2 != null) { diff --git a/src/commands/copyCurrentBranch.ts b/src/commands/copyCurrentBranch.ts index c912e4d97a720..d5cf76ff2d4e1 100644 --- a/src/commands/copyCurrentBranch.ts +++ b/src/commands/copyCurrentBranch.ts @@ -1,12 +1,12 @@ import type { TextEditor, Uri } from 'vscode'; import { env } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; -import { command } from '../system/command'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; import { ActiveEditorCommand, getCommandUri } from './base'; @command() @@ -20,7 +20,7 @@ export class CopyCurrentBranchCommand extends ActiveEditorCommand { const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; - const repository = await RepositoryPicker.getBestRepositoryOrShow(gitUri, editor, 'Copy Current Branch Name'); + const repository = await getBestRepositoryOrShowPicker(gitUri, editor, 'Copy Current Branch Name'); if (repository == null) return; try { diff --git a/src/commands/copyDeepLink.ts b/src/commands/copyDeepLink.ts index 43c4cf3640a22..9dd49b6d7f4ad 100644 --- a/src/commands/copyDeepLink.ts +++ b/src/commands/copyDeepLink.ts @@ -1,29 +1,39 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; +import type { StoredNamedRef } from '../constants.storage'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; -import { splitBranchNameAndRemote } from '../git/models/branch'; +import { getBranchNameAndRemote } from '../git/models/branch'; import type { GitReference } from '../git/models/reference'; -import { Logger } from '../logger'; +import { createReference } from '../git/models/reference'; import { showGenericErrorMessage } from '../messages'; -import { RemotePicker } from '../quickpicks/remotePicker'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; -import { command } from '../system/command'; +import { ReferencesQuickPickIncludes, showReferencePicker } from '../quickpicks/referencePicker'; +import { showRemotePicker } from '../quickpicks/remotePicker'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { normalizePath } from '../system/path'; +import { command } from '../system/vscode/command'; import { DeepLinkType, deepLinkTypeToString, refTypeToDeepLinkType } from '../uris/deepLinks/deepLink'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri, + isCommandContextEditorLine, isCommandContextViewNodeHasBranch, isCommandContextViewNodeHasCommit, + isCommandContextViewNodeHasComparison, isCommandContextViewNodeHasRemote, isCommandContextViewNodeHasTag, + isCommandContextViewNodeHasWorkspace, } from './base'; export interface CopyDeepLinkCommandArgs { refOrRepoPath?: GitReference | string; + compareRef?: StoredNamedRef; + compareWithRef?: StoredNamedRef; remote?: string; prePickRemote?: boolean; + workspaceId?: string; } @command() @@ -34,6 +44,8 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { Commands.CopyDeepLinkToCommit, Commands.CopyDeepLinkToRepo, Commands.CopyDeepLinkToTag, + Commands.CopyDeepLinkToComparison, + Commands.CopyDeepLinkToWorkspace, ]); } @@ -42,11 +54,26 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { if (isCommandContextViewNodeHasCommit(context)) { args = { refOrRepoPath: context.node.commit }; } else if (isCommandContextViewNodeHasBranch(context)) { - args = { refOrRepoPath: context.node.branch }; + if (context.command === Commands.CopyDeepLinkToRepo) { + args = { + refOrRepoPath: context.node.branch.repoPath, + remote: context.node.branch.getRemoteName(), + }; + } else { + args = { refOrRepoPath: context.node.branch }; + } } else if (isCommandContextViewNodeHasTag(context)) { args = { refOrRepoPath: context.node.tag }; } else if (isCommandContextViewNodeHasRemote(context)) { args = { refOrRepoPath: context.node.remote.repoPath, remote: context.node.remote.name }; + } else if (isCommandContextViewNodeHasComparison(context)) { + args = { + refOrRepoPath: context.node.uri.fsPath, + compareRef: context.node.compareRef, + compareWithRef: context.node.compareWithRef, + }; + } else if (isCommandContextViewNodeHasWorkspace(context)) { + args = { workspaceId: context.node.workspace.id }; } } @@ -56,6 +83,16 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { async execute(editor?: TextEditor, uri?: Uri, args?: CopyDeepLinkCommandArgs) { args = { ...args }; + if (args.workspaceId != null) { + try { + await this.container.deepLinks.copyDeepLinkUrl(args.workspaceId); + } catch (ex) { + Logger.error(ex, 'CopyDeepLinkCommand'); + void showGenericErrorMessage('Unable to copy link'); + } + return; + } + let type; let repoPath; if (args?.refOrRepoPath == null) { @@ -64,14 +101,10 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { type = DeepLinkType.Repository; repoPath = ( - await RepositoryPicker.getBestRepositoryOrShow( - gitUri, - editor, - `Copy Link to ${deepLinkTypeToString(type)}`, - ) + await getBestRepositoryOrShowPicker(gitUri, editor, `Copy Link to ${deepLinkTypeToString(type)}`) )?.path; } else if (typeof args.refOrRepoPath === 'string') { - type = DeepLinkType.Repository; + type = args.compareRef == null ? DeepLinkType.Repository : DeepLinkType.Comparison; repoPath = args.refOrRepoPath; args.refOrRepoPath = undefined; } else { @@ -84,11 +117,8 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { if (args.refOrRepoPath?.refType === 'branch') { // If the branch is remote, or has an upstream, pre-select the remote if (args.refOrRepoPath.remote || args.refOrRepoPath.upstream?.name != null) { - const [branchName, remoteName] = splitBranchNameAndRemote( - args.refOrRepoPath.remote ? args.refOrRepoPath.name : args.refOrRepoPath.upstream!.name, - ); - - if (branchName != null) { + const [branchName, remoteName] = getBranchNameAndRemote(args.refOrRepoPath); + if (branchName != null && remoteName != null) { args.remote = remoteName; args.prePickRemote = true; } @@ -99,10 +129,13 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { try { let chosenRemote; const remotes = await this.container.git.getRemotes(repoPath, { sort: true }); + const defaultRemote = remotes.find(r => r.default); if (args.remote && !args.prePickRemote) { chosenRemote = remotes.find(r => r.name === args?.remote); + } else if (defaultRemote != null) { + chosenRemote = defaultRemote; } else { - const pick = await RemotePicker.show( + const pick = await showRemotePicker( `Copy Link to ${deepLinkTypeToString(type)}`, `Choose which remote to copy the link for`, remotes, @@ -113,13 +146,19 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { }, ); if (pick == null) return; - chosenRemote = pick.item; + + chosenRemote = pick; } if (chosenRemote == null) return; if (args.refOrRepoPath == null) { - await this.container.deepLinks.copyDeepLinkUrl(repoPath, chosenRemote.url); + await this.container.deepLinks.copyDeepLinkUrl( + repoPath, + chosenRemote.url, + args.compareRef, + args.compareWithRef, + ); } else { await this.container.deepLinks.copyDeepLinkUrl(args.refOrRepoPath, chosenRemote.url); } @@ -129,3 +168,157 @@ export class CopyDeepLinkCommand extends ActiveEditorCommand { } } } + +export interface CopyFileDeepLinkCommandArgs { + ref?: GitReference; + filePath?: string; + lines?: number[]; + repoPath?: string; + remote?: string; + prePickRemote?: boolean; + chooseRef?: boolean; +} + +@command() +export class CopyFileDeepLinkCommand extends ActiveEditorCommand { + constructor(private readonly container: Container) { + super([Commands.CopyDeepLinkToFile, Commands.CopyDeepLinkToFileAtRevision, Commands.CopyDeepLinkToLines]); + } + + protected override preExecute(context: CommandContext, args?: CopyFileDeepLinkCommandArgs) { + if (args == null) { + args = {}; + } + + if (args.ref == null && context.command === Commands.CopyDeepLinkToFileAtRevision) { + args.chooseRef = true; + } + + if (args.lines == null && context.command === Commands.CopyDeepLinkToLines) { + let lines: number[] | undefined; + if (isCommandContextEditorLine(context) && context.line != null) { + lines = [context.line + 1]; + } else if (context.editor?.selection != null && !context.editor.selection.isEmpty) { + if (context.editor.selection.isSingleLine) { + lines = [context.editor.selection.start.line + 1]; + } else { + lines = [context.editor.selection.start.line + 1, context.editor.selection.end.line + 1]; + } + } + + args.lines = lines; + } + + return this.execute(context.editor, context.uri, args); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: CopyFileDeepLinkCommandArgs) { + args = { ...args }; + + const type = DeepLinkType.File; + let repoPath = args?.repoPath; + let filePath = args?.filePath; + let ref = args?.ref; + if (repoPath == null || filePath == null || ref == null) { + uri = getCommandUri(uri, editor); + const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; + if (gitUri?.path == null || gitUri?.repoPath == null) return; + + if (repoPath == null) { + repoPath = gitUri.repoPath; + } + + if (filePath == null) { + filePath = gitUri?.fsPath; + } + + if (args?.chooseRef !== true && ref == null && repoPath != null && gitUri?.sha != null) { + ref = createReference(gitUri.sha, repoPath, { refType: 'revision' }); + } + + if (repoPath == null || filePath == null) return; + repoPath = normalizePath(repoPath); + filePath = normalizePath(filePath); + + if (!filePath.startsWith(repoPath)) { + Logger.error( + `CopyFileDeepLinkCommand: File path ${filePath} is not contained in repo path ${repoPath}`, + ); + + void showGenericErrorMessage('Unable to copy file link'); + } + + filePath = filePath.substring(repoPath.length + 1); + if (filePath.startsWith('/')) { + filePath = filePath.substring(1); + } + } + + if (!repoPath || !filePath) return; + + if (args?.chooseRef) { + const pick = await showReferencePicker( + repoPath, + `Copy Link to ${filePath} at Reference`, + 'Choose a reference (branch, tag, etc) to copy the file link for', + { + allowRevisions: true, + include: ReferencesQuickPickIncludes.All, + }, + ); + + if (pick == null) { + return; + } else if (pick.ref === '') { + ref = undefined; + } else { + ref = pick; + } + } + + if (!args.remote) { + if (args.ref?.refType === 'branch') { + // If the branch is remote, or has an upstream, pre-select the remote + if (args.ref.remote || args.ref.upstream?.name != null) { + const [branchName, remoteName] = getBranchNameAndRemote(args.ref); + if (branchName != null && remoteName != null) { + args.remote = remoteName; + args.prePickRemote = true; + } + } + } + } + + try { + let chosenRemote; + const remotes = await this.container.git.getRemotes(repoPath, { sort: true }); + const defaultRemote = remotes.find(r => r.default); + if (args.remote && !args.prePickRemote) { + chosenRemote = remotes.find(r => r.name === args?.remote); + } else if (defaultRemote != null) { + chosenRemote = defaultRemote; + } else { + const pick = await showRemotePicker( + `Copy Link to ${deepLinkTypeToString(type)}`, + `Choose which remote to copy the link for`, + remotes, + { + autoPick: true, + picked: args.remote, + setDefault: true, + }, + ); + if (pick == null) return; + + chosenRemote = pick; + } + + if (chosenRemote == null) return; + + await this.container.deepLinks.copyFileDeepLinkUrl(repoPath, filePath, chosenRemote.url, args.lines, ref); + } catch (ex) { + Logger.error(ex, 'CopyFileDeepLinkCommand'); + void showGenericErrorMessage('Unable to copy file link'); + } + } +} diff --git a/src/commands/copyMessageToClipboard.ts b/src/commands/copyMessageToClipboard.ts index 32465169e4f88..03135295878be 100644 --- a/src/commands/copyMessageToClipboard.ts +++ b/src/commands/copyMessageToClipboard.ts @@ -1,13 +1,13 @@ import type { TextEditor, Uri } from 'vscode'; import { env } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { copyMessageToClipboard } from '../git/actions/commit'; import { GitUri } from '../git/gitUri'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { command } from '../system/command'; import { first } from '../system/iterable'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import { ActiveEditorCommand, @@ -83,6 +83,7 @@ export class CopyMessageToClipboardCommand extends ActiveEditorCommand { } else if (args.message == null) { const gitUri = await GitUri.fromUri(uri); repoPath = gitUri.repoPath; + if (!repoPath) return; if (args.sha == null) { const blameline = editor?.selection.active.line ?? 0; @@ -101,7 +102,7 @@ export class CopyMessageToClipboardCommand extends ActiveEditorCommand { return; } } else { - await copyMessageToClipboard({ ref: args.sha, repoPath: repoPath! }); + await copyMessageToClipboard({ ref: args.sha, repoPath: repoPath }); return; } } diff --git a/src/commands/copyRelativePathToClipboard.ts b/src/commands/copyRelativePathToClipboard.ts new file mode 100644 index 0000000000000..263189f1a1871 --- /dev/null +++ b/src/commands/copyRelativePathToClipboard.ts @@ -0,0 +1,36 @@ +import type { TextEditor, Uri } from 'vscode'; +import { env } from 'vscode'; +import { Commands } from '../constants.commands'; +import type { Container } from '../container'; +import { command } from '../system/vscode/command'; +import type { CommandContext } from './base'; +import { ActiveEditorCommand, getCommandUri, isCommandContextViewNodeHasFileCommit } from './base'; + +@command() +export class CopyRelativePathToClipboardCommand extends ActiveEditorCommand { + constructor(private readonly container: Container) { + super(Commands.CopyRelativePathToClipboard); + } + + protected override preExecute(context: CommandContext) { + if (isCommandContextViewNodeHasFileCommit(context)) { + return this.execute(context.editor, context.node.commit.file!.uri); + } + + return this.execute(context.editor, context.uri); + } + + async execute(editor?: TextEditor, uri?: Uri) { + uri = getCommandUri(uri, editor); + let relativePath = ''; + if (uri != null) { + const repoPath = this.container.git.getBestRepository(editor)?.uri; + if (repoPath != null) { + relativePath = this.container.git.getRelativePath(uri, repoPath); + } + } + + await env.clipboard.writeText(relativePath); + return undefined; + } +} diff --git a/src/commands/copyShaToClipboard.ts b/src/commands/copyShaToClipboard.ts index 4fe20f62060a1..d5e4de7b74197 100644 --- a/src/commands/copyShaToClipboard.ts +++ b/src/commands/copyShaToClipboard.ts @@ -1,14 +1,14 @@ import type { TextEditor, Uri } from 'vscode'; import { env } from 'vscode'; -import { configuration } from '../configuration'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; -import { GitRevision } from '../git/models/reference'; -import { Logger } from '../logger'; +import { shortenRevision } from '../git/models/reference'; import { showGenericErrorMessage } from '../messages'; -import { command } from '../system/command'; import { first } from '../system/iterable'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; import type { CommandContext } from './base'; import { ActiveEditorCommand, @@ -86,7 +86,7 @@ export class CopyShaToClipboardCommand extends ActiveEditorCommand { } await env.clipboard.writeText( - configuration.get('advanced.abbreviateShaOnCopy') ? GitRevision.shorten(args.sha) : args.sha, + configuration.get('advanced.abbreviateShaOnCopy') ? shortenRevision(args.sha) : args.sha, ); } catch (ex) { Logger.error(ex, 'CopyShaToClipboardCommand'); diff --git a/src/commands/createPullRequestOnRemote.ts b/src/commands/createPullRequestOnRemote.ts index eb4341b709d30..2a71c27eb92d7 100644 --- a/src/commands/createPullRequestOnRemote.ts +++ b/src/commands/createPullRequestOnRemote.ts @@ -1,10 +1,13 @@ -import { Commands } from '../constants'; +import { window } from 'vscode'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; +import { getRemoteNameFromBranchName } from '../git/models/branch'; import type { GitRemote } from '../git/models/remote'; import type { RemoteResource } from '../git/models/remoteResource'; import { RemoteResourceType } from '../git/models/remoteResource'; import type { RemoteProvider } from '../git/remotes/remoteProvider'; -import { command, executeCommand } from '../system/command'; +import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { command, executeCommand } from '../system/vscode/command'; import { Command } from './base'; import type { OpenOnRemoteCommandArgs } from './openOnRemote'; @@ -24,17 +27,37 @@ export class CreatePullRequestOnRemoteCommand extends Command { } async execute(args?: CreatePullRequestOnRemoteCommandArgs) { - if (args?.repoPath == null) return; - - const repo = this.container.git.getRepository(args.repoPath); + let repo; + if (args?.repoPath != null) { + repo = this.container.git.getRepository(args.repoPath); + } + repo ??= await getRepositoryOrShowPicker('Create Pull Request', undefined, undefined); if (repo == null) return; + if (args == null) { + const branch = await repo.getBranch(); + if (branch?.upstream == null) { + void window.showErrorMessage( + `Unable to create a pull request for branch \`${branch?.name}\` because it has no upstream branch`, + ); + return; + } + + args = { + base: undefined, + compare: branch.name, + remote: getRemoteNameFromBranchName(branch.upstream.name), + repoPath: repo.path, + }; + } + const compareRemote = await repo.getRemote(args.remote); if (compareRemote?.provider == null) return; const providerId = compareRemote.provider.id; const remotes = (await repo.getRemotes({ filter: r => r.provider?.id === providerId, + sort: true, })) as GitRemote[]; const resource: RemoteResource = { diff --git a/src/commands/diffFolderWithRevision.ts b/src/commands/diffFolderWithRevision.ts new file mode 100644 index 0000000000000..15485425ec3b6 --- /dev/null +++ b/src/commands/diffFolderWithRevision.ts @@ -0,0 +1,78 @@ +import type { TextDocumentShowOptions, TextEditor } from 'vscode'; +import { FileType, Uri, workspace } from 'vscode'; +import { GlyphChars } from '../constants'; +import { Commands } from '../constants.commands'; +import type { Container } from '../container'; +import { openFolderCompare } from '../git/actions/commit'; +import { GitUri } from '../git/gitUri'; +import { shortenRevision } from '../git/models/reference'; +import { showGenericErrorMessage } from '../messages'; +import { showCommitPicker } from '../quickpicks/commitPicker'; +import { CommandQuickPickItem } from '../quickpicks/items/common'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { pad } from '../system/string'; +import { command } from '../system/vscode/command'; +import { ActiveEditorCommand, getCommandUri } from './base'; + +export interface DiffFolderWithRevisionCommandArgs { + uri?: Uri; + ref1?: string; + ref2?: string; + showOptions?: TextDocumentShowOptions; +} + +@command() +export class DiffFolderWithRevisionCommand extends ActiveEditorCommand { + constructor(private readonly container: Container) { + super(Commands.DiffFolderWithRevision); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: DiffFolderWithRevisionCommandArgs): Promise { + args = { ...args }; + uri = args?.uri ?? getCommandUri(uri, editor); + if (uri == null) return; + + try { + const stat = await workspace.fs.stat(uri); + if (stat.type !== FileType.Directory) { + uri = Uri.joinPath(uri, '..'); + } + } catch {} + + const gitUri = await GitUri.fromUri(uri); + + try { + const repoPath = (await getBestRepositoryOrShowPicker(uri, editor, `Open Folder Changes with Revision`)) + ?.path; + if (!repoPath) return; + + const log = this.container.git + .getLogForFile(gitUri.repoPath, gitUri.fsPath) + .then( + log => + log ?? + (gitUri.sha + ? this.container.git.getLogForFile(gitUri.repoPath, gitUri.fsPath, { ref: gitUri.sha }) + : undefined), + ); + + const relativePath = this.container.git.getRelativePath(uri, repoPath); + const title = `Open Folder Changes with Revision${pad(GlyphChars.Dot, 2, 2)}${relativePath}${ + gitUri.sha ? ` at ${shortenRevision(gitUri.sha)}` : '' + }`; + const pick = await showCommitPicker(log, title, 'Choose a commit to compare with', { + picked: gitUri.sha, + showOtherReferences: [ + CommandQuickPickItem.fromCommand('Choose a Branch or Tag...', Commands.DiffFolderWithRevisionFrom), + ], + }); + if (pick == null) return; + + void openFolderCompare(uri, { repoPath: repoPath, lhs: pick.ref, rhs: gitUri.sha ?? '' }); + } catch (ex) { + Logger.error(ex, 'DiffFolderWithRevisionCommand'); + void showGenericErrorMessage('Unable to open comparison'); + } + } +} diff --git a/src/commands/diffFolderWithRevisionFrom.ts b/src/commands/diffFolderWithRevisionFrom.ts new file mode 100644 index 0000000000000..e10192bf8a2b8 --- /dev/null +++ b/src/commands/diffFolderWithRevisionFrom.ts @@ -0,0 +1,103 @@ +import type { TextEditor } from 'vscode'; +import { FileType, Uri, workspace } from 'vscode'; +import { GlyphChars } from '../constants'; +import { Commands } from '../constants.commands'; +import type { Container } from '../container'; +import { openFolderCompare } from '../git/actions/commit'; +import { GitUri } from '../git/gitUri'; +import { shortenRevision } from '../git/models/reference'; +import { showGenericErrorMessage } from '../messages'; +import { ReferencesQuickPickIncludes, showReferencePicker } from '../quickpicks/referencePicker'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { pad } from '../system/string'; +import { command } from '../system/vscode/command'; +import { ActiveEditorCommand, getCommandUri } from './base'; + +export interface DiffFolderWithRevisionFromCommandArgs { + uri?: Uri; + lhs?: string; + rhs?: string; +} + +@command() +export class DiffFolderWithRevisionFromCommand extends ActiveEditorCommand { + constructor(private readonly container: Container) { + super(Commands.DiffFolderWithRevisionFrom); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: DiffFolderWithRevisionFromCommandArgs): Promise { + const defaultRHS = args == null; + args = { ...args }; + uri = args?.uri ?? getCommandUri(uri, editor); + if (uri == null) return; + + try { + const stat = await workspace.fs.stat(uri); + if (stat.type !== FileType.Directory) { + uri = Uri.joinPath(uri, '..'); + } + } catch {} + + try { + const repoPath = ( + await getBestRepositoryOrShowPicker(uri, editor, 'Open Folder Changes with Branch or Tag') + )?.path; + if (!repoPath) return; + + const relativePath = this.container.git.getRelativePath(uri, repoPath); + if (args.rhs == null) { + // Default to the current sha or the working tree, if args are missing + if (defaultRHS) { + const gitUri = await GitUri.fromUri(uri); + args.rhs = gitUri.sha ?? ''; + } else { + const pick = await showReferencePicker( + repoPath, + `Open Folder Changes with Branch or Tag${pad(GlyphChars.Dot, 2, 2)}${relativePath}`, + 'Choose a reference (branch, tag, etc) to compare', + { + allowRevisions: true, + include: ReferencesQuickPickIncludes.All, + sort: { branches: { current: true }, tags: {} }, + }, + ); + if (pick?.ref == null) return; + + args.rhs = pick.ref; + } + } + + if (!args.lhs) { + const pick = await showReferencePicker( + repoPath, + `Open Folder Changes with Branch or Tag${pad(GlyphChars.Dot, 2, 2)}${relativePath}${ + args.rhs ? ` at ${shortenRevision(args.rhs)}` : '' + }`, + 'Choose a reference (branch, tag, etc) to compare with', + { + allowRevisions: true, + include: + args.rhs === '' + ? ReferencesQuickPickIncludes.All & ~ReferencesQuickPickIncludes.WorkingTree + : ReferencesQuickPickIncludes.All, + }, + ); + if (pick?.ref == null) return; + + args.lhs = pick.ref; + + // If we are trying to compare to the working tree, swap the lhs and rhs + if (args.rhs !== '' && args.lhs === '') { + args.lhs = args.rhs; + args.rhs = ''; + } + } + + void openFolderCompare(uri, { repoPath: repoPath, lhs: args.lhs, rhs: args.rhs }); + } catch (ex) { + Logger.error(ex, 'DiffFolderWithRevisionFromCommand'); + void showGenericErrorMessage('Unable to open comparison'); + } + } +} diff --git a/src/commands/diffLineWithPrevious.ts b/src/commands/diffLineWithPrevious.ts index ecbe6944e301b..9e1b20ac1a43e 100644 --- a/src/commands/diffLineWithPrevious.ts +++ b/src/commands/diffLineWithPrevious.ts @@ -1,11 +1,12 @@ import type { TextDocumentShowOptions, TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import type { GitCommit } from '../git/models/commit'; -import { Logger } from '../logger'; import { showCommitHasNoPreviousCommitWarningMessage, showGenericErrorMessage } from '../messages'; -import { command, executeCommand } from '../system/command'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; +import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { DiffWithCommandArgs } from './diffWith'; @@ -22,6 +23,14 @@ export class DiffLineWithPreviousCommand extends ActiveEditorCommand { super(Commands.DiffLineWithPrevious); } + protected override preExecute(context: CommandContext, args?: DiffLineWithPreviousCommandArgs): Promise { + if (context.type === 'editorLine') { + args = { ...args, line: context.line }; + } + + return this.execute(context.editor, context.uri, args); + } + async execute(editor?: TextEditor, uri?: Uri, args?: DiffLineWithPreviousCommandArgs): Promise { uri = getCommandUri(uri, editor); if (uri == null) return; @@ -41,7 +50,7 @@ export class DiffLineWithPreviousCommand extends ActiveEditorCommand { gitUri.sha, ); - if (diffUris == null || diffUris.previous == null) { + if (diffUris?.previous == null) { void showCommitHasNoPreviousCommitWarningMessage(); return; diff --git a/src/commands/diffLineWithWorking.ts b/src/commands/diffLineWithWorking.ts index aac808b289192..13986797c7f91 100644 --- a/src/commands/diffLineWithWorking.ts +++ b/src/commands/diffLineWithWorking.ts @@ -1,13 +1,14 @@ import type { TextDocumentShowOptions, TextEditor, Uri } from 'vscode'; import { window } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import type { GitCommit } from '../git/models/commit'; -import { GitRevision } from '../git/models/reference'; -import { Logger } from '../logger'; +import { uncommittedStaged } from '../git/models/constants'; import { showFileNotUnderSourceControlWarningMessage, showGenericErrorMessage } from '../messages'; -import { command, executeCommand } from '../system/command'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; +import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { DiffWithCommandArgs } from './diffWith'; @@ -24,6 +25,14 @@ export class DiffLineWithWorkingCommand extends ActiveEditorCommand { super(Commands.DiffLineWithWorking); } + protected override preExecute(context: CommandContext, args?: DiffLineWithWorkingCommandArgs): Promise { + if (context.type === 'editorLine') { + args = { ...args, line: context.line }; + } + + return this.execute(context.editor, context.uri, args); + } + async execute(editor?: TextEditor, uri?: Uri, args?: DiffLineWithWorkingCommandArgs): Promise { uri = getCommandUri(uri, editor); if (uri == null) return; @@ -56,7 +65,7 @@ export class DiffLineWithWorkingCommand extends ActiveEditorCommand { if (args.commit.isUncommitted) { const status = await this.container.git.getStatusForFile(gitUri.repoPath!, gitUri); if (status?.indexStatus != null) { - lhsSha = GitRevision.uncommittedStaged; + lhsSha = uncommittedStaged; lhsUri = this.container.git.getAbsoluteUri( status.originalPath || status.path, args.commit.repoPath, diff --git a/src/commands/diffWith.ts b/src/commands/diffWith.ts index adfbaf1c79d05..30bf1597a748f 100644 --- a/src/commands/diffWith.ts +++ b/src/commands/diffWith.ts @@ -1,14 +1,17 @@ import type { TextDocumentShowOptions, Uri } from 'vscode'; import { Range, ViewColumn } from 'vscode'; -import { Commands, CoreCommands, GlyphChars } from '../constants'; +import { GlyphChars } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import type { GitCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; -import { GitRevision } from '../git/models/reference'; -import { Logger } from '../logger'; +import { deletedOrMissing } from '../git/models/constants'; +import { isShaLike, isUncommitted, shortenRevision } from '../git/models/reference'; import { showGenericErrorMessage } from '../messages'; -import { command, executeCoreCommand } from '../system/command'; +import { Logger } from '../system/logger'; import { basename } from '../system/path'; +import { command } from '../system/vscode/command'; +import { openDiffEditor } from '../system/vscode/utils'; import { Command } from './base'; export interface DiffWithCommandArgsRevision { @@ -56,6 +59,7 @@ export class DiffWithCommand extends Command { args = { repoPath: commit.repoPath, lhs: { + // Don't need to worry about verifying the previous sha, as the DiffWith command will sha: commit.unresolvedPreviousSha, uri: commit.file.originalUri ?? commit.file.uri, }, @@ -96,19 +100,19 @@ export class DiffWithCommand extends Command { [args.lhs.sha, args.rhs.sha] = await Promise.all([ await this.container.git.resolveReference(args.repoPath, args.lhs.sha, args.lhs.uri, { // If the ref looks like a sha, don't wait too long, since it should work - timeout: GitRevision.isShaLike(args.lhs.sha) ? 100 : undefined, + timeout: isShaLike(args.lhs.sha) ? 100 : undefined, }), await this.container.git.resolveReference(args.repoPath, args.rhs.sha, args.rhs.uri, { // If the ref looks like a sha, don't wait too long, since it should work - timeout: GitRevision.isShaLike(args.rhs.sha) ? 100 : undefined, + timeout: isShaLike(args.rhs.sha) ? 100 : undefined, }), ]); - if (args.lhs.sha !== GitRevision.deletedOrMissing) { + if (args.lhs.sha !== deletedOrMissing) { lhsSha = args.lhs.sha; } - if (args.rhs.sha && args.rhs.sha !== GitRevision.deletedOrMissing) { + if (args.rhs.sha && args.rhs.sha !== deletedOrMissing) { // Ensure that the file still exists in this commit const status = await this.container.git.getFileStatusForCommit( args.repoPath, @@ -116,13 +120,13 @@ export class DiffWithCommand extends Command { args.rhs.sha, ); if (status?.status === 'D') { - args.rhs.sha = GitRevision.deletedOrMissing; + args.rhs.sha = deletedOrMissing; } else { rhsSha = args.rhs.sha; } if (status?.status === 'A' && args.lhs.sha.endsWith('^')) { - args.lhs.sha = GitRevision.deletedOrMissing; + args.lhs.sha = deletedOrMissing; } } @@ -131,11 +135,11 @@ export class DiffWithCommand extends Command { this.container.git.getBestRevisionUri(args.repoPath, args.rhs.uri.fsPath, args.rhs.sha), ]); - let rhsSuffix = GitRevision.shorten(rhsSha, { strings: { uncommitted: 'Working Tree' } }); + let rhsSuffix = shortenRevision(rhsSha, { strings: { uncommitted: 'Working Tree' } }); if (rhs == null) { - if (GitRevision.isUncommitted(args.rhs.sha)) { + if (isUncommitted(args.rhs.sha)) { rhsSuffix = 'deleted'; - } else if (rhsSuffix.length === 0 && args.rhs.sha === GitRevision.deletedOrMissing) { + } else if (rhsSuffix.length === 0 && args.rhs.sha === deletedOrMissing) { rhsSuffix = 'not in Working Tree'; } else { rhsSuffix = `deleted${rhsSuffix.length === 0 ? '' : ` in ${rhsSuffix}`}`; @@ -144,7 +148,7 @@ export class DiffWithCommand extends Command { rhsSuffix = `added${rhsSuffix.length === 0 ? '' : ` in ${rhsSuffix}`}`; } - let lhsSuffix = args.lhs.sha !== GitRevision.deletedOrMissing ? GitRevision.shorten(lhsSha) : ''; + let lhsSuffix = args.lhs.sha !== deletedOrMissing ? shortenRevision(lhsSha) : ''; if (lhs == null && args.rhs.sha.length === 0) { if (rhs != null) { lhsSuffix = lhsSuffix.length === 0 ? '' : `not in ${lhsSuffix}`; @@ -178,15 +182,12 @@ export class DiffWithCommand extends Command { args.showOptions.selection = new Range(args.line, 0, args.line, 0); } - void (await executeCoreCommand( - CoreCommands.Diff, - lhs ?? - this.container.git.getRevisionUri(GitRevision.deletedOrMissing, args.lhs.uri.fsPath, args.repoPath), - rhs ?? - this.container.git.getRevisionUri(GitRevision.deletedOrMissing, args.rhs.uri.fsPath, args.repoPath), + await openDiffEditor( + lhs ?? this.container.git.getRevisionUri(deletedOrMissing, args.lhs.uri.fsPath, args.repoPath), + rhs ?? this.container.git.getRevisionUri(deletedOrMissing, args.rhs.uri.fsPath, args.repoPath), title, args.showOptions, - )); + ); } catch (ex) { Logger.error(ex, 'DiffWithCommand', 'getVersionedFile'); void showGenericErrorMessage('Unable to open compare'); diff --git a/src/commands/diffWithNext.ts b/src/commands/diffWithNext.ts index f6c2cf79262e3..0637a5744cd11 100644 --- a/src/commands/diffWithNext.ts +++ b/src/commands/diffWithNext.ts @@ -1,11 +1,11 @@ import type { Range, TextDocumentShowOptions, TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import type { GitCommit } from '../git/models/commit'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { command, executeCommand } from '../system/command'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { DiffWithCommandArgs } from './diffWith'; @@ -52,7 +52,7 @@ export class DiffWithNextCommand extends ActiveEditorCommand { args.inDiffLeftEditor ? 1 : 0, ); - if (diffUris == null || diffUris.next == null) return; + if (diffUris?.next == null) return; void (await executeCommand(Commands.DiffWith, { repoPath: diffUris.current.repoPath, diff --git a/src/commands/diffWithPrevious.ts b/src/commands/diffWithPrevious.ts index 19351fd9bda72..3c7ce70ad4402 100644 --- a/src/commands/diffWithPrevious.ts +++ b/src/commands/diffWithPrevious.ts @@ -1,13 +1,13 @@ import type { TextDocumentShowOptions, TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import type { GitCommit } from '../git/models/commit'; -import { GitRevision } from '../git/models/reference'; -import { Logger } from '../logger'; +import { deletedOrMissing } from '../git/models/constants'; import { showCommitHasNoPreviousCommitWarningMessage, showGenericErrorMessage } from '../messages'; -import { command, executeCommand } from '../system/command'; -import { findOrOpenEditor } from '../system/utils'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; +import { findOrOpenEditor } from '../system/vscode/utils'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { DiffWithCommandArgs } from './diffWith'; @@ -88,7 +88,7 @@ export class DiffWithPreviousCommand extends ActiveEditorCommand { args.inDiffRightEditor ? 1 : 0, ); - if (diffUris == null || diffUris.previous == null) { + if (diffUris?.previous == null) { if (diffUris == null) { void showCommitHasNoPreviousCommitWarningMessage(); @@ -112,7 +112,7 @@ export class DiffWithPreviousCommand extends ActiveEditorCommand { diffUris.previous = GitUri.fromFile( diffUris.current.fileName, diffUris.current.repoPath!, - GitRevision.deletedOrMissing, + deletedOrMissing, ); } diff --git a/src/commands/diffWithRevision.ts b/src/commands/diffWithRevision.ts index 8122abd51e7dc..a9f94b3273fc3 100644 --- a/src/commands/diffWithRevision.ts +++ b/src/commands/diffWithRevision.ts @@ -1,14 +1,18 @@ import type { TextDocumentShowOptions, TextEditor, Uri } from 'vscode'; -import { Commands, GlyphChars, quickPickTitleMaxChars } from '../constants'; +import { GlyphChars, quickPickTitleMaxChars } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; -import { GitRevision } from '../git/models/reference'; -import { Logger } from '../logger'; +import { shortenRevision } from '../git/models/reference'; import { showGenericErrorMessage } from '../messages'; -import { CommitPicker } from '../quickpicks/commitPicker'; +import { showCommitPicker } from '../quickpicks/commitPicker'; import { CommandQuickPickItem } from '../quickpicks/items/common'; -import { command, executeCommand } from '../system/command'; +import type { DirectiveQuickPickItem } from '../quickpicks/items/directive'; +import { createDirectiveQuickPickItem, Directive } from '../quickpicks/items/directive'; +import { Logger } from '../system/logger'; import { pad } from '../system/string'; +import { command, executeCommand } from '../system/vscode/command'; +import { splitPath } from '../system/vscode/path'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { DiffWithCommandArgs } from './diffWith'; import type { DiffWithRevisionFromCommandArgs } from './diffWithRevisionFrom'; @@ -47,18 +51,68 @@ export class DiffWithRevisionCommand extends ActiveEditorCommand { ); const title = `Open Changes with Revision${pad(GlyphChars.Dot, 2, 2)}`; - const pick = await CommitPicker.show( - log, - `${title}${gitUri.getFormattedFileName({ - suffix: gitUri.sha ? `:${GitRevision.shorten(gitUri.sha)}` : undefined, - truncateTo: quickPickTitleMaxChars - title.length, - })}`, - 'Choose a commit to compare with', - { - picked: gitUri.sha, + const titleWithContext = `${title}${gitUri.getFormattedFileName({ + suffix: gitUri.sha ? `:${shortenRevision(gitUri.sha)}` : undefined, + truncateTo: quickPickTitleMaxChars - title.length, + })}`; + const pick = await showCommitPicker(log, titleWithContext, 'Choose a commit to compare with', { + empty: !gitUri.sha + ? { + getState: async () => { + const items: (CommandQuickPickItem | DirectiveQuickPickItem)[] = []; + + const status = await this.container.git.getStatusForRepo(gitUri.repoPath); + if (status != null) { + for (const f of status.files) { + if (f.workingTreeStatus === '?' || f.workingTreeStatus === '!') { + continue; + } + + const [label, description] = splitPath(f.path, undefined, true); + + items.push( + new CommandQuickPickItem<[Uri]>( + { + label: label, + description: description, + }, + undefined, + Commands.DiffWithRevision, + [this.container.git.getAbsoluteUri(f.path, gitUri.repoPath)], + ), + ); + } + } + + let newPlaceholder; + let newTitle; + + if (items.length) { + newPlaceholder = `${gitUri.getFormattedFileName()} is likely untracked, choose a different file?`; + newTitle = `${titleWithContext} (Untracked?)`; + } else { + newPlaceholder = 'No commits found'; + } + + items.push( + createDirectiveQuickPickItem(Directive.Cancel, undefined, { + label: items.length ? 'Cancel' : 'OK', + }), + ); + + return { + items: items, + placeholder: newPlaceholder, + title: newTitle, + }; + }, + } + : undefined, + picked: gitUri.sha, + keyboard: { keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (key, item) => { - void (await executeCommand(Commands.DiffWith, { + onDidPressKey: async (_key, item) => { + await executeCommand(Commands.DiffWith, { repoPath: gitUri.repoPath, lhs: { sha: item.item.ref, @@ -68,20 +122,24 @@ export class DiffWithRevisionCommand extends ActiveEditorCommand { sha: '', uri: gitUri, }, - line: args!.line, - showOptions: args!.showOptions, - })); + line: args.line, + showOptions: args.showOptions, + }); }, - showOtherReferences: [ - CommandQuickPickItem.fromCommand('Choose a Branch or Tag...', Commands.DiffWithRevisionFrom), - CommandQuickPickItem.fromCommand( - 'Choose a Stash...', - Commands.DiffWithRevisionFrom, - { stash: true }, - ), - ], }, - ); + showOtherReferences: [ + CommandQuickPickItem.fromCommand<[Uri]>( + 'Choose a Branch or Tag...', + Commands.DiffWithRevisionFrom, + [uri], + ), + CommandQuickPickItem.fromCommand<[Uri, DiffWithRevisionFromCommandArgs]>( + 'Choose a Stash...', + Commands.DiffWithRevisionFrom, + [uri, { stash: true }], + ), + ], + }); if (pick == null) return; void (await executeCommand(Commands.DiffWith, { diff --git a/src/commands/diffWithRevisionFrom.ts b/src/commands/diffWithRevisionFrom.ts index d2a3f94705ff3..7b5111c83493b 100644 --- a/src/commands/diffWithRevisionFrom.ts +++ b/src/commands/diffWithRevisionFrom.ts @@ -1,14 +1,15 @@ import type { TextDocumentShowOptions, TextEditor, Uri } from 'vscode'; -import { Commands, GlyphChars, quickPickTitleMaxChars } from '../constants'; +import { GlyphChars, quickPickTitleMaxChars } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; -import { GitReference, GitRevision } from '../git/models/reference'; +import { isBranchReference, shortenRevision } from '../git/models/reference'; import { showNoRepositoryWarningMessage } from '../messages'; -import { StashPicker } from '../quickpicks/commitPicker'; -import { ReferencePicker } from '../quickpicks/referencePicker'; -import { command, executeCommand } from '../system/command'; +import { showStashPicker } from '../quickpicks/commitPicker'; +import { showReferencePicker } from '../quickpicks/referencePicker'; import { basename } from '../system/path'; import { pad } from '../system/string'; +import { command, executeCommand } from '../system/vscode/command'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { DiffWithCommandArgs } from './diffWith'; @@ -30,7 +31,7 @@ export class DiffWithRevisionFromCommand extends ActiveEditorCommand { const gitUri = await GitUri.fromUri(uri); if (!gitUri.repoPath) { - void showNoRepositoryWarningMessage('Unable to open file compare'); + void showNoRepositoryWarningMessage('Unable to open file comparison'); return; } @@ -46,7 +47,7 @@ export class DiffWithRevisionFromCommand extends ActiveEditorCommand { let sha; if (args?.stash) { const title = `Open Changes with Stash${pad(GlyphChars.Dot, 2, 2)}`; - const pick = await StashPicker.show( + const pick = await showStashPicker( this.container.git.getStash(gitUri.repoPath), `${title}${gitUri.getFormattedFileName({ truncateTo: quickPickTitleMaxChars - title.length })}`, 'Choose a stash to compare with', @@ -62,19 +63,18 @@ export class DiffWithRevisionFromCommand extends ActiveEditorCommand { sha = ref; } else { const title = `Open Changes with Branch or Tag${pad(GlyphChars.Dot, 2, 2)}`; - const pick = await ReferencePicker.show( + const pick = await showReferencePicker( gitUri.repoPath, `${title}${gitUri.getFormattedFileName({ truncateTo: quickPickTitleMaxChars - title.length })}`, - 'Choose a branch or tag to compare with', + 'Choose a reference (branch, tag, etc) to compare with', { - allowEnteringRefs: true, - // checkmarks: false, + allowRevisions: true, }, ); if (pick == null) return; ref = pick.ref; - sha = GitReference.isBranch(pick) && pick.remote ? `remotes/${ref}` : ref; + sha = isBranchReference(pick) && pick.remote ? `remotes/${ref}` : ref; } if (ref == null) return; @@ -88,7 +88,7 @@ export class DiffWithRevisionFromCommand extends ActiveEditorCommand { const rename = files.find(s => s.path === path); if (rename?.originalPath != null) { renamedUri = this.container.git.getAbsoluteUri(rename.originalPath, gitUri.repoPath); - renamedTitle = `${basename(rename.originalPath)} (${GitRevision.shorten(ref)})`; + renamedTitle = `${basename(rename.originalPath)} (${shortenRevision(ref)})`; } } @@ -97,7 +97,7 @@ export class DiffWithRevisionFromCommand extends ActiveEditorCommand { lhs: { sha: sha, uri: renamedUri ?? gitUri, - title: renamedTitle ?? `${basename(gitUri.fsPath)} (${GitRevision.shorten(ref)})`, + title: renamedTitle ?? `${basename(gitUri.fsPath)} (${shortenRevision(ref)})`, }, rhs: { sha: '', diff --git a/src/commands/diffWithWorking.ts b/src/commands/diffWithWorking.ts index 18e0956bead5b..4cbab8bf9840b 100644 --- a/src/commands/diffWithWorking.ts +++ b/src/commands/diffWithWorking.ts @@ -1,12 +1,15 @@ import type { TextDocumentShowOptions, TextEditor, Uri } from 'vscode'; import { window } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; -import { GitRevision } from '../git/models/reference'; -import { Logger } from '../logger'; +import { deletedOrMissing, uncommittedStaged } from '../git/models/constants'; +import { createReference } from '../git/models/reference'; import { showGenericErrorMessage } from '../messages'; -import { command, executeCommand } from '../system/command'; +import { showRevisionFilesPicker } from '../quickpicks/revisionFilesPicker'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; +import { findOrOpenEditor } from '../system/vscode/utils'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { DiffWithCommandArgs } from './diffWith'; @@ -15,6 +18,7 @@ export interface DiffWithWorkingCommandArgs { uri?: Uri; line?: number; showOptions?: TextDocumentShowOptions; + lhsTitle?: string; } @command() @@ -64,7 +68,7 @@ export class DiffWithWorkingCommand extends ActiveEditorCommand { return; } - if (gitUri.sha === GitRevision.deletedOrMissing) { + if (gitUri.sha === deletedOrMissing) { void window.showWarningMessage('Unable to open compare. File has been deleted from the working tree'); return; @@ -77,7 +81,7 @@ export class DiffWithWorkingCommand extends ActiveEditorCommand { void (await executeCommand(Commands.DiffWith, { repoPath: gitUri.repoPath, lhs: { - sha: GitRevision.uncommittedStaged, + sha: uncommittedStaged, uri: gitUri.documentUri(), }, rhs: { @@ -94,11 +98,23 @@ export class DiffWithWorkingCommand extends ActiveEditorCommand { uri = gitUri.toFileUri(); - const workingUri = await this.container.git.getWorkingUri(gitUri.repoPath!, uri); + let workingUri = await this.container.git.getWorkingUri(gitUri.repoPath!, uri); if (workingUri == null) { - void window.showWarningMessage('Unable to open compare. File has been deleted from the working tree'); + const pickedUri = await showRevisionFilesPicker(this.container, createReference('HEAD', gitUri.repoPath!), { + ignoreFocusOut: true, + initialPath: gitUri.relativePath, + title: `Open File \u2022 Unable to open '${gitUri.relativePath}'`, + placeholder: 'Choose another working file to open', + keyboard: { + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: async (_key, uri) => { + await findOrOpenEditor(uri, { ...args.showOptions, preserveFocus: true, preview: true }); + }, + }, + }); + if (pickedUri == null) return; - return; + workingUri = pickedUri; } void (await executeCommand(Commands.DiffWith, { @@ -106,6 +122,7 @@ export class DiffWithWorkingCommand extends ActiveEditorCommand { lhs: { sha: gitUri.sha, uri: uri, + title: args?.lhsTitle, }, rhs: { sha: '', diff --git a/src/commands/externalDiff.ts b/src/commands/externalDiff.ts index da60641077056..e3078675a9609 100644 --- a/src/commands/externalDiff.ts +++ b/src/commands/externalDiff.ts @@ -2,16 +2,16 @@ import type { SourceControlResourceState } from 'vscode'; import { env, Uri, window } from 'vscode'; import type { ScmResource } from '../@types/vscode.git.resources'; import { ScmResourceGroupType, ScmStatus } from '../@types/vscode.git.resources.enums'; -import { configuration } from '../configuration'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; -import { GitRevision } from '../git/models/reference'; -import { Logger } from '../logger'; +import { isUncommitted } from '../git/models/reference'; import { showGenericErrorMessage } from '../messages'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; +import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; import { filterMap } from '../system/array'; -import { command } from '../system/command'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; import type { CommandContext } from './base'; import { Command, isCommandContextViewNodeHasFileCommit, isCommandContextViewNodeHasFileRefs } from './base'; @@ -37,7 +37,7 @@ export class ExternalDiffCommand extends Command { if (isCommandContextViewNodeHasFileCommit(context)) { const previousSha = await context.node.commit.getPreviousSha(); - const ref1 = GitRevision.isUncommitted(previousSha) ? '' : previousSha; + const ref1 = isUncommitted(previousSha) ? '' : previousSha; const ref2 = context.node.commit.isUncommitted ? '' : context.node.commit.sha; args.files = [ @@ -85,7 +85,7 @@ export class ExternalDiffCommand extends Command { if (context.command === Commands.ExternalDiffAll) { if (args.files == null) { - const repository = await RepositoryPicker.getRepositoryOrShow('Open All Changes (difftool)'); + const repository = await getRepositoryOrShowPicker('Open All Changes (difftool)'); if (repository == null) return undefined; const status = await this.container.git.getStatusForRepo(repository.uri); diff --git a/src/commands/generateCommitMessage.ts b/src/commands/generateCommitMessage.ts new file mode 100644 index 0000000000000..65b04ee74904a --- /dev/null +++ b/src/commands/generateCommitMessage.ts @@ -0,0 +1,67 @@ +import type { TextEditor, Uri } from 'vscode'; +import { ProgressLocation, window } from 'vscode'; +import { Commands } from '../constants.commands'; +import type { Container } from '../container'; +import { GitUri } from '../git/gitUri'; +import { showGenericErrorMessage } from '../messages'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { command, executeCoreCommand } from '../system/vscode/command'; +import { ActiveEditorCommand, getCommandUri } from './base'; + +export interface GenerateCommitMessageCommandArgs { + repoPath?: string; +} + +@command() +export class GenerateCommitMessageCommand extends ActiveEditorCommand { + constructor(private readonly container: Container) { + super(Commands.GenerateCommitMessage); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: GenerateCommitMessageCommandArgs) { + args = { ...args }; + + let repository; + if (args.repoPath != null) { + repository = this.container.git.getRepository(args.repoPath); + } else { + uri = getCommandUri(uri, editor); + + const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; + + repository = await getBestRepositoryOrShowPicker(gitUri, editor, 'Generate Commit Message'); + } + if (repository == null) return; + + const scmRepo = await this.container.git.getScmRepository(repository.path); + if (scmRepo == null) return; + + try { + const currentMessage = scmRepo.inputBox.value; + const message = await ( + await this.container.ai + )?.generateCommitMessage( + repository, + { source: 'commandPalette' }, + { + context: currentMessage, + progress: { location: ProgressLocation.Notification, title: 'Generating commit message...' }, + }, + ); + if (message == null) return; + + void executeCoreCommand('workbench.view.scm'); + scmRepo.inputBox.value = currentMessage ? `${currentMessage}\n\n${message}` : message; + } catch (ex) { + Logger.error(ex, 'GenerateCommitMessageCommand'); + + if (ex instanceof Error && ex.message.startsWith('No changes')) { + void window.showInformationMessage('No changes to generate a commit message from.'); + return; + } + + void showGenericErrorMessage(ex.message); + } + } +} diff --git a/src/commands/ghpr/openOrCreateWorktree.ts b/src/commands/ghpr/openOrCreateWorktree.ts index 2b0fc1239dd37..d904d6a55446a 100644 --- a/src/commands/ghpr/openOrCreateWorktree.ts +++ b/src/commands/ghpr/openOrCreateWorktree.ts @@ -1,26 +1,29 @@ import type { Uri } from 'vscode'; import { window } from 'vscode'; -import { Commands } from '../../constants'; +import { Commands } from '../../constants.commands'; import type { Container } from '../../container'; -import { add as addRemote } from '../../git/actions/remote'; import { create as createWorktree, open as openWorktree } from '../../git/actions/worktree'; -import { GitReference } from '../../git/models/reference'; +import { getLocalBranchByUpstream } from '../../git/models/branch'; +import type { GitBranchReference } from '../../git/models/reference'; +import { createReference, getReferenceFromBranch } from '../../git/models/reference'; import type { GitRemote } from '../../git/models/remote'; +import { getWorktreeForBranch } from '../../git/models/worktree'; import { parseGitRemoteUrl } from '../../git/parsers/remoteParser'; -import { Logger } from '../../logger'; -import { command } from '../../system/command'; +import { Logger } from '../../system/logger'; import { waitUntilNextTick } from '../../system/promise'; +import { command } from '../../system/vscode/command'; import { Command } from '../base'; -interface PullRequestNode { - readonly pullRequestModel: PullRequest; +interface GHPRPullRequestNode { + readonly pullRequestModel: GHPRPullRequest; } -interface PullRequest { +export interface GHPRPullRequest { readonly base: { readonly repositoryCloneUrl: { - readonly owner: string; readonly repositoryName: string; + readonly owner: string; + readonly url: Uri; }; }; readonly githubRepository: { @@ -30,6 +33,7 @@ interface PullRequest { readonly ref: string; readonly sha: string; readonly repositoryCloneUrl: { + readonly repositoryName: string; readonly owner: string; readonly url: Uri; }; @@ -46,7 +50,7 @@ export class OpenOrCreateWorktreeCommand extends Command { super(Commands.OpenOrCreateWorktreeForGHPR); } - async execute(...args: [PullRequestNode | PullRequest, ...unknown[]]) { + async execute(...args: [GHPRPullRequestNode | GHPRPullRequest, ...unknown[]]) { const [arg] = args; let pr; if ('pullRequestModel' in arg) { @@ -57,9 +61,9 @@ export class OpenOrCreateWorktreeCommand extends Command { const { base: { - repositoryCloneUrl: { owner: rootOwner, repositoryName: rootRepository }, + repositoryCloneUrl: { url: rootUri, owner: rootOwner, repositoryName: rootRepository }, }, - githubRepository: { rootUri }, + githubRepository: { rootUri: localUri }, head: { repositoryCloneUrl: { url: remoteUri, owner: remoteOwner }, ref, @@ -67,78 +71,80 @@ export class OpenOrCreateWorktreeCommand extends Command { item: { number }, } = pr; - let repo = this.container.git.getRepository(rootUri); + let repo = this.container.git.getRepository(localUri); if (repo == null) { - void window.showWarningMessage(`Unable to find repository(${rootUri.toString()}) for PR #${number}`); + void window.showWarningMessage(`Unable to find repository(${localUri.toString()}) for PR #${number}`); return; } - repo = await repo.getMainRepository(); + repo = await repo.getCommonRepository(); if (repo == null) { - void window.showWarningMessage(`Unable to find main repository(${rootUri.toString()}) for PR #${number}`); + void window.showWarningMessage(`Unable to find main repository(${localUri.toString()}) for PR #${number}`); return; } - const worktrees = await repo.getWorktrees(); - const worktree = worktrees.find(w => w.branch === ref); - if (worktree != null) { - void openWorktree(worktree); + const remoteUrl = remoteUri.toString(); + const [, remoteDomain, remotePath] = parseGitRemoteUrl(remoteUrl); - return; + const remotes = await repo.getRemotes({ filter: r => r.matches(remoteDomain, remotePath) }); + const remote = remotes[0] as GitRemote | undefined; + + let addRemote: { name: string; url: string } | undefined; + let remoteName; + if (remote != null) { + remoteName = remote.name; + // Ensure we have the latest from the remote + await this.container.git.fetch(repo.path, { remote: remote.name }); + } else { + remoteName = remoteOwner; + addRemote = { name: remoteOwner, url: remoteUrl }; } - const remoteUrl = remoteUri.toString(); - const [, remoteDomain, remotePath] = parseGitRemoteUrl(remoteUrl); + const remoteBranchName = `${remoteName}/${ref}`; + const localBranchName = `pr/${rootUri.toString() === remoteUri.toString() ? ref : remoteBranchName}`; + const qualifiedRemoteBranchName = `remotes/${remoteBranchName}`; - let remote: GitRemote | undefined; - [remote] = await repo.getRemotes({ filter: r => r.matches(remoteDomain, remotePath) }); - if (remote == null) { - const result = await window.showInformationMessage( - `Unable to find a remote for '${remoteUrl}'. Would you like to add a new remote?`, - { modal: true }, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result?.title !== 'Yes') return; + const worktree = await getWorktreeForBranch(repo, localBranchName, remoteBranchName); + if (worktree != null) { + void openWorktree(worktree, { openOnly: true }); + return; + } - await addRemote(repo, remoteOwner, remoteUrl, { - confirm: false, - fetch: true, - reveal: false, - }); - [remote] = await repo.getRemotes({ filter: r => r.url === remoteUrl }); - if (remote == null) return; + let branchRef: GitBranchReference; + let createBranch: string | undefined; + + const localBranch = await getLocalBranchByUpstream(repo, remoteBranchName); + if (localBranch != null) { + branchRef = getReferenceFromBranch(localBranch); + // TODO@eamodio check if we are behind and if so ask the user to fast-forward } else { - await this.container.git.fetch(repo.path, { remote: remote.name }); + branchRef = createReference(qualifiedRemoteBranchName, repo.path, { + refType: 'branch', + name: qualifiedRemoteBranchName, + remote: true, + }); + createBranch = localBranchName; } await waitUntilNextTick(); try { - await createWorktree( - repo, - undefined, - GitReference.create(`${remote.name}/${ref}`, repo.path, { - refType: 'branch', - name: `${remote.name}/${ref}`, - remote: true, - }), - ); - - // Ensure that the worktree was created - const worktree = await this.container.git.getWorktree(repo.path, w => w.branch === ref); + const worktree = await createWorktree(repo, undefined, branchRef, { + addRemote: addRemote, + createBranch: createBranch, + }); if (worktree == null) return; // Save the PR number in the branch config // https://github.com/Microsoft/vscode-pull-request-github/blob/0c556c48c69a3df2f9cf9a45ed2c40909791b8ab/src/github/pullRequestGitHelper.ts#L18 void this.container.git.setConfig( repo.path, - `branch.${ref}.github-pr-owner-number`, + `branch.${localBranchName}.github-pr-owner-number`, `${rootOwner}#${rootRepository}#${number}`, ); } catch (ex) { Logger.error(ex, 'CreateWorktreeCommand', 'Unable to create worktree'); - void window.showErrorMessage(`Unable to create worktree for ${ref}`); + void window.showErrorMessage(`Unable to create worktree for ${remoteOwner}:${ref}`); } } } diff --git a/src/commands/git/branch.ts b/src/commands/git/branch.ts index cfd67986757f8..5a9c1f119d8f3 100644 --- a/src/commands/git/branch.ts +++ b/src/commands/git/branch.ts @@ -1,13 +1,18 @@ import { QuickInputButtons } from 'vscode'; import type { Container } from '../../container'; -import type { GitBranchReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; +import type { GitBranchReference, GitReference } from '../../git/models/reference'; +import { getNameWithoutRemote, getReferenceLabel, isRevisionReference } from '../../git/models/reference'; import { Repository } from '../../git/models/repository'; +import type { GitWorktree } from '../../git/models/worktree'; +import { getWorktreesByBranch } from '../../git/models/worktree'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; +import { createQuickPickSeparator } from '../../quickpicks/items/common'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; +import { ensureArray } from '../../system/array'; import { pluralize } from '../../system/string'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; +import { getSteps } from '../gitCommands.utils'; import type { AsyncStepResultGenerator, PartialStepState, @@ -18,19 +23,21 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, canPickStepContinue, createConfirmStep, createPickStep, endSteps, + QuickCommand, + StepResultBreak, +} from '../quickCommand'; +import { + appendReposToTitle, inputBranchNameStep, pickBranchesStep, pickBranchOrTagStep, pickBranchStep, pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -39,7 +46,7 @@ interface Context { title: string; } -type CreateFlags = '--switch'; +type CreateFlags = '--switch' | '--worktree'; interface CreateState { subcommand: 'create'; @@ -47,6 +54,8 @@ interface CreateState { reference: GitReference; name: string; flags: CreateFlags[]; + + suggestNameOnly?: boolean; } type DeleteFlags = '--force' | '--remotes'; @@ -58,6 +67,8 @@ interface DeleteState { flags: DeleteFlags[]; } +type PruneState = Replace; + type RenameFlags = '-m'; interface RenameState { @@ -68,7 +79,7 @@ interface RenameState { flags: RenameFlags[]; } -type State = CreateState | DeleteState | RenameState; +type State = CreateState | DeleteState | PruneState | RenameState; type BranchStepState = SomeNonNullable, 'subcommand'>; type CreateStepState = BranchStepState>; @@ -87,6 +98,14 @@ function assertStateStepDelete(state: PartialStepState): asserts state is throw new Error('Missing repository'); } +type PruneStepState = BranchStepState>; +function assertStateStepPrune(state: PartialStepState): asserts state is PruneStepState { + if (state.repo instanceof Repository && state.subcommand === 'prune') return; + + debugger; + throw new Error('Missing repository'); +} + type RenameStepState = BranchStepState>; function assertStateStepRename(state: PartialStepState): asserts state is RenameStepState { if (state.repo instanceof Repository && state.subcommand === 'rename') return; @@ -96,7 +115,7 @@ function assertStateStepRename(state: PartialStepState): asserts state is } function assertStateStepDeleteBranches( - state: DeleteStepState, + state: DeleteStepState | PruneStepState, ): asserts state is ExcludeSome { if (Array.isArray(state.references)) return; @@ -107,6 +126,7 @@ function assertStateStepDeleteBranches( const subcommandToTitleMap = new Map([ ['create', 'Create'], ['delete', 'Delete'], + ['prune', 'Prune'], ['rename', 'Rename'], ]); function getTitle(title: string, subcommand: State['subcommand'] | undefined) { @@ -119,12 +139,12 @@ export interface BranchGitCommandArgs { state?: Partial; } -export class BranchGitCommand extends QuickCommand { +export class BranchGitCommand extends QuickCommand { private subcommand: State['subcommand'] | undefined; constructor(container: Container, args?: BranchGitCommandArgs) { super(container, 'branch', 'branch', 'Branch', { - description: 'create, rename, or delete branches', + description: 'create, prune, rename, or delete branches', }); let counter = 0; @@ -137,12 +157,13 @@ export class BranchGitCommand extends QuickCommand { counter++; } - if (args.state.name != null) { + if (!args.state.suggestNameOnly && args.state.name != null) { counter++; } break; case 'delete': + case 'prune': if ( args.state.references != null && (!Array.isArray(args.state.references) || args.state.references.length !== 0) @@ -180,7 +201,9 @@ export class BranchGitCommand extends QuickCommand { } override get canSkipConfirm(): boolean { - return this.subcommand === 'delete' || this.subcommand === 'rename' ? false : super.canSkipConfirm; + return this.subcommand === 'delete' || this.subcommand === 'prune' || this.subcommand === 'rename' + ? false + : super.canSkipConfirm; } override get skipConfirmKey() { @@ -212,13 +235,18 @@ export class BranchGitCommand extends QuickCommand { this.subcommand = state.subcommand; - context.title = getTitle(state.subcommand === 'delete' ? 'Branches' : this.title, state.subcommand); + context.title = getTitle( + state.subcommand === 'delete' || state.subcommand === 'prune' ? 'Branches' : this.title, + state.subcommand, + ); if (state.counter < 2 || state.repo == null || typeof state.repo === 'string') { skippedStepTwo = false; if (context.repos.length === 1) { skippedStepTwo = true; - state.counter++; + if (state.repo == null) { + state.counter++; + } state.repo = context.repos[0]; } else { @@ -240,6 +268,10 @@ export class BranchGitCommand extends QuickCommand { assertStateStepDelete(state); yield* this.deleteCommandSteps(state, context); break; + case 'prune': + assertStateStepPrune(state); + yield* this.deleteCommandSteps(state, context); + break; case 'rename': assertStateStepRename(state); yield* this.renameCommandSteps(state, context); @@ -277,6 +309,12 @@ export class BranchGitCommand extends QuickCommand { picked: state.subcommand === 'delete', item: 'delete', }, + { + label: 'prune', + description: 'deletes local branches with missing upstreams', + picked: state.subcommand === 'prune', + item: 'prune', + }, { label: 'rename', description: 'renames the specified branch', @@ -302,7 +340,7 @@ export class BranchGitCommand extends QuickCommand { `Choose a branch${context.showTags ? ' or tag' : ''} to create the new branch from`, picked: state.reference?.ref ?? (await state.repo.getBranch())?.ref, titleContext: ' from', - value: GitReference.isRevision(state.reference) ? state.reference.ref : undefined, + value: isRevisionReference(state.reference) ? state.reference.ref : undefined, }); // Always break on the first step (so we will go back) if (result === StepResultBreak) break; @@ -312,13 +350,12 @@ export class BranchGitCommand extends QuickCommand { if (state.counter < 4 || state.name == null) { const result = yield* inputBranchNameStep(state, context, { - placeholder: 'Please provide a name for the new branch', - titleContext: ` from ${GitReference.toString(state.reference, { + titleContext: ` from ${getReferenceLabel(state.reference, { capitalize: true, icon: false, label: state.reference.refType !== 'branch', })}`, - value: state.name ?? GitReference.getNameWithoutRemote(state.reference), + value: state.name ?? getNameWithoutRemote(state.reference), }); if (result === StepResultBreak) continue; @@ -332,6 +369,26 @@ export class BranchGitCommand extends QuickCommand { state.flags = result; } + if (state.flags.includes('--worktree')) { + const worktreeResult = yield* getSteps( + this.container, + { + command: 'worktree', + state: { + subcommand: 'create', + reference: state.reference, + createBranch: state.name, + repo: state.repo, + }, + }, + this.pickedVia, + ); + if (worktreeResult === StepResultBreak) continue; + + endSteps(state); + return; + } + endSteps(state); if (state.flags.includes('--switch')) { await state.repo.switch(state.reference.ref, { createBranch: state.name }); @@ -341,23 +398,24 @@ export class BranchGitCommand extends QuickCommand { } } - private *createCommandConfirmStep( - state: CreateStepState, - context: Context, - ): StepResultGenerator { + private *createCommandConfirmStep(state: CreateStepState, context: Context): StepResultGenerator { const step: QuickPickStep> = createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context), [ createFlagsQuickPickItem(state.flags, [], { label: context.title, - detail: `Will create a new branch named ${state.name} from ${GitReference.toString( + detail: `Will create a new branch named ${state.name} from ${getReferenceLabel(state.reference)}`, + }), + createFlagsQuickPickItem(state.flags, ['--switch'], { + label: `Create & Switch to Branch`, + detail: `Will create and switch to a new branch named ${state.name} from ${getReferenceLabel( state.reference, )}`, }), - createFlagsQuickPickItem(state.flags, ['--switch'], { - label: `${context.title} and Switch`, - description: '--switch', - detail: `Will create and switch to a new branch named ${state.name} from ${GitReference.toString( + createFlagsQuickPickItem(state.flags, ['--worktree'], { + label: `${context.title} in New Worktree`, + description: 'avoids modifying your working tree', + detail: `Will create a new worktree for a new branch named ${state.name} from ${getReferenceLabel( state.reference, )}`, }), @@ -368,7 +426,11 @@ export class BranchGitCommand extends QuickCommand { return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } - private async *deleteCommandSteps(state: DeleteStepState, context: Context): AsyncStepResultGenerator { + private async *deleteCommandSteps( + state: DeleteStepState | PruneStepState, + context: Context, + ): AsyncStepResultGenerator { + const prune = state.subcommand === 'prune'; if (state.flags == null) { state.flags = []; } @@ -378,6 +440,8 @@ export class BranchGitCommand extends QuickCommand { state.references = [state.references]; } + const worktreesByBranch = await getWorktreesByBranch(state.repo, { includeDefault: true }); + if ( state.counter < 3 || state.references == null || @@ -386,9 +450,16 @@ export class BranchGitCommand extends QuickCommand { context.title = getTitle('Branches', state.subcommand); const result = yield* pickBranchesStep(state, context, { - filter: b => !b.current, + filter: prune + ? b => !b.current && Boolean(b.upstream?.missing) && !worktreesByBranch.get(b.id)?.isDefault + : b => !b.current && !worktreesByBranch.get(b.id)?.isDefault, picked: state.references?.map(r => r.ref), - placeholder: 'Choose branches to delete', + placeholder: prune + ? 'Choose branches with missing upstreams to delete' + : 'Choose branches to delete', + emptyPlaceholder: prune + ? `No branches with missing upstreams in ${state.repo.formattedName}` + : undefined, sort: { current: false, missingUpstream: true }, }); // Always break on the first step (so we will go back) @@ -399,10 +470,38 @@ export class BranchGitCommand extends QuickCommand { context.title = getTitle( pluralize('Branch', state.references.length, { only: true, plural: 'Branches' }), - state.subcommand, + state.subcommand === 'prune' ? 'delete' : state.subcommand, ); assertStateStepDeleteBranches(state); + + const worktrees = this.getSelectedWorktrees(state, worktreesByBranch); + if (worktrees.length) { + const result = yield* getSteps( + this.container, + { + command: 'worktree', + state: { + subcommand: 'delete', + repo: state.repo, + uris: worktrees.map(wt => wt.uri), + startingFromBranchDelete: true, + overrides: { + title: `Delete ${worktrees.length === 1 ? 'Worktree' : 'Worktrees'} for ${ + worktrees.length === 1 ? 'Branch' : 'Branches' + }`, + }, + }, + }, + this.pickedVia, + ); + if (result !== StepResultBreak) { + // we get here if it was a step back from the delete worktrees picker + state.counter--; + continue; + } + } + const result = yield* this.deleteCommandConfirmStep(state, context); if (result === StepResultBreak) continue; @@ -416,14 +515,32 @@ export class BranchGitCommand extends QuickCommand { } } + private getSelectedWorktrees( + state: DeleteStepState | PruneStepState, + worktreesByBranch: Map, + ): GitWorktree[] { + const worktrees: GitWorktree[] = []; + + for (const ref of ensureArray(state.references)) { + const worktree = worktreesByBranch.get(ref.id!); + if (worktree != null && !worktree.isDefault) { + worktrees.push(worktree); + } + } + + return worktrees; + } + private *deleteCommandConfirmStep( - state: DeleteStepState>, + state: + | DeleteStepState> + | PruneStepState>, context: Context, ): StepResultGenerator { const confirmations: FlagsQuickPickItem[] = [ createFlagsQuickPickItem(state.flags, [], { label: context.title, - detail: `Will delete ${GitReference.toString(state.references)}`, + detail: `Will delete ${getReferenceLabel(state.references)}`, }), ]; if (!state.references.every(b => b.remote)) { @@ -431,27 +548,22 @@ export class BranchGitCommand extends QuickCommand { createFlagsQuickPickItem(state.flags, ['--force'], { label: `Force ${context.title}`, description: '--force', - detail: `Will forcibly delete ${GitReference.toString(state.references)}`, + detail: `Will forcibly delete ${getReferenceLabel(state.references)}`, }), ); - if (state.references.some(b => b.upstream != null)) { + if (state.subcommand !== 'prune' && state.references.some(b => b.upstream != null)) { confirmations.push( + createQuickPickSeparator(), createFlagsQuickPickItem(state.flags, ['--remotes'], { - label: `${context.title} & Remote${ - state.references.filter(b => !b.remote).length > 1 ? 's' : '' - }`, + label: 'Delete Local & Remote Branches', description: '--remotes', - detail: `Will delete ${GitReference.toString( - state.references, - )} and any remote tracking branches`, + detail: `Will delete ${getReferenceLabel(state.references)} and any remote tracking branches`, }), createFlagsQuickPickItem(state.flags, ['--force', '--remotes'], { - label: `Force ${context.title} & Remote${ - state.references.filter(b => !b.remote).length > 1 ? 's' : '' - }`, + label: 'Force Delete Local & Remote Branches', description: '--force --remotes', - detail: `Will forcibly delete ${GitReference.toString( + detail: `Will forcibly delete ${getReferenceLabel( state.references, )} and any remote tracking branches`, }), @@ -488,10 +600,7 @@ export class BranchGitCommand extends QuickCommand { if (state.counter < 4 || state.name == null) { const result = yield* inputBranchNameStep(state, context, { - placeholder: `Please provide a new name for ${GitReference.toString(state.reference, { - icon: false, - })}`, - titleContext: ` ${GitReference.toString(state.reference, false)}`, + titleContext: ` ${getReferenceLabel(state.reference, false)}`, value: state.name ?? state.reference.name, }); if (result === StepResultBreak) continue; @@ -509,16 +618,13 @@ export class BranchGitCommand extends QuickCommand { } } - private *renameCommandConfirmStep( - state: RenameStepState, - context: Context, - ): StepResultGenerator { + private *renameCommandConfirmStep(state: RenameStepState, context: Context): StepResultGenerator { const step: QuickPickStep> = createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context), [ createFlagsQuickPickItem(state.flags, ['-m'], { label: context.title, - detail: `Will rename ${GitReference.toString(state.reference)} to ${state.name}`, + detail: `Will rename ${getReferenceLabel(state.reference)} to ${state.name}`, }), ], context, diff --git a/src/commands/git/cherry-pick.ts b/src/commands/git/cherry-pick.ts index 4dcfe60ac1f45..1df599e04a0eb 100644 --- a/src/commands/git/cherry-pick.ts +++ b/src/commands/git/cherry-pick.ts @@ -1,7 +1,8 @@ import type { Container } from '../../container'; import type { GitBranch } from '../../git/models/branch'; import type { GitLog } from '../../git/models/log'; -import { GitReference, GitRevision } from '../../git/models/reference'; +import type { GitReference } from '../../git/models/reference'; +import { createRevisionRange, getReferenceLabel, isRevisionReference } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; @@ -15,17 +16,8 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - createConfirmStep, - endSteps, - pickBranchOrTagStep, - pickCommitsStep, - pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, createConfirmStep, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { appendReposToTitle, pickBranchOrTagStep, pickCommitsStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -63,11 +55,18 @@ export class CherryPickGitCommand extends QuickCommand { counter++; } - if ( - args?.state?.references != null && - (!Array.isArray(args.state.references) || args.state.references.length !== 0) - ) { - counter++; + if (args?.state?.references != null) { + if (Array.isArray(args.state.references)) { + if (args.state.references.length > 0) { + if (isRevisionReference(args.state.references[0])) { + counter += 2; + } else { + counter++; + } + } + } else { + counter++; + } } this.initialState = { @@ -138,7 +137,10 @@ export class CherryPickGitCommand extends QuickCommand { context.destination = branch; } - context.title = `${this.title} into ${GitReference.toString(context.destination, { icon: false })}`; + context.title = `${this.title} into ${getReferenceLabel(context.destination, { + icon: false, + label: false, + })}`; if (state.counter < 2 || state.references == null || state.references.length === 0) { const result: StepResult = yield* pickBranchOrTagStep( @@ -161,7 +163,7 @@ export class CherryPickGitCommand extends QuickCommand { continue; } - if (GitReference.isRevision(result)) { + if (isRevisionReference(result)) { state.references = [result]; context.selectedBranchOrTag = undefined; } else { @@ -169,12 +171,27 @@ export class CherryPickGitCommand extends QuickCommand { } } + if (context.selectedBranchOrTag == null && state.references?.length) { + const branches = await this.container.git.getCommitBranches( + state.repo.path, + state.references.map(r => r.ref), + undefined, + { mode: 'contains' }, + ); + if (branches.length) { + const branch = await state.repo.getBranch(branches[0]); + if (branch != null) { + context.selectedBranchOrTag = branch; + } + } + } + if (state.counter < 3 && context.selectedBranchOrTag != null) { - const ref = GitRevision.createRange(context.destination.ref, context.selectedBranchOrTag.ref); + const ref = createRevisionRange(context.destination.ref, context.selectedBranchOrTag.ref, '..'); let log = context.cache.get(ref); if (log == null) { - log = this.container.git.getLog(state.repo.path, { ref: ref, merges: false }); + log = this.container.git.getLog(state.repo.path, { ref: ref, merges: 'first-parent' }); context.cache.set(ref, log); } @@ -187,10 +204,10 @@ export class CherryPickGitCommand extends QuickCommand { picked: state.references?.map(r => r.ref), placeholder: (context, log) => log == null - ? `No pickable commits found on ${GitReference.toString(context.selectedBranchOrTag, { + ? `No pickable commits found on ${getReferenceLabel(context.selectedBranchOrTag, { icon: false, })}` - : `Choose commits to cherry-pick into ${GitReference.toString(context.destination, { + : `Choose commits to cherry-pick into ${getReferenceLabel(context.destination, { icon: false, })}`, }, @@ -220,22 +237,24 @@ export class CherryPickGitCommand extends QuickCommand { [ createFlagsQuickPickItem(state.flags, [], { label: this.title, - detail: `Will apply ${GitReference.toString(state.references)} to ${GitReference.toString( + detail: `Will apply ${getReferenceLabel(state.references, { label: false })} to ${getReferenceLabel( context.destination, + { label: false }, )}`, }), createFlagsQuickPickItem(state.flags, ['--edit'], { label: `${this.title} & Edit`, description: '--edit', - detail: `Will edit and apply ${GitReference.toString(state.references)} to ${GitReference.toString( - context.destination, - )}`, + detail: `Will edit and apply ${getReferenceLabel(state.references, { + label: false, + })} to ${getReferenceLabel(context.destination, { label: false })}`, }), createFlagsQuickPickItem(state.flags, ['--no-commit'], { label: `${this.title} without Committing`, description: '--no-commit', - detail: `Will apply ${GitReference.toString(state.references)} to ${GitReference.toString( + detail: `Will apply ${getReferenceLabel(state.references, { label: false })} to ${getReferenceLabel( context.destination, + { label: false }, )} without Committing`, }), ], diff --git a/src/commands/git/coauthors.ts b/src/commands/git/coauthors.ts index 9fe2ce7ba8b98..21dad4d9ee866 100644 --- a/src/commands/git/coauthors.ts +++ b/src/commands/git/coauthors.ts @@ -1,12 +1,12 @@ -import { CoreCommands } from '../../constants'; import type { Container } from '../../container'; import type { GitContributor } from '../../git/models/contributor'; import type { Repository } from '../../git/models/repository'; -import { executeCoreCommand } from '../../system/command'; import { normalizePath } from '../../system/path'; +import { executeCoreCommand } from '../../system/vscode/command'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { PartialStepState, StepGenerator, StepState } from '../quickCommand'; -import { endSteps, pickContributorsStep, pickRepositoryStep, QuickCommand, StepResultBreak } from '../quickCommand'; +import { endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { pickContributorsStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -64,7 +64,7 @@ export class CoAuthorsGitCommand extends QuickCommand { const index = message.indexOf('Co-authored-by: '); if (index !== -1) { - message = message.substring(0, index - 1).trimRight(); + message = message.substring(0, index - 1).trimEnd(); } if (state.contributors != null && !Array.isArray(state.contributors)) { @@ -85,7 +85,7 @@ export class CoAuthorsGitCommand extends QuickCommand { } repo.inputBox.value = message; - void (await executeCoreCommand(CoreCommands.ShowSCM)); + void (await executeCoreCommand('workbench.view.scm')); } protected async *steps(state: PartialStepState): StepGenerator { diff --git a/src/commands/git/fetch.ts b/src/commands/git/fetch.ts index b5e4414805869..b13e43473f2b0 100644 --- a/src/commands/git/fetch.ts +++ b/src/commands/git/fetch.ts @@ -1,7 +1,7 @@ import { GlyphChars } from '../../constants'; import type { Container } from '../../container'; import type { GitBranchReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; +import { getReferenceLabel, isBranchReference } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; @@ -17,15 +17,8 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - createConfirmStep, - endSteps, - pickRepositoriesStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, createConfirmStep, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { appendReposToTitle, pickRepositoriesStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -66,7 +59,7 @@ export class FetchGitCommand extends QuickCommand { } execute(state: FetchStepState) { - if (GitReference.isBranch(state.reference)) { + if (isBranchReference(state.reference)) { return state.repos[0].fetch({ branch: state.reference }); } @@ -100,7 +93,9 @@ export class FetchGitCommand extends QuickCommand { skippedStepOne = false; if (context.repos.length === 1) { skippedStepOne = true; - state.counter++; + if (state.repos == null) { + state.counter++; + } state.repos = [context.repos[0]]; } else { @@ -148,21 +143,19 @@ export class FetchGitCommand extends QuickCommand { let step: QuickPickStep>; - if (state.repos.length === 1 && GitReference.isBranch(state.reference)) { + if (state.repos.length === 1 && isBranchReference(state.reference)) { step = this.createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context, lastFetchedOn), [ createFlagsQuickPickItem(state.flags, [], { label: this.title, - detail: `Will fetch ${GitReference.toString(state.reference)}`, + detail: `Will fetch ${getReferenceLabel(state.reference)}`, }), ], ); } else { const reposToFetch = - state.repos.length === 1 - ? `$(repo) ${state.repos[0].formattedName}` - : `${state.repos.length} repositories`; + state.repos.length === 1 ? `$(repo) ${state.repos[0].formattedName}` : `${state.repos.length} repos`; step = createConfirmStep( appendReposToTitle(`Confirm ${this.title}`, state, context, lastFetchedOn), diff --git a/src/commands/git/log.ts b/src/commands/git/log.ts index 5761d679d6729..5f8ecff93090f 100644 --- a/src/commands/git/log.ts +++ b/src/commands/git/log.ts @@ -3,21 +3,16 @@ import type { Container } from '../../container'; import { showDetailsView } from '../../git/actions/commit'; import { GitCommit } from '../../git/models/commit'; import type { GitLog } from '../../git/models/log'; -import { GitReference } from '../../git/models/reference'; +import type { GitReference } from '../../git/models/reference'; +import { getReferenceLabel, isRevisionRangeReference, isRevisionReference } from '../../git/models/reference'; import { Repository } from '../../git/models/repository'; -import { formatPath } from '../../system/formatPath'; import { pad } from '../../system/string'; +import { formatPath } from '../../system/vscode/formatPath'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import { getSteps } from '../gitCommands.utils'; import type { PartialStepState, StepGenerator, StepResult } from '../quickCommand'; -import { - endSteps, - pickBranchOrTagStep, - pickCommitStep, - pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { pickBranchOrTagStep, pickCommitStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -66,8 +61,8 @@ export class LogGitCommand extends QuickCommand { counter++; if ( args.state.reference !== 'HEAD' && - GitReference.isRevision(args.state.reference) && - !GitReference.isRevisionRange(args.state.reference) + isRevisionReference(args.state.reference) && + !isRevisionRangeReference(args.state.reference) ) { counter++; } @@ -147,14 +142,13 @@ export class LogGitCommand extends QuickCommand { context.selectedBranchOrTag = undefined; } - if (!GitReference.isRevision(state.reference) || GitReference.isRevisionRange(state.reference)) { + if (!isRevisionReference(state.reference) || isRevisionRangeReference(state.reference)) { context.selectedBranchOrTag = state.reference; } - context.title = `${this.title}${pad(GlyphChars.Dot, 2, 2)}${GitReference.toString( - context.selectedBranchOrTag, - { icon: false }, - )}`; + context.title = `${this.title}${pad(GlyphChars.Dot, 2, 2)}${getReferenceLabel(context.selectedBranchOrTag, { + icon: false, + })}`; if (state.fileName) { context.title += `${pad(GlyphChars.Dot, 2, 2)}${formatPath(state.fileName, { @@ -181,7 +175,7 @@ export class LogGitCommand extends QuickCommand { onDidLoadMore: log => context.cache.set(ref, Promise.resolve(log)), placeholder: (context, log) => log == null - ? `No commits found in ${GitReference.toString(context.selectedBranchOrTag, { + ? `No commits found in ${getReferenceLabel(context.selectedBranchOrTag, { icon: false, })}` : 'Choose a commit', diff --git a/src/commands/git/merge.ts b/src/commands/git/merge.ts index b1ef665053b32..34f08e4c01cc2 100644 --- a/src/commands/git/merge.ts +++ b/src/commands/git/merge.ts @@ -1,7 +1,8 @@ import type { Container } from '../../container'; import type { GitBranch } from '../../git/models/branch'; import type { GitLog } from '../../git/models/log'; -import { GitReference, GitRevision } from '../../git/models/reference'; +import type { GitReference } from '../../git/models/reference'; +import { createRevisionRange, getReferenceLabel, isRevisionReference } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive'; import { createDirectiveQuickPickItem, Directive } from '../../quickpicks/items/directive'; @@ -18,17 +19,9 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - pickBranchOrTagStep, - pickCommitStep, - pickRepositoryStep, - QuickCommand, - QuickCommandButtons, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { PickCommitToggleQuickInputButton } from '../quickCommand.buttons'; +import { appendReposToTitle, pickBranchOrTagStep, pickCommitStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -84,7 +77,7 @@ export class MergeGitCommand extends QuickCommand { } execute(state: MergeStepState) { - return state.repo.merge(...state.flags, state.reference.ref); + state.repo.merge(...state.flags, state.reference.ref); } protected async *steps(state: PartialStepState): StepGenerator { @@ -134,11 +127,14 @@ export class MergeGitCommand extends QuickCommand { context.destination = branch; } - context.title = `${this.title} into ${GitReference.toString(context.destination, { icon: false })}`; + context.title = `${this.title} into ${getReferenceLabel(context.destination, { + icon: false, + label: false, + })}`; context.pickCommitForItem = false; if (state.counter < 2 || state.reference == null) { - const pickCommitToggle = new QuickCommandButtons.PickCommitToggle(context.pickCommit, context, () => { + const pickCommitToggle = new PickCommitToggleQuickInputButton(context.pickCommit, context, () => { context.pickCommit = !context.pickCommit; pickCommitToggle.on = context.pickCommit; }); @@ -162,7 +158,7 @@ export class MergeGitCommand extends QuickCommand { context.selectedBranchOrTag = undefined; } - if (!GitReference.isRevision(state.reference)) { + if (!isRevisionReference(state.reference)) { context.selectedBranchOrTag = state.reference; } @@ -175,7 +171,7 @@ export class MergeGitCommand extends QuickCommand { let log = context.cache.get(ref); if (log == null) { - log = this.container.git.getLog(state.repo.path, { ref: ref, merges: false }); + log = this.container.git.getLog(state.repo.path, { ref: ref, merges: 'first-parent' }); context.cache.set(ref, log); } @@ -185,10 +181,10 @@ export class MergeGitCommand extends QuickCommand { onDidLoadMore: log => context.cache.set(ref, Promise.resolve(log)), placeholder: (context, log) => log == null - ? `No commits found on ${GitReference.toString(context.selectedBranchOrTag, { + ? `No commits found on ${getReferenceLabel(context.selectedBranchOrTag, { icon: false, })}` - : `Choose a commit to merge into ${GitReference.toString(context.destination, { + : `Choose a commit to merge into ${getReferenceLabel(context.destination, { icon: false, })}`, picked: state.reference?.ref, @@ -211,20 +207,33 @@ export class MergeGitCommand extends QuickCommand { } private async *confirmStep(state: MergeStepState, context: Context): AsyncStepResultGenerator { - const aheadBehind = await this.container.git.getAheadBehindCommitCount(state.repo.path, [ - GitRevision.createRange(context.destination.name, state.reference.name), - ]); - const count = aheadBehind != null ? aheadBehind.ahead + aheadBehind.behind : 0; + const counts = await this.container.git.getLeftRightCommitCount( + state.repo.path, + createRevisionRange(context.destination.ref, state.reference.ref, '...'), + ); + + const title = `Merge ${getReferenceLabel(state.reference, { + icon: false, + label: false, + })} into ${getReferenceLabel(context.destination, { icon: false, label: false })} `; + const count = counts != null ? counts.right : 0; if (count === 0) { const step: QuickPickStep = this.createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), + appendReposToTitle(title, state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: `Cancel ${this.title}`, - detail: `${GitReference.toString(context.destination, { + label: 'OK', + detail: `${getReferenceLabel(context.destination, { capitalize: true, - })} is up to date with ${GitReference.toString(state.reference)}`, + label: false, + })} is already up to date with ${getReferenceLabel(state.reference, { label: false })}`, }), + { + placeholder: `Nothing to merge; ${getReferenceLabel(context.destination, { + label: false, + icon: false, + })} is already up to date`, + }, ); const selection: StepSelection = yield step; canPickStepContinue(step, state, selection); @@ -232,44 +241,49 @@ export class MergeGitCommand extends QuickCommand { } const step: QuickPickStep> = this.createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), + appendReposToTitle(`Confirm ${title}`, state, context), [ createFlagsQuickPickItem(state.flags, [], { label: this.title, - detail: `Will merge ${pluralize('commit', count)} from ${GitReference.toString( - state.reference, - )} into ${GitReference.toString(context.destination)}`, + detail: `Will merge ${pluralize('commit', count)} from ${getReferenceLabel(state.reference, { + label: false, + })} into ${getReferenceLabel(context.destination, { label: false })}`, }), createFlagsQuickPickItem(state.flags, ['--ff-only'], { label: `Fast-forward ${this.title}`, description: '--ff-only', - detail: `Will fast-forward merge ${pluralize('commit', count)} from ${GitReference.toString( + detail: `Will fast-forward merge ${pluralize('commit', count)} from ${getReferenceLabel( state.reference, - )} into ${GitReference.toString(context.destination)}`, + { label: false }, + )} into ${getReferenceLabel(context.destination, { label: false })}`, }), createFlagsQuickPickItem(state.flags, ['--squash'], { label: `Squash ${this.title}`, description: '--squash', - detail: `Will squash ${pluralize('commit', count)} from ${GitReference.toString( - state.reference, - )} into one when merging into ${GitReference.toString(context.destination)}`, + detail: `Will squash ${pluralize('commit', count)} from ${getReferenceLabel(state.reference, { + label: false, + })} into one when merging into ${getReferenceLabel(context.destination, { label: false })}`, }), createFlagsQuickPickItem(state.flags, ['--no-ff'], { - label: `${this.title} without Fast-Forwarding`, + label: `No Fast-forward ${this.title}`, description: '--no-ff', detail: `Will create a merge commit when merging ${pluralize( 'commit', count, - )} from ${GitReference.toString(state.reference)} into ${GitReference.toString( + )} from ${getReferenceLabel(state.reference, { label: false })} into ${getReferenceLabel( context.destination, + { label: false }, )}`, }), createFlagsQuickPickItem(state.flags, ['--no-ff', '--no-commit'], { - label: `${this.title} without Fast-Forwarding or Committing`, - description: '--no-ff --no-commit', - detail: `Will merge ${pluralize('commit', count)} from ${GitReference.toString( - state.reference, - )} into ${GitReference.toString(context.destination)} without Committing`, + label: `Don't Commit ${this.title}`, + description: '--no-commit --no-ff', + detail: `Will pause before committing the merge of ${pluralize( + 'commit', + count, + )} from ${getReferenceLabel(state.reference, { + label: false, + })} into ${getReferenceLabel(context.destination, { label: false })}`, }), ], ); diff --git a/src/commands/git/pull.ts b/src/commands/git/pull.ts index e57bc92dc4a0d..7e57de3b5c17d 100644 --- a/src/commands/git/pull.ts +++ b/src/commands/git/pull.ts @@ -2,7 +2,7 @@ import { GlyphChars } from '../../constants'; import type { Container } from '../../container'; import { isBranch } from '../../git/models/branch'; import type { GitBranchReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; +import { getReferenceLabel, isBranchReference } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import { createDirectiveQuickPickItem, Directive } from '../../quickpicks/items/directive'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; @@ -19,15 +19,9 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - pickRepositoriesStep, - QuickCommand, - QuickCommandButtons, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { FetchQuickInputButton } from '../quickCommand.buttons'; +import { appendReposToTitle, pickRepositoriesStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -70,7 +64,7 @@ export class PullGitCommand extends QuickCommand { } async execute(state: PullStepState) { - if (GitReference.isBranch(state.reference)) { + if (isBranchReference(state.reference)) { // Only resort to a branch fetch if the branch isn't the current one if (!isBranch(state.reference) || !state.reference.current) { const currentBranch = await state.repos[0].getBranch(); @@ -107,7 +101,9 @@ export class PullGitCommand extends QuickCommand { skippedStepOne = false; if (context.repos.length === 1) { skippedStepOne = true; - state.counter++; + if (state.repos == null) { + state.counter++; + } state.repos = [context.repos[0]]; } else { @@ -151,15 +147,15 @@ export class PullGitCommand extends QuickCommand { step = this.createConfirmStep(appendReposToTitle(`Confirm ${context.title}`, state, context), [ createFlagsQuickPickItem(state.flags, [], { label: this.title, - detail: `Will pull ${state.repos.length} repositories`, + detail: `Will pull ${state.repos.length} repos`, }), createFlagsQuickPickItem(state.flags, ['--rebase'], { label: `${this.title} with Rebase`, description: '--rebase', - detail: `Will pull ${state.repos.length} repositories by rebasing`, + detail: `Will pull ${state.repos.length} repos by rebasing`, }), ]); - } else if (GitReference.isBranch(state.reference)) { + } else if (isBranchReference(state.reference)) { if (state.reference.remote) { step = this.createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context), @@ -188,10 +184,8 @@ export class PullGitCommand extends QuickCommand { label: this.title, detail: `Will pull${ branch.state.behind - ? ` ${pluralize('commit', branch.state.behind)} into ${GitReference.toString( - branch, - )}` - : ` into ${GitReference.toString(branch)}` + ? ` ${pluralize('commit', branch.state.behind)} into ${getReferenceLabel(branch)}` + : ` into ${getReferenceLabel(branch)}` }`, }), ]); @@ -226,9 +220,9 @@ export class PullGitCommand extends QuickCommand { ], undefined, { - additionalButtons: [QuickCommandButtons.Fetch], + additionalButtons: [FetchQuickInputButton], onDidClickButton: async (quickpick, button) => { - if (button !== QuickCommandButtons.Fetch || quickpick.busy) return false; + if (button !== FetchQuickInputButton || quickpick.busy) return false; quickpick.title = `Confirm ${context.title}${pad(GlyphChars.Dot, 2, 2)}Fetching${ GlyphChars.Ellipsis diff --git a/src/commands/git/push.ts b/src/commands/git/push.ts index 252667380884f..661e8146efb71 100644 --- a/src/commands/git/push.ts +++ b/src/commands/git/push.ts @@ -1,9 +1,9 @@ -import { configuration } from '../../configuration'; -import { CoreGitConfiguration, GlyphChars } from '../../constants'; +import { GlyphChars } from '../../constants'; import type { Container } from '../../container'; +import { Features } from '../../features'; import { getRemoteNameFromBranchName } from '../../git/models/branch'; -import type { GitBranchReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; +import type { GitBranchReference, GitReference } from '../../git/models/reference'; +import { getReferenceLabel, isBranchReference } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import { createDirectiveQuickPickItem, Directive } from '../../quickpicks/items/directive'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; @@ -11,6 +11,7 @@ import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; import { isStringArray } from '../../system/array'; import { fromNow } from '../../system/date'; import { pad, pluralize } from '../../system/string'; +import { configuration } from '../../system/vscode/configuration'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { AsyncStepResultGenerator, @@ -20,16 +21,9 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - pickRepositoriesStep, - pickRepositoryStep, - QuickCommand, - QuickCommandButtons, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { FetchQuickInputButton } from '../quickCommand.buttons'; +import { appendReposToTitle, pickRepositoriesStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -74,8 +68,6 @@ export class PushGitCommand extends QuickCommand { execute(state: State) { const index = state.flags.indexOf('--set-upstream'); if (index !== -1) { - if (!GitReference.isBranch(state.reference)) return Promise.resolve(); - return this.container.git.pushAll(state.repos, { force: false, publish: { remote: state.flags[index + 1] }, @@ -113,7 +105,9 @@ export class PushGitCommand extends QuickCommand { skippedStepOne = false; if (context.repos.length === 1) { skippedStepOne = true; - state.counter++; + if (state.repos == null) { + state.counter++; + } state.repos = [context.repos[0]]; } else if (state.reference != null) { @@ -160,7 +154,11 @@ export class PushGitCommand extends QuickCommand { } private async *confirmStep(state: PushStepState, context: Context): AsyncStepResultGenerator { - const useForceWithLease = configuration.getAny(CoreGitConfiguration.UseForcePushWithLease) ?? false; + const useForceWithLease = configuration.getCore('git.useForcePushWithLease') ?? true; + const useForceIfIncludes = + useForceWithLease && + (configuration.getCore('git.useForcePushIfIncludes') ?? true) && + (await this.container.git.supports(state.repos[0].uri, Features.ForceIfIncludes)); let step: QuickPickStep>; @@ -168,14 +166,18 @@ export class PushGitCommand extends QuickCommand { step = this.createConfirmStep(appendReposToTitle(`Confirm ${context.title}`, state, context), [ createFlagsQuickPickItem(state.flags, [], { label: this.title, - detail: `Will push ${state.repos.length} repositories`, + detail: `Will push ${state.repos.length} repos`, }), createFlagsQuickPickItem(state.flags, ['--force'], { - label: `Force ${this.title}${useForceWithLease ? ' (with lease)' : ''}`, - description: `--force${useForceWithLease ? '-with-lease' : ''}`, - detail: `Will force push${useForceWithLease ? ' (with lease)' : ''} ${ - state.repos.length - } repositories`, + label: `Force ${this.title}${ + useForceIfIncludes ? ' (with lease and if includes)' : useForceWithLease ? ' (with lease)' : '' + }`, + description: `--force${ + useForceWithLease ? `-with-lease${useForceIfIncludes ? ' --force-if-includes' : ''}` : '' + }`, + detail: `Will force push${ + useForceIfIncludes ? ' (with lease and if includes)' : useForceWithLease ? ' (with lease)' : '' + } ${state.repos.length} repos`, }), ]); } else { @@ -183,15 +185,16 @@ export class PushGitCommand extends QuickCommand { const items: FlagsQuickPickItem[] = []; - if (GitReference.isBranch(state.reference)) { + if (isBranchReference(state.reference)) { if (state.reference.remote) { step = this.createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), + appendReposToTitle(context.title, state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: `Cancel ${this.title}`, - detail: 'Cannot push remote branch', + label: 'OK', + detail: 'Cannot push a remote branch', }), + { placeholder: 'Cannot push a remote branch' }, ); } else { const branch = await repo.getBranch(state.reference.name); @@ -204,7 +207,7 @@ export class PushGitCommand extends QuickCommand { ['--set-upstream', remote.name, branch.name], { label: `Publish ${branch.name} to ${remote.name}`, - detail: `Will publish ${GitReference.toString(branch)} to ${remote.name}`, + detail: `Will publish ${getReferenceLabel(branch)} to ${remote.name}`, }, ), ); @@ -219,13 +222,13 @@ export class PushGitCommand extends QuickCommand { ); } else { step = this.createConfirmStep( - appendReposToTitle('Confirm Publish', state, context), + appendReposToTitle('Publish', state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: 'Cancel Publish', - detail: 'Cannot publish; No remotes found', + label: 'OK', + detail: 'No remotes found', }), - { placeholder: 'Confirm Publish' }, + { placeholder: 'Cannot publish; No remotes found' }, ); } } else if (branch != null && branch?.state.behind > 0) { @@ -233,11 +236,27 @@ export class PushGitCommand extends QuickCommand { appendReposToTitle(`Confirm ${context.title}`, state, context), [ createFlagsQuickPickItem(state.flags, ['--force'], { - label: `Force ${this.title}${useForceWithLease ? ' (with lease)' : ''}`, - description: `--force${useForceWithLease ? '-with-lease' : ''}`, - detail: `Will force push${useForceWithLease ? ' (with lease)' : ''} ${ - branch?.state.ahead ? ` ${pluralize('commit', branch.state.ahead)}` : '' - }${branch.getRemoteName() ? ` to ${branch.getRemoteName()}` : ''}${ + label: `Force ${this.title}${ + useForceIfIncludes + ? ' (with lease and if includes)' + : useForceWithLease + ? ' (with lease)' + : '' + }`, + description: `--force${ + useForceWithLease + ? `-with-lease${useForceIfIncludes ? ' --force-if-includes' : ''}` + : '' + }`, + detail: `Will force push${ + useForceIfIncludes + ? ' (with lease and if includes)' + : useForceWithLease + ? ' (with lease)' + : '' + } ${branch?.state.ahead ? ` ${pluralize('commit', branch.state.ahead)}` : ''}${ + branch.getRemoteName() ? ` to ${branch.getRemoteName()}` : '' + }${ branch != null && branch.state.behind > 0 ? `, overwriting ${pluralize('commit', branch.state.behind)}${ branch?.getRemoteName() ? ` on ${branch.getRemoteName()}` : '' @@ -248,7 +267,7 @@ export class PushGitCommand extends QuickCommand { ], createDirectiveQuickPickItem(Directive.Cancel, true, { label: `Cancel ${this.title}`, - detail: `Cannot push; ${GitReference.toString( + detail: `Cannot push; ${getReferenceLabel( branch, )} is behind ${branch.getRemoteName()} by ${pluralize('commit', branch.state.behind)}`, }), @@ -257,20 +276,20 @@ export class PushGitCommand extends QuickCommand { step = this.createConfirmStep(appendReposToTitle(`Confirm ${context.title}`, state, context), [ createFlagsQuickPickItem(state.flags, [branch.getRemoteName()!], { label: this.title, - detail: `Will push ${pluralize( - 'commit', - branch.state.ahead, - )} from ${GitReference.toString(branch)} to ${branch.getRemoteName()}`, + detail: `Will push ${pluralize('commit', branch.state.ahead)} from ${getReferenceLabel( + branch, + )} to ${branch.getRemoteName()}`, }), ]); } else { step = this.createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), + appendReposToTitle(context.title, state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: `Cancel ${this.title}`, + label: 'OK', detail: 'No commits found to push', }), + { placeholder: 'Nothing to push; No commits found to push' }, ); } } @@ -286,8 +305,17 @@ export class PushGitCommand extends QuickCommand { }; if (status?.state.ahead === 0) { - if (state.reference == null && status.upstream == null) { - state.reference = branch; + if (!isBranchReference(state.reference) && status.upstream == null) { + let pushDetails; + + if (state.reference != null) { + pushDetails = ` up to and including ${getReferenceLabel(state.reference, { + label: false, + })}`; + } else { + state.reference = branch; + pushDetails = ''; + } for (const remote of await repo.getRemotes()) { items.push( @@ -296,7 +324,9 @@ export class PushGitCommand extends QuickCommand { ['--set-upstream', remote.name, status.branch], { label: `Publish ${branch.name} to ${remote.name}`, - detail: `Will publish ${GitReference.toString(branch)} to ${remote.name}`, + detail: `Will publish ${getReferenceLabel(branch)}${pushDetails} to ${ + remote.name + }`, }, ), ); @@ -312,24 +342,27 @@ export class PushGitCommand extends QuickCommand { ); } else if (status.upstream == null) { step = this.createConfirmStep( - appendReposToTitle('Confirm Publish', state, context), + appendReposToTitle('Publish', state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: 'Cancel Publish', - detail: 'Cannot publish; No remotes found', + label: 'OK', + detail: 'No remotes found', }), - { placeholder: 'Confirm Publish' }, + { placeholder: 'Cannot publish; No remotes found' }, ); } else { step = this.createConfirmStep( - appendReposToTitle('Confirm Push', state, context), + appendReposToTitle(context.title, state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: `Cancel ${this.title}`, - detail: `Cannot push; No commits ahead of ${getRemoteNameFromBranchName( - status.upstream, - )}`, + label: 'OK', + detail: `No commits ahead of ${getRemoteNameFromBranchName(status.upstream?.name)}`, }), + { + placeholder: `Nothing to push; No commits ahead of ${getRemoteNameFromBranchName( + status.upstream?.name, + )}`, + }, ); } } else { @@ -344,14 +377,14 @@ export class PushGitCommand extends QuickCommand { if (state.reference != null) { pushDetails = `${ status?.state.ahead - ? ` commits up to and including ${GitReference.toString(state.reference, { + ? ` commits up to and including ${getReferenceLabel(state.reference, { label: false, })}` : '' - }${status?.upstream ? ` to ${getRemoteNameFromBranchName(status.upstream)}` : ''}`; + }${status?.upstream ? ` to ${getRemoteNameFromBranchName(status.upstream?.name)}` : ''}`; } else { pushDetails = `${status?.state.ahead ? ` ${pluralize('commit', status.state.ahead)}` : ''}${ - status?.upstream ? ` to ${getRemoteNameFromBranchName(status.upstream)}` : '' + status?.upstream ? ` to ${getRemoteNameFromBranchName(status.upstream?.name)}` : '' }`; } @@ -367,13 +400,29 @@ export class PushGitCommand extends QuickCommand { }), ]), createFlagsQuickPickItem(state.flags, ['--force'], { - label: `Force ${this.title}${useForceWithLease ? ' (with lease)' : ''}`, - description: `--force${useForceWithLease ? '-with-lease' : ''}`, - detail: `Will force push${useForceWithLease ? ' (with lease)' : ''} ${pushDetails}${ + label: `Force ${this.title}${ + useForceIfIncludes + ? ' (with lease and if includes)' + : useForceWithLease + ? ' (with lease)' + : '' + }`, + description: `--force${ + useForceWithLease + ? `-with-lease${useForceIfIncludes ? ' --force-if-includes' : ''}` + : '' + }`, + detail: `Will force push${ + useForceIfIncludes + ? ' (with lease and if includes)' + : useForceWithLease + ? ' (with lease)' + : '' + } ${pushDetails}${ status != null && status.state.behind > 0 ? `, overwriting ${pluralize('commit', status.state.behind)}${ status?.upstream - ? ` on ${getRemoteNameFromBranchName(status.upstream)}` + ? ` on ${getRemoteNameFromBranchName(status.upstream?.name)}` : '' }` : '' @@ -383,16 +432,16 @@ export class PushGitCommand extends QuickCommand { status?.state.behind ? createDirectiveQuickPickItem(Directive.Cancel, true, { label: `Cancel ${this.title}`, - detail: `Cannot push; ${GitReference.toString(branch)} is behind${ - status?.upstream ? ` ${getRemoteNameFromBranchName(status.upstream)}` : '' + detail: `Cannot push; ${getReferenceLabel(branch)} is behind${ + status?.upstream ? ` ${getRemoteNameFromBranchName(status.upstream?.name)}` : '' } by ${pluralize('commit', status.state.behind)}`, }) : undefined, ); - step.additionalButtons = [QuickCommandButtons.Fetch]; + step.additionalButtons = [FetchQuickInputButton]; step.onDidClickButton = async (quickpick, button) => { - if (button !== QuickCommandButtons.Fetch || quickpick.busy) return false; + if (button !== FetchQuickInputButton || quickpick.busy) return false; quickpick.title = `Confirm ${context.title}${pad(GlyphChars.Dot, 2, 2)}Fetching${ GlyphChars.Ellipsis diff --git a/src/commands/git/rebase.ts b/src/commands/git/rebase.ts index 139e4c5b2b71e..2166612a6f31e 100644 --- a/src/commands/git/rebase.ts +++ b/src/commands/git/rebase.ts @@ -1,14 +1,15 @@ import type { Container } from '../../container'; import type { GitBranch } from '../../git/models/branch'; import type { GitLog } from '../../git/models/log'; -import { GitReference, GitRevision } from '../../git/models/reference'; +import type { GitReference } from '../../git/models/reference'; +import { createRevisionRange, getReferenceLabel, isRevisionReference } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive'; import { createDirectiveQuickPickItem, Directive } from '../../quickpicks/items/directive'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; import { pluralize } from '../../system/string'; -import { getEditorCommand } from '../../system/utils'; +import { getEditorCommand } from '../../system/vscode/utils'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { AsyncStepResultGenerator, @@ -19,17 +20,9 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - pickBranchOrTagStep, - pickCommitStep, - pickRepositoryStep, - QuickCommand, - QuickCommandButtons, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { PickCommitToggleQuickInputButton } from '../quickCommand.buttons'; +import { appendReposToTitle, pickBranchOrTagStep, pickCommitStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -93,7 +86,8 @@ export class RebaseGitCommand extends QuickCommand { const editor = getEditorCommand(); configs = ['-c', `"sequence.editor=${editor}"`]; } - return state.repo.rebase(configs, ...state.flags, state.reference.ref); + + state.repo.rebase(configs, ...state.flags, state.reference.ref); } protected async *steps(state: PartialStepState): StepGenerator { @@ -143,11 +137,14 @@ export class RebaseGitCommand extends QuickCommand { context.destination = branch; } - context.title = `${this.title} ${GitReference.toString(context.destination, { icon: false })}`; + context.title = `${this.title} ${getReferenceLabel(context.destination, { + icon: false, + label: false, + })} onto`; context.pickCommitForItem = false; if (state.counter < 2 || state.reference == null) { - const pickCommitToggle = new QuickCommandButtons.PickCommitToggle(context.pickCommit, context, () => { + const pickCommitToggle = new PickCommitToggleQuickInputButton(context.pickCommit, context, () => { context.pickCommit = !context.pickCommit; pickCommitToggle.on = context.pickCommit; }); @@ -171,7 +168,7 @@ export class RebaseGitCommand extends QuickCommand { context.selectedBranchOrTag = undefined; } - if (!GitReference.isRevision(state.reference)) { + if (!isRevisionReference(state.reference)) { context.selectedBranchOrTag = state.reference; } @@ -184,7 +181,7 @@ export class RebaseGitCommand extends QuickCommand { let log = context.cache.get(ref); if (log == null) { - log = this.container.git.getLog(state.repo.path, { ref: ref, merges: false }); + log = this.container.git.getLog(state.repo.path, { ref: ref, merges: 'first-parent' }); context.cache.set(ref, log); } @@ -194,10 +191,10 @@ export class RebaseGitCommand extends QuickCommand { onDidLoadMore: log => context.cache.set(ref, Promise.resolve(log)), placeholder: (context, log) => log == null - ? `No commits found on ${GitReference.toString(context.selectedBranchOrTag, { + ? `No commits found on ${getReferenceLabel(context.selectedBranchOrTag, { icon: false, })}` - : `Choose a commit to rebase ${GitReference.toString(context.destination, { + : `Choose a commit to rebase ${getReferenceLabel(context.destination, { icon: false, })} onto`, picked: state.reference?.ref, @@ -220,23 +217,30 @@ export class RebaseGitCommand extends QuickCommand { } private async *confirmStep(state: RebaseStepState, context: Context): AsyncStepResultGenerator { - const aheadBehind = await this.container.git.getAheadBehindCommitCount(state.repo.path, [ - state.reference.refType === 'revision' - ? GitRevision.createRange(state.reference.ref, context.destination.ref) - : GitRevision.createRange(context.destination.name, state.reference.name), - ]); + const counts = await this.container.git.getLeftRightCommitCount( + state.repo.path, + createRevisionRange(context.destination.ref, state.reference.ref, '...'), + { excludeMerges: true }, + ); - const count = aheadBehind != null ? aheadBehind.ahead + aheadBehind.behind : 0; + const title = `${context.title} ${getReferenceLabel(state.reference, { icon: false, label: false })}`; + const count = counts != null ? counts.left : 0; if (count === 0) { const step: QuickPickStep = this.createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), + appendReposToTitle(title, state, context), [], createDirectiveQuickPickItem(Directive.Cancel, true, { - label: `Cancel ${this.title}`, - detail: `${GitReference.toString(context.destination, { + label: 'OK', + detail: `${getReferenceLabel(context.destination, { capitalize: true, - })} is up to date with ${GitReference.toString(state.reference)}`, + })} is already up to date with ${getReferenceLabel(state.reference, { label: false })}`, }), + { + placeholder: `Nothing to rebase; ${getReferenceLabel(context.destination, { + label: false, + icon: false, + })} is already up to date`, + }, ); const selection: StepSelection = yield step; canPickStepContinue(step, state, selection); @@ -244,21 +248,24 @@ export class RebaseGitCommand extends QuickCommand { } const step: QuickPickStep> = this.createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), + appendReposToTitle(`Confirm ${title}`, state, context), [ createFlagsQuickPickItem(state.flags, [], { label: this.title, - detail: `Will update ${GitReference.toString(context.destination)} by applying ${pluralize( - 'commit', - count, - )} on top of ${GitReference.toString(state.reference)}`, + detail: `Will update ${getReferenceLabel(context.destination, { + label: false, + })} by applying ${pluralize('commit', count)} on top of ${getReferenceLabel(state.reference, { + label: false, + })}`, }), createFlagsQuickPickItem(state.flags, ['--interactive'], { label: `Interactive ${this.title}`, description: '--interactive', - detail: `Will interactively update ${GitReference.toString( - context.destination, - )} by applying ${pluralize('commit', count)} on top of ${GitReference.toString(state.reference)}`, + detail: `Will interactively update ${getReferenceLabel(context.destination, { + label: false, + })} by applying ${pluralize('commit', count)} on top of ${getReferenceLabel(state.reference, { + label: false, + })}`, }), ], ); diff --git a/src/commands/git/remote.ts b/src/commands/git/remote.ts index 53312987829ea..2c4821096b915 100644 --- a/src/commands/git/remote.ts +++ b/src/commands/git/remote.ts @@ -1,14 +1,13 @@ -import type { QuickPickItem } from 'vscode'; import { QuickInputButtons } from 'vscode'; import type { Container } from '../../container'; import { reveal } from '../../git/actions/remote'; import type { GitRemote } from '../../git/models/remote'; import { Repository } from '../../git/models/repository'; -import { Logger } from '../../logger'; import { showGenericErrorMessage } from '../../messages'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; +import { Logger } from '../../system/logger'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { AsyncStepResultGenerator, @@ -20,18 +19,20 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, canPickStepContinue, createConfirmStep, createPickStep, endSteps, + QuickCommand, + StepResultBreak, +} from '../quickCommand'; +import { + appendReposToTitle, inputRemoteNameStep, inputRemoteUrlStep, pickRemoteStep, pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -209,7 +210,9 @@ export class RemoteGitCommand extends QuickCommand { skippedStepTwo = false; if (context.repos.length === 1) { skippedStepTwo = true; - state.counter++; + if (state.repo == null) { + state.counter++; + } state.repo = context.repos[0]; } else { @@ -333,7 +336,7 @@ export class RemoteGitCommand extends QuickCommand { } } - private *addCommandConfirmStep(state: AddStepState, context: Context): StepResultGenerator { + private *addCommandConfirmStep(state: AddStepState, context: Context): StepResultGenerator { const step: QuickPickStep> = createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context), [ @@ -367,8 +370,6 @@ export class RemoteGitCommand extends QuickCommand { } if (state.counter < 3 || state.remote == null) { - context.title = getTitle('Remotes', state.subcommand); - const result = yield* pickRemoteStep(state, context, { picked: state.remote?.name, placeholder: 'Choose remote to remove', @@ -397,7 +398,7 @@ export class RemoteGitCommand extends QuickCommand { state: RemoveStepState>, context: Context, ): StepResultGenerator { - const step: QuickPickStep = createConfirmStep( + const step: QuickPickStep = createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context), [ { @@ -448,7 +449,7 @@ export class RemoteGitCommand extends QuickCommand { state: PruneStepState>, context: Context, ): StepResultGenerator { - const step: QuickPickStep = createConfirmStep( + const step: QuickPickStep = createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context), [ { diff --git a/src/commands/git/reset.ts b/src/commands/git/reset.ts index 7b84edfa3a63f..4b72b477b993d 100644 --- a/src/commands/git/reset.ts +++ b/src/commands/git/reset.ts @@ -1,8 +1,8 @@ import type { Container } from '../../container'; import type { GitBranch } from '../../git/models/branch'; import type { GitLog } from '../../git/models/log'; -import type { GitRevisionReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; +import type { GitReference, GitRevisionReference } from '../../git/models/reference'; +import { getReferenceLabel } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; @@ -16,15 +16,8 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - pickCommitStep, - pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { appendReposToTitle, pickCommitStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -77,7 +70,7 @@ export class ResetGitCommand extends QuickCommand { } execute(state: ResetStepState) { - return state.repo.reset(...state.flags, state.reference.ref); + state.repo.reset(...state.flags, state.reference.ref); } protected async *steps(state: PartialStepState): StepGenerator { @@ -123,14 +116,14 @@ export class ResetGitCommand extends QuickCommand { context.destination = branch; } - context.title = `${this.title} ${GitReference.toString(context.destination, { icon: false })}`; + context.title = `${this.title} ${getReferenceLabel(context.destination, { icon: false })}`; if (state.counter < 2 || state.reference == null) { const ref = context.destination.ref; let log = context.cache.get(ref); if (log == null) { - log = this.container.git.getLog(state.repo.path, { ref: ref, merges: false }); + log = this.container.git.getLog(state.repo.path, { ref: ref, merges: 'first-parent' }); context.cache.set(ref, log); } @@ -175,23 +168,23 @@ export class ResetGitCommand extends QuickCommand { [ createFlagsQuickPickItem(state.flags, [], { label: this.title, - detail: `Will reset (leaves changes in the working tree) ${GitReference.toString( + detail: `Will reset (leaves changes in the working tree) ${getReferenceLabel( context.destination, - )} to ${GitReference.toString(state.reference)}`, + )} to ${getReferenceLabel(state.reference)}`, }), createFlagsQuickPickItem(state.flags, ['--soft'], { label: `Soft ${this.title}`, description: '--soft', - detail: `Will soft reset (leaves changes in the index and working tree) ${GitReference.toString( + detail: `Will soft reset (leaves changes in the index and working tree) ${getReferenceLabel( context.destination, - )} to ${GitReference.toString(state.reference)}`, + )} to ${getReferenceLabel(state.reference)}`, }), createFlagsQuickPickItem(state.flags, ['--hard'], { label: `Hard ${this.title}`, description: '--hard', - detail: `Will hard reset (discards all changes) ${GitReference.toString( + detail: `Will hard reset (discards all changes) ${getReferenceLabel( context.destination, - )} to ${GitReference.toString(state.reference)}`, + )} to ${getReferenceLabel(state.reference)}`, }), ], ); diff --git a/src/commands/git/revert.ts b/src/commands/git/revert.ts index 78d3c27686d83..e1300d79cb3ed 100644 --- a/src/commands/git/revert.ts +++ b/src/commands/git/revert.ts @@ -2,7 +2,7 @@ import type { Container } from '../../container'; import type { GitBranch } from '../../git/models/branch'; import type { GitLog } from '../../git/models/log'; import type { GitRevisionReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; +import { getReferenceLabel } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; @@ -16,15 +16,8 @@ import type { StepSelection, StepState, } from '../quickCommand'; -import { - appendReposToTitle, - canPickStepContinue, - endSteps, - pickCommitsStep, - pickRepositoryStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +import { canPickStepContinue, endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { appendReposToTitle, pickCommitsStep, pickRepositoryStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -79,7 +72,7 @@ export class RevertGitCommand extends QuickCommand { } execute(state: RevertStepState>) { - return state.repo.revert(...state.flags, ...state.references.map(c => c.ref).reverse()); + state.repo.revert(...state.flags, ...state.references.map(c => c.ref).reverse()); } protected async *steps(state: PartialStepState): StepGenerator { @@ -134,7 +127,7 @@ export class RevertGitCommand extends QuickCommand { let log = context.cache.get(ref); if (log == null) { - log = this.container.git.getLog(state.repo.path, { ref: ref, merges: false }); + log = this.container.git.getLog(state.repo.path, { ref: ref, merges: 'first-parent' }); context.cache.set(ref, log); } @@ -180,12 +173,12 @@ export class RevertGitCommand extends QuickCommand { createFlagsQuickPickItem(state.flags, ['--no-edit'], { label: this.title, description: '--no-edit', - detail: `Will revert ${GitReference.toString(state.references)}`, + detail: `Will revert ${getReferenceLabel(state.references)}`, }), createFlagsQuickPickItem(state.flags, ['--edit'], { label: `${this.title} & Edit`, description: '--edit', - detail: `Will revert and edit ${GitReference.toString(state.references)}`, + detail: `Will revert and edit ${getReferenceLabel(state.references)}`, }), ], ); diff --git a/src/commands/git/search.ts b/src/commands/git/search.ts index 8a82fecfd9a91..2dd843f2e57d8 100644 --- a/src/commands/git/search.ts +++ b/src/commands/git/search.ts @@ -1,21 +1,28 @@ -import { configuration } from '../../configuration'; -import { ContextKeys, GlyphChars } from '../../constants'; +import type { QuickInputButton, QuickPick } from 'vscode'; +import { ThemeIcon, window } from 'vscode'; +import { GlyphChars } from '../../constants'; +import type { SearchOperators, SearchOperatorsLongForm, SearchQuery } from '../../constants.search'; +import { searchOperators } from '../../constants.search'; import type { Container } from '../../container'; -import { getContext } from '../../context'; import { showDetailsView } from '../../git/actions/commit'; import type { GitCommit } from '../../git/models/commit'; import type { GitLog } from '../../git/models/log'; import type { Repository } from '../../git/models/repository'; -import type { SearchOperators, SearchQuery } from '../../git/search'; -import { getSearchQueryComparisonKey, parseSearchQuery, searchOperators } from '../../git/search'; +import { getSearchQueryComparisonKey, parseSearchQuery } from '../../git/search'; +import { showContributorsPicker } from '../../quickpicks/contributorsPicker'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { ActionQuickPickItem } from '../../quickpicks/items/common'; +import { isDirectiveQuickPickItem } from '../../quickpicks/items/directive'; +import { first, join, map } from '../../system/iterable'; import { pluralize } from '../../system/string'; +import { configuration } from '../../system/vscode/configuration'; +import { getContext } from '../../system/vscode/context'; import { SearchResultsNode } from '../../views/nodes/searchResultsNode'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import { getSteps } from '../gitCommands.utils'; import type { PartialStepState, + QuickPickStep, StepGenerator, StepResult, StepResultGenerator, @@ -23,18 +30,38 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, canPickStepContinue, createPickStep, endSteps, - pickCommitStep, - pickRepositoryStep, + freezeStep, QuickCommand, - QuickCommandButtons, StepResultBreak, } from '../quickCommand'; +import { + MatchAllToggleQuickInputButton, + MatchCaseToggleQuickInputButton, + MatchRegexToggleQuickInputButton, + ShowResultsInSideBarQuickInputButton, +} from '../quickCommand.buttons'; +import { appendReposToTitle, pickCommitStep, pickRepositoryStep } from '../quickCommand.steps'; + +const UseAuthorPickerQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('person-add'), + tooltip: 'Pick Authors', +}; + +const UseFilePickerQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('new-file'), + tooltip: 'Pick Files', +}; + +const UseFolderPickerQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('new-folder'), + tooltip: 'Pick Folder', +}; interface Context { + container: Container; repos: Repository[]; associatedView: ViewsWithRepositoryFolders; commit: GitCommit | undefined; @@ -68,6 +95,7 @@ const searchOperatorToTitleMap = new Map([ ['file:', 'Search by File'], ['~:', 'Search by Changes'], ['change:', 'Search by Changes'], + ['type:', 'Search by Type'], ]); type SearchStepState = ExcludeSome, 'repo', string>; @@ -108,10 +136,11 @@ export class SearchGitCommand extends QuickCommand { protected async *steps(state: PartialStepState): StepGenerator { const context: Context = { + container: this.container, repos: this.container.git.openRepositories, associatedView: this.container.searchAndCompareView, commit: undefined, - hasVirtualFolders: getContext(ContextKeys.HasVirtualFolders, false), + hasVirtualFolders: getContext('gitlens:hasVirtualFolders', false), resultsKey: undefined, resultsPromise: undefined, title: this.title, @@ -203,7 +232,7 @@ export class SearchGitCommand extends QuickCommand { ignoreFocusOut: true, log: await context.resultsPromise, onDidLoadMore: log => (context.resultsPromise = Promise.resolve(log)), - placeholder: (context, log) => + placeholder: (_context, log) => log == null ? `No results for ${state.query}` : `${pluralize('result', log.count, { @@ -228,7 +257,7 @@ export class SearchGitCommand extends QuickCommand { ), ), showInSideBarButton: { - button: QuickCommandButtons.ShowResultsInSideBar, + button: ShowResultsInSideBarQuickInputButton, onDidClick: () => void this.container.searchAndCompareView.search( repoPath, @@ -284,20 +313,24 @@ export class SearchGitCommand extends QuickCommand { } private *pickSearchOperatorStep(state: SearchStepState, context: Context): StepResultGenerator { - const items: QuickPickItemOfT[] = [ + const items: QuickPickItemOfT[] = [ { label: searchOperatorToTitleMap.get('')!, description: `pattern or message: pattern or =: pattern ${GlyphChars.Dash} use quotes to search for phrases`, + alwaysShow: true, item: 'message:' as const, }, { label: searchOperatorToTitleMap.get('author:')!, description: 'author: pattern or @: pattern', + buttons: [UseAuthorPickerQuickInputButton], + alwaysShow: true, item: 'author:' as const, }, { label: searchOperatorToTitleMap.get('commit:')!, description: 'commit: sha or #: sha', + alwaysShow: true, item: 'commit:' as const, }, context.hasVirtualFolders @@ -305,6 +338,8 @@ export class SearchGitCommand extends QuickCommand { : { label: searchOperatorToTitleMap.get('file:')!, description: 'file: glob or ?: glob', + buttons: [UseFilePickerQuickInputButton, UseFolderPickerQuickInputButton], + alwaysShow: true, item: 'file:' as const, }, context.hasVirtualFolders @@ -312,39 +347,34 @@ export class SearchGitCommand extends QuickCommand { : { label: searchOperatorToTitleMap.get('change:')!, description: 'change: pattern or ~: pattern', + alwaysShow: true, item: 'change:' as const, }, ].filter((i?: T): i is T => i != null); - const matchCaseButton = new QuickCommandButtons.MatchCaseToggle(state.matchCase); - const matchAllButton = new QuickCommandButtons.MatchAllToggle(state.matchAll); - const matchRegexButton = new QuickCommandButtons.MatchRegexToggle(state.matchRegex); + const matchCaseButton = new MatchCaseToggleQuickInputButton(state.matchCase); + const matchAllButton = new MatchAllToggleQuickInputButton(state.matchAll); + const matchRegexButton = new MatchRegexToggleQuickInputButton(state.matchRegex); - const step = createPickStep>({ + const step = createPickStep>({ title: appendReposToTitle(context.title, state, context), placeholder: 'e.g. "Updates dependencies" author:eamodio', + ignoreFocusOut: true, matchOnDescription: true, matchOnDetail: true, additionalButtons: [matchCaseButton, matchAllButton, matchRegexButton], items: items, value: state.query, selectValueWhenShown: false, - onDidAccept: (quickpick): boolean => { - const pick = quickpick.selectedItems[0]; - if (!searchOperators.has(pick.item)) return true; - - const value = quickpick.value.trim(); - if (value.length === 0 || searchOperators.has(value)) { - quickpick.value = pick.item; - } else { - quickpick.value = `${value} ${pick.item}`; - } - - void step.onDidChangeValue!(quickpick); + onDidAccept: async quickpick => { + const item = quickpick.selectedItems[0]; + if (isDirectiveQuickPickItem(item)) return false; + if (!searchOperators.has(item.item)) return true; + await updateSearchQuery(item, {}, quickpick, step, state, context); return false; }, - onDidClickButton: (quickpick, button) => { + onDidClickButton: (_quickpick, button) => { if (button === matchCaseButton) { state.matchCase = !state.matchCase; matchCaseButton.on = state.matchCase; @@ -356,6 +386,17 @@ export class SearchGitCommand extends QuickCommand { matchRegexButton.on = state.matchRegex; } }, + onDidClickItemButton: async function (quickpick, button, item) { + if (button === UseAuthorPickerQuickInputButton) { + await updateSearchQuery(item, { author: true }, quickpick, step, state, context); + } else if (button === UseFilePickerQuickInputButton) { + await updateSearchQuery(item, { file: { type: 'file' } }, quickpick, step, state, context); + } else if (button === UseFolderPickerQuickInputButton) { + await updateSearchQuery(item, { file: { type: 'folder' } }, quickpick, step, state, context); + } + + return false; + }, onDidChangeValue: (quickpick): boolean => { const value = quickpick.value.trim(); // Simulate an extra step if we have a value @@ -369,9 +410,9 @@ export class SearchGitCommand extends QuickCommand { }); quickpick.title = appendReposToTitle( - operations.size === 0 || operations.size > 1 - ? context.title - : `Commit ${searchOperatorToTitleMap.get(operations.keys().next().value)!}`, + operations.size === 1 + ? `Commit ${searchOperatorToTitleMap.get(first(operations.keys())!)}` + : context.title, state, context, ); @@ -387,9 +428,13 @@ export class SearchGitCommand extends QuickCommand { { label: 'Search for', description: quickpick.value, - item: quickpick.value as SearchOperators, + item: quickpick.value as SearchOperatorsLongForm, + picked: true, }, + ...items, ]; + + quickpick.activeItems = [quickpick.items[0]]; } return true; @@ -407,3 +452,101 @@ export class SearchGitCommand extends QuickCommand { return selection[0].item.trim(); } } + +async function updateSearchQuery( + item: QuickPickItemOfT, + usePickers: { author?: boolean; file?: { type: 'file' | 'folder' } }, + quickpick: QuickPick, + step: QuickPickStep, + state: SearchStepState, + context: Context, +) { + const ops = parseSearchQuery({ + query: quickpick.value, + matchCase: state.matchCase, + matchAll: state.matchAll, + }); + + let append = false; + + if (usePickers?.author && item.item === 'author:') { + using frozen = freezeStep(step, quickpick); + + const authors = ops.get('author:'); + + const contributors = await showContributorsPicker( + context.container, + state.repo, + 'Search by Author', + 'Choose contributors to include commits from', + { + appendReposToTitle: true, + clearButton: true, + ignoreFocusOut: true, + multiselect: true, + picked: c => + authors != null && + ((c.email != null && authors.has(c.email)) || + (c.name != null && authors.has(c.name)) || + (c.username != null && authors.has(c.username))), + }, + ); + + frozen[Symbol.dispose](); + + if (contributors != null) { + const authors = contributors + .map(c => c.email ?? c.name ?? c.username) + .filter((c?: T): c is T => c != null); + if (authors.length) { + ops.set('author:', new Set(authors)); + } else { + ops.delete('author:'); + } + } else { + append = true; + } + } else if (usePickers?.file && item.item === 'file:') { + using frozen = freezeStep(step, quickpick); + + let files = ops.get('file:'); + + const uris = await window.showOpenDialog({ + canSelectFiles: usePickers.file.type === 'file', + canSelectFolders: usePickers.file.type === 'folder', + canSelectMany: usePickers.file.type === 'file', + title: 'Search by File', + openLabel: 'Add to Search', + defaultUri: state.repo.folder?.uri, + }); + + frozen[Symbol.dispose](); + + if (uris?.length) { + if (files == null) { + files = new Set(); + ops.set('file:', files); + } + + for (const uri of uris) { + files.add(context.container.git.getRelativePath(uri, state.repo.uri)); + } + } else { + append = true; + } + + if (files == null || files.size === 0) { + ops.delete('file:'); + } + } else { + const values = ops.get(item.item); + append = !values?.has(''); + } + + quickpick.value = `${join( + map(ops.entries(), ([op, values]) => `${op}${join(values, ` ${op}`)}`), + ' ', + )}${append ? ` ${item.item}` : ''}`; + + void step.onDidChangeValue!(quickpick); +} diff --git a/src/commands/git/show.ts b/src/commands/git/show.ts index 4f9d24484c731..d6a389bb231e7 100644 --- a/src/commands/git/show.ts +++ b/src/commands/git/show.ts @@ -8,16 +8,14 @@ import { CommandQuickPickItem } from '../../quickpicks/items/common'; import { GitCommandQuickPickItem } from '../../quickpicks/items/gitCommands'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { PartialStepState, StepGenerator } from '../quickCommand'; +import { endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; import { - endSteps, pickCommitStep, pickRepositoryStep, - QuickCommand, showCommitOrStashFilesStep, showCommitOrStashFileStep, showCommitOrStashStep, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; diff --git a/src/commands/git/stash.ts b/src/commands/git/stash.ts index 06ca5a7658071..e8cf52604c0a2 100644 --- a/src/commands/git/stash.ts +++ b/src/commands/git/stash.ts @@ -1,27 +1,26 @@ import type { QuickPickItem, Uri } from 'vscode'; import { QuickInputButtons, window } from 'vscode'; -import { ContextKeys, GlyphChars } from '../../constants'; +import { GlyphChars } from '../../constants'; import type { Container } from '../../container'; -import { getContext } from '../../context'; import { reveal, showDetailsView } from '../../git/actions/stash'; -import { StashApplyError, StashApplyErrorReason } from '../../git/errors'; +import { StashApplyError, StashApplyErrorReason, StashPushError, StashPushErrorReason } from '../../git/errors'; import type { GitStashCommit } from '../../git/models/commit'; import type { GitStashReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; +import { getReferenceLabel } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; -import { Logger } from '../../logger'; import { showGenericErrorMessage } from '../../messages'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; -import { formatPath } from '../../system/formatPath'; +import { Logger } from '../../system/logger'; import { pad } from '../../system/string'; +import { getContext } from '../../system/vscode/context'; +import { formatPath } from '../../system/vscode/formatPath'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import { getSteps } from '../gitCommands.utils'; import type { AsyncStepResultGenerator, PartialStepState, - QuickPickStep, StepGenerator, StepResult, StepResultGenerator, @@ -29,19 +28,17 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, canInputStepContinue, canPickStepContinue, canStepContinue, createInputStep, createPickStep, endSteps, - pickRepositoryStep, - pickStashStep, QuickCommand, - QuickCommandButtons, StepResultBreak, } from '../quickCommand'; +import { RevealInSideBarQuickInputButton, ShowDetailsViewQuickInputButton } from '../quickCommand.buttons'; +import { appendReposToTitle, pickRepositoryStep, pickStashesStep, pickStashStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -59,7 +56,7 @@ interface ApplyState { interface DropState { subcommand: 'drop'; repo: string | Repository; - reference: GitStashReference; + references: GitStashReference[]; } interface ListState { @@ -74,23 +71,32 @@ interface PopState { reference: GitStashReference; } -type PushFlags = '--include-untracked' | '--keep-index'; +export type PushFlags = '--include-untracked' | '--keep-index' | '--staged' | '--snapshot'; interface PushState { subcommand: 'push'; repo: string | Repository; message?: string; uris?: Uri[]; + onlyStagedUris?: Uri[]; flags: PushFlags[]; } -type State = ApplyState | DropState | ListState | PopState | PushState; +interface RenameState { + subcommand: 'rename'; + repo: string | Repository; + reference: GitStashReference; + message: string; +} + +type State = ApplyState | DropState | ListState | PopState | PushState | RenameState; type StashStepState = SomeNonNullable, 'subcommand'>; type ApplyStepState = StashStepState>; type DropStepState = StashStepState>; type ListStepState = StashStepState>; type PopStepState = StashStepState>; type PushStepState = StashStepState>; +type RenameStepState = StashStepState>; const subcommandToTitleMap = new Map([ ['apply', 'Apply'], @@ -98,8 +104,12 @@ const subcommandToTitleMap = new Map([ ['list', 'List'], ['pop', 'Pop'], ['push', 'Push'], + ['rename', 'Rename'], ]); function getTitle(title: string, subcommand: State['subcommand'] | undefined) { + if (subcommand === 'drop') { + title = 'Stashes'; + } return subcommand == null ? title : `${subcommandToTitleMap.get(subcommand)} ${title}`; } @@ -123,18 +133,30 @@ export class StashGitCommand extends QuickCommand { switch (args.state.subcommand) { case 'apply': - case 'drop': case 'pop': if (args.state.reference != null) { counter++; } break; - + case 'drop': + if (args.state.references != null) { + counter++; + } + break; case 'push': if (args.state.message != null) { counter++; } + break; + case 'rename': + if (args.state.reference != null) { + counter++; + } + + if (args.state.message != null) { + counter++; + } break; } } @@ -167,9 +189,9 @@ export class StashGitCommand extends QuickCommand { repos: this.container.git.openRepositories, associatedView: this.container.stashesView, readonly: - getContext(ContextKeys.Readonly, false) || - getContext(ContextKeys.Untrusted, false) || - getContext(ContextKeys.HasVirtualFolders, false), + getContext('gitlens:readonly', false) || + getContext('gitlens:untrusted', false) || + getContext('gitlens:hasVirtualFolders', false), title: this.title, }; @@ -200,7 +222,9 @@ export class StashGitCommand extends QuickCommand { skippedStepTwo = false; if (context.repos.length === 1) { skippedStepTwo = true; - state.counter++; + if (state.repo == null) { + state.counter++; + } state.repo = context.repos[0]; } else { @@ -225,6 +249,11 @@ export class StashGitCommand extends QuickCommand { case 'push': yield* this.pushCommandSteps(state as PushStepState, context); break; + case 'rename': + yield* this.renameCommandSteps(state as RenameStepState, context); + // Clear any chosen message, since we are exiting this subcommand + state.message = undefined!; + break; default: endSteps(state); break; @@ -252,7 +281,7 @@ export class StashGitCommand extends QuickCommand { }, { label: 'drop', - description: 'deletes the specified stash', + description: 'deletes the specified stashes', picked: state.subcommand === 'drop', item: 'drop', }, @@ -276,6 +305,12 @@ export class StashGitCommand extends QuickCommand { picked: state.subcommand === 'push', item: 'push', }, + { + label: 'rename', + description: 'renames the specified stash', + picked: state.subcommand === 'rename', + item: 'rename', + }, ], buttons: [QuickInputButtons.Back], }); @@ -288,7 +323,7 @@ export class StashGitCommand extends QuickCommand { if (state.counter < 3 || state.reference == null) { const result: StepResult = yield* pickStashStep(state, context, { stash: await this.container.git.getStash(state.repo.path), - placeholder: (context, stash) => + placeholder: (_context, stash) => stash == null ? `No stashes found in ${state.repo.formattedName}` : 'Choose a stash to apply to your working tree', @@ -347,12 +382,10 @@ export class StashGitCommand extends QuickCommand { label: context.title, detail: state.subcommand === 'pop' - ? `Will delete ${GitReference.toString( + ? `Will delete ${getReferenceLabel( state.reference, )} and apply the changes to the working tree` - : `Will apply the changes from ${GitReference.toString( - state.reference, - )} to the working tree`, + : `Will apply the changes from ${getReferenceLabel(state.reference)} to the working tree`, item: state.subcommand, }, // Alternate confirmation (if pop then apply, and vice versa) @@ -360,10 +393,8 @@ export class StashGitCommand extends QuickCommand { label: getTitle(this.title, state.subcommand === 'pop' ? 'apply' : 'pop'), detail: state.subcommand === 'pop' - ? `Will apply the changes from ${GitReference.toString( - state.reference, - )} to the working tree` - : `Will delete ${GitReference.toString( + ? `Will apply the changes from ${getReferenceLabel(state.reference)} to the working tree` + : `Will delete ${getReferenceLabel( state.reference, )} and apply the changes to the working tree`, item: state.subcommand === 'pop' ? 'apply' : 'pop', @@ -372,14 +403,14 @@ export class StashGitCommand extends QuickCommand { undefined, { placeholder: `Confirm ${context.title}`, - additionalButtons: [QuickCommandButtons.ShowDetailsView, QuickCommandButtons.RevealInSideBar], - onDidClickButton: (quickpick, button) => { - if (button === QuickCommandButtons.ShowDetailsView) { + additionalButtons: [ShowDetailsViewQuickInputButton, RevealInSideBarQuickInputButton], + onDidClickButton: (_quickpick, button) => { + if (button === ShowDetailsViewQuickInputButton) { void showDetailsView(state.reference, { pin: false, preserveFocus: true, }); - } else if (button === QuickCommandButtons.RevealInSideBar) { + } else if (button === RevealInSideBarQuickInputButton) { void reveal(state.reference, { select: true, expand: true, @@ -394,32 +425,36 @@ export class StashGitCommand extends QuickCommand { private async *dropCommandSteps(state: DropStepState, context: Context): StepGenerator { while (this.canStepsContinue(state)) { - if (state.counter < 3 || state.reference == null) { - const result: StepResult = yield* pickStashStep(state, context, { + if (state.counter < 3 || !state.references?.length) { + const result: StepResult = yield* pickStashesStep(state, context, { stash: await this.container.git.getStash(state.repo.path), - placeholder: (context, stash) => - stash == null ? `No stashes found in ${state.repo.formattedName}` : 'Choose a stash to delete', - picked: state.reference?.ref, + placeholder: (_context, stash) => + stash == null ? `No stashes found in ${state.repo.formattedName}` : 'Choose stashes to delete', + picked: state.references?.map(r => r.ref), }); // Always break on the first step (so we will go back) if (result === StepResultBreak) break; - state.reference = result; + state.references = result; } const result = yield* this.dropCommandConfirmStep(state, context); if (result === StepResultBreak) continue; endSteps(state); - try { - // drop can only take a stash index, e.g. `stash@{1}` - await state.repo.stashDelete(`stash@{${state.reference.number}}`, state.reference.ref); - } catch (ex) { - Logger.error(ex, context.title); - void showGenericErrorMessage('Unable to delete stash'); + state.references.sort((a, b) => parseInt(b.number, 10) - parseInt(a.number, 10)); + for (const ref of state.references) { + try { + // drop can only take a stash index, e.g. `stash@{1}` + await state.repo.stashDelete(`stash@{${ref.number}}`, ref.ref); + } catch (ex) { + Logger.error(ex, context.title); - return; + void showGenericErrorMessage( + `Unable to delete stash@{${ref.number}}${ref.message ? `: ${ref.message}` : ''}`, + ); + } } } } @@ -430,27 +465,11 @@ export class StashGitCommand extends QuickCommand { [ { label: context.title, - detail: `Will delete ${GitReference.toString(state.reference)}`, + detail: `Will delete ${getReferenceLabel(state.references)}`, }, ], undefined, - { - placeholder: `Confirm ${context.title}`, - additionalButtons: [QuickCommandButtons.ShowDetailsView, QuickCommandButtons.RevealInSideBar], - onDidClickButton: (quickpick, button) => { - if (button === QuickCommandButtons.ShowDetailsView) { - void showDetailsView(state.reference, { - pin: false, - preserveFocus: true, - }); - } else if (button === QuickCommandButtons.RevealInSideBar) { - void reveal(state.reference, { - select: true, - expand: true, - }); - } - }, - }, + { placeholder: `Confirm ${context.title}` }, ); const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? undefined : StepResultBreak; @@ -463,7 +482,7 @@ export class StashGitCommand extends QuickCommand { if (state.counter < 3 || state.reference == null) { const result: StepResult = yield* pickStashStep(state, context, { stash: await this.container.git.getStash(state.repo.path), - placeholder: (context, stash) => + placeholder: (_context, stash) => stash == null ? `No stashes found in ${state.repo.formattedName}` : 'Choose a stash', picked: state.reference?.ref, }); @@ -517,15 +536,58 @@ export class StashGitCommand extends QuickCommand { state.flags = result; } - endSteps(state); try { - await state.repo.stashSave(state.message, state.uris, { - includeUntracked: state.flags.includes('--include-untracked'), - keepIndex: state.flags.includes('--keep-index'), - }); + if (state.flags.includes('--snapshot')) { + await state.repo.stashSaveSnapshot(state.message); + } else { + await state.repo.stashSave(state.message, state.uris, { + includeUntracked: state.flags.includes('--include-untracked'), + keepIndex: state.flags.includes('--keep-index'), + onlyStaged: state.flags.includes('--staged'), + }); + } + + endSteps(state); } catch (ex) { Logger.error(ex, context.title); + if (ex instanceof StashPushError) { + if (ex.reason === StashPushErrorReason.NothingToSave) { + if (!state.flags.includes('--include-untracked')) { + void window.showWarningMessage( + 'No changes to stash. Choose the "Push & Include Untracked" option, if you have untracked files.', + ); + continue; + } + + void window.showInformationMessage('No changes to stash.'); + return; + } + if ( + ex.reason === StashPushErrorReason.ConflictingStagedAndUnstagedLines && + state.flags.includes('--staged') + ) { + const confirm = { title: 'Stash Everything' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showErrorMessage( + `Changes were stashed, but the working tree cannot be updated because at least one file has staged and unstaged changes on the same line(s)\n\nDo you want to try again by stashing both your staged and unstaged changes?`, + { modal: true }, + confirm, + cancel, + ); + + if (result === confirm) { + if (state.uris == null) { + state.uris = state.onlyStagedUris; + } + state.flags.splice(state.flags.indexOf('--staged'), 1); + continue; + } + + return; + } + } + const msg: string = ex?.message ?? ex?.toString() ?? ''; if (msg.includes('newer version of Git')) { void window.showErrorMessage(`Unable to stash changes. ${msg}`); @@ -571,47 +633,177 @@ export class StashGitCommand extends QuickCommand { } private *pushCommandConfirmStep(state: PushStepState, context: Context): StepResultGenerator { - const step: QuickPickStep> = this.createConfirmStep( + const stagedOnly = state.flags.includes('--staged'); + + const baseFlags: PushFlags[] = []; + if (stagedOnly) { + baseFlags.push('--staged'); + } + + type StepType = FlagsQuickPickItem; + + const confirmations: StepType[] = []; + if (state.uris?.length) { + if (state.flags.includes('--include-untracked')) { + baseFlags.push('--include-untracked'); + } + + confirmations.push( + createFlagsQuickPickItem(state.flags, [...baseFlags], { + label: context.title, + detail: `Will stash changes from ${ + state.uris.length === 1 + ? formatPath(state.uris[0], { fileOnly: true }) + : `${state.uris.length} files` + }`, + }), + ); + // If we are including untracked file, then avoid allowing --keep-index since Git will error out for some reason + if (!state.flags.includes('--include-untracked')) { + confirmations.push( + createFlagsQuickPickItem(state.flags, [...baseFlags, '--keep-index'], { + label: `${context.title} & Keep Staged`, + detail: `Will stash changes from ${ + state.uris.length === 1 + ? formatPath(state.uris[0], { fileOnly: true }) + : `${state.uris.length} files` + }, but will keep staged files intact`, + }), + ); + } + } else { + confirmations.push( + createFlagsQuickPickItem(state.flags, [...baseFlags], { + label: context.title, + detail: `Will stash ${stagedOnly ? 'staged' : 'uncommitted'} changes`, + }), + createFlagsQuickPickItem(state.flags, [...baseFlags, '--snapshot'], { + label: `${context.title} Snapshot`, + detail: 'Will stash uncommitted changes without changing the working tree', + }), + ); + if (!stagedOnly) { + confirmations.push( + createFlagsQuickPickItem(state.flags, [...baseFlags, '--include-untracked'], { + label: `${context.title} & Include Untracked`, + description: '--include-untracked', + detail: 'Will stash uncommitted changes, including untracked files', + }), + ); + confirmations.push( + createFlagsQuickPickItem(state.flags, [...baseFlags, '--keep-index'], { + label: `${context.title} & Keep Staged`, + description: '--keep-index', + detail: `Will stash ${ + stagedOnly ? 'staged' : 'uncommitted' + } changes, but will keep staged files intact`, + }), + ); + } + } + + const step = this.createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context), - state.uris == null || state.uris.length === 0 - ? [ - createFlagsQuickPickItem(state.flags, [], { - label: context.title, - detail: 'Will stash uncommitted changes', - }), - createFlagsQuickPickItem(state.flags, ['--include-untracked'], { - label: `${context.title} & Include Untracked`, - description: '--include-untracked', - detail: 'Will stash uncommitted changes, including untracked files', - }), - createFlagsQuickPickItem(state.flags, ['--keep-index'], { - label: `${context.title} & Keep Staged`, - description: '--keep-index', - detail: 'Will stash uncommitted changes, but will keep staged files intact', - }), - ] - : [ - createFlagsQuickPickItem(state.flags, [], { - label: context.title, - detail: `Will stash changes from ${ - state.uris.length === 1 - ? formatPath(state.uris[0], { fileOnly: true }) - : `${state.uris.length} files` - }`, - }), - createFlagsQuickPickItem(state.flags, ['--keep-index'], { - label: `${context.title} & Keep Staged`, - detail: `Will stash changes from ${ - state.uris.length === 1 - ? formatPath(state.uris[0], { fileOnly: true }) - : `${state.uris.length} files` - }, but will keep staged files intact`, - }), - ], + confirmations, undefined, { placeholder: `Confirm ${context.title}` }, ); const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } + + private async *renameCommandSteps(state: RenameStepState, context: Context): StepGenerator { + while (this.canStepsContinue(state)) { + if (state.counter < 3 || state.reference == null) { + const result: StepResult = yield* pickStashStep(state, context, { + stash: await this.container.git.getStash(state.repo.path), + placeholder: (_context, stash) => + stash == null ? `No stashes found in ${state.repo.formattedName}` : 'Choose a stash to rename', + picked: state.reference?.ref, + }); + // Always break on the first step (so we will go back) + if (result === StepResultBreak) break; + + state.reference = result; + } + + if (state.counter < 4 || state.message == null) { + const result: StepResult = yield* this.renameCommandInputMessageStep(state, context); + if (result === StepResultBreak) continue; + + state.message = result; + } + + if (this.confirm(state.confirm)) { + const result = yield* this.renameCommandConfirmStep(state, context); + if (result === StepResultBreak) continue; + } + + endSteps(state); + + try { + await state.repo.stashRename( + state.reference.name, + state.reference.ref, + state.message, + state.reference.stashOnRef, + ); + } catch (ex) { + Logger.error(ex, context.title); + void showGenericErrorMessage(ex.message); + } + } + } + + private async *renameCommandInputMessageStep( + state: RenameStepState, + context: Context, + ): AsyncStepResultGenerator { + const step = createInputStep({ + title: appendReposToTitle(context.title, state, context), + placeholder: `Please provide a new message for ${getReferenceLabel(state.reference, { icon: false })}`, + value: state.message ?? state.reference?.message, + prompt: 'Enter new stash message', + }); + + const value: StepSelection = yield step; + if (!canStepContinue(step, state, value) || !(await canInputStepContinue(step, state, value))) { + return StepResultBreak; + } + + return value; + } + + private *renameCommandConfirmStep(state: RenameStepState, context: Context): StepResultGenerator<'rename'> { + const step = this.createConfirmStep( + appendReposToTitle(`Confirm ${context.title}`, state, context), + [ + { + label: context.title, + detail: `Will rename ${getReferenceLabel(state.reference)}`, + item: state.subcommand, + }, + ], + undefined, + { + placeholder: `Confirm ${context.title}`, + additionalButtons: [ShowDetailsViewQuickInputButton, RevealInSideBarQuickInputButton], + onDidClickButton: (_quickpick, button) => { + if (button === ShowDetailsViewQuickInputButton) { + void showDetailsView(state.reference, { + pin: false, + preserveFocus: true, + }); + } else if (button === RevealInSideBarQuickInputButton) { + void reveal(state.reference, { + select: true, + expand: true, + }); + } + }, + }, + ); + const selection: StepSelection = yield step; + return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; + } } diff --git a/src/commands/git/status.ts b/src/commands/git/status.ts index 327fb0e393aba..8715b0c582d61 100644 --- a/src/commands/git/status.ts +++ b/src/commands/git/status.ts @@ -1,6 +1,6 @@ import { GlyphChars } from '../../constants'; import type { Container } from '../../container'; -import { GitReference } from '../../git/models/reference'; +import { createReference, getReferenceLabel } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import type { GitStatus } from '../../git/models/status'; import { CommandQuickPickItem } from '../../quickpicks/items/common'; @@ -8,7 +8,8 @@ import { GitCommandQuickPickItem } from '../../quickpicks/items/gitCommands'; import { pad } from '../../system/string'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; import type { PartialStepState, StepGenerator, StepState } from '../quickCommand'; -import { endSteps, pickRepositoryStep, QuickCommand, showRepositoryStatusStep, StepResultBreak } from '../quickCommand'; +import { endSteps, QuickCommand, StepResultBreak } from '../quickCommand'; +import { pickRepositoryStep, showRepositoryStatusStep } from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -84,13 +85,12 @@ export class StatusGitCommand extends QuickCommand { context.status = (await state.repo.getStatus())!; if (context.status == null) return; - context.title = `${this.title}${pad(GlyphChars.Dot, 2, 2)}${GitReference.toString( - GitReference.create(context.status.branch, state.repo.path, { + context.title = `${this.title}${pad(GlyphChars.Dot, 2, 2)}${getReferenceLabel( + createReference(context.status.branch, state.repo.path, { refType: 'branch', name: context.status.branch, remote: false, - upstream: - context.status.upstream != null ? { name: context.status.upstream, missing: false } : undefined, + upstream: context.status.upstream, }), { icon: false }, )}`; diff --git a/src/commands/git/switch.ts b/src/commands/git/switch.ts index 937eb8280ec06..ff454f8397780 100644 --- a/src/commands/git/switch.ts +++ b/src/commands/git/switch.ts @@ -1,46 +1,54 @@ import { ProgressLocation, window } from 'vscode'; -import { BranchSorting } from '../../config'; import type { Container } from '../../container'; -import { GitReference } from '../../git/models/reference'; +import type { GitReference } from '../../git/models/reference'; +import { + getNameWithoutRemote, + getReferenceLabel, + getReferenceTypeLabel, + isBranchReference, +} from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; +import { createQuickPickSeparator } from '../../quickpicks/items/common'; import { isStringArray } from '../../system/array'; +import { executeCommand } from '../../system/vscode/command'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; -import type { - PartialStepState, - QuickPickStep, - StepGenerator, - StepResultGenerator, - StepSelection, - StepState, -} from '../quickCommand'; +import { getSteps } from '../gitCommands.utils'; +import type { PartialStepState, StepGenerator, StepResultGenerator, StepSelection, StepState } from '../quickCommand'; +import { canPickStepContinue, endSteps, isCrossCommandReference, QuickCommand, StepResultBreak } from '../quickCommand'; import { appendReposToTitle, - canPickStepContinue, - endSteps, inputBranchNameStep, pickBranchOrTagStepMultiRepo, pickRepositoriesStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; associatedView: ViewsWithRepositoryFolders; + canSwitchToLocalBranch: GitReference | undefined; + promptToCreateBranch: boolean; showTags: boolean; - switchToLocalFrom: GitReference | undefined; title: string; } interface State { repos: string | string[] | Repository | Repository[]; + onWorkspaceChanging?: (() => Promise) | (() => void); reference: GitReference; createBranch?: string; fastForwardTo?: GitReference; + skipWorktreeConfirmations?: boolean; } -type ConfirmationChoice = 'switch' | 'switch+fast-forward'; +type ConfirmationChoice = + | 'switch' + | 'switchViaWorktree' + | 'switchToLocalBranch' + | 'switchToLocalBranchAndFastForward' + | 'switchToLocalBranchViaWorktree' + | 'switchToNewBranch' + | 'switchToNewBranchViaWorktree'; type SwitchStepState = ExcludeSome, 'repos', string | string[] | Repository>; @@ -52,8 +60,8 @@ export interface SwitchGitCommandArgs { export class SwitchGitCommand extends QuickCommand { constructor(container: Container, args?: SwitchGitCommandArgs) { - super(container, 'switch', 'switch', 'Switch', { - description: 'aka checkout, switches the current branch to a specified branch', + super(container, 'switch', 'switch', 'Switch to...', { + description: 'aka checkout, switches to a specified branch', }); let counter = 0; @@ -72,13 +80,20 @@ export class SwitchGitCommand extends QuickCommand { }; } + private _canConfirmOverride: boolean | undefined; + override get canConfirm(): boolean { + return this._canConfirmOverride ?? true; + } + async execute(state: SwitchStepState) { await window.withProgress( { location: ProgressLocation.Notification, - title: `Switching ${ - state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repositories` - } to ${state.reference.name}`, + title: `${ + isBranchReference(state.reference) || state.createBranch ? 'Switching to' : 'Checking out' + } ${getReferenceLabel(state.reference, { icon: false, label: false })} in ${ + state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repos` + }`, }, () => Promise.all( @@ -105,8 +120,9 @@ export class SwitchGitCommand extends QuickCommand { const context: Context = { repos: this.container.git.openRepositories, associatedView: this.container.commitsView, + canSwitchToLocalBranch: undefined, + promptToCreateBranch: false, showTags: false, - switchToLocalFrom: undefined, title: this.title, }; @@ -116,14 +132,16 @@ export class SwitchGitCommand extends QuickCommand { let skippedStepOne = false; - while (this.canStepsContinue(state)) { + outer: while (this.canStepsContinue(state)) { context.title = this.title; if (state.counter < 1 || state.repos == null || state.repos.length === 0 || isStringArray(state.repos)) { skippedStepOne = false; if (context.repos.length === 1) { skippedStepOne = true; - state.counter++; + if (state.repos == null) { + state.counter++; + } state.repos = [context.repos[0]]; } else { @@ -142,6 +160,7 @@ export class SwitchGitCommand extends QuickCommand { if (state.counter < 2 || state.reference == null) { const result = yield* pickBranchOrTagStepMultiRepo(state as SwitchStepState, context, { placeholder: context => `Choose a branch${context.showTags ? ' or tag' : ''} to switch to`, + allowCreate: state.repos.length === 1, }); if (result === StepResultBreak) { // If we skipped the previous step, make sure we back up past it @@ -152,44 +171,172 @@ export class SwitchGitCommand extends QuickCommand { continue; } + if (typeof result == 'string') { + yield* getSteps( + this.container, + { + command: 'branch', + state: { + subcommand: 'create', + repo: state.repos[0], + name: result, + suggestNameOnly: true, + flags: ['--switch'], + }, + }, + this.pickedVia, + ); + + endSteps(state); + return; + } + + if (isCrossCommandReference(result)) { + void executeCommand(result.command, result.args); + endSteps(state); + return; + } + state.reference = result; } - if (GitReference.isBranch(state.reference) && state.reference.remote) { - context.title = `Create Branch and ${this.title}`; + context.canSwitchToLocalBranch = undefined; + if (isBranchReference(state.reference) && !state.reference.remote) { + state.createBranch = undefined; + + const worktree = await this.container.git.getWorktree( + state.reference.repoPath, + w => w.branch?.name === state.reference!.name, + ); + if (worktree != null && !worktree.isDefault) { + if (state.fastForwardTo != null) { + state.repos[0].merge('--ff-only', state.fastForwardTo.ref); + } + + const worktreeResult = yield* getSteps( + this.container, + { + command: 'worktree', + state: { + subcommand: 'open', + worktree: worktree, + openOnly: true, + overrides: { + disallowBack: true, + confirmation: state.skipWorktreeConfirmations + ? undefined + : { + title: `Confirm Switch to Worktree \u2022 ${getReferenceLabel( + state.reference, + { + icon: false, + label: false, + }, + )}`, + placeholder: `${getReferenceLabel(state.reference, { + capitalize: true, + icon: false, + })} is linked to a worktree`, + }, + }, + onWorkspaceChanging: state.onWorkspaceChanging, + repo: state.repos[0], + skipWorktreeConfirmations: state.skipWorktreeConfirmations, + }, + }, + this.pickedVia, + ); + if (worktreeResult === StepResultBreak && !state.skipWorktreeConfirmations) continue; + + endSteps(state); + return; + } + } else if (isBranchReference(state.reference) && state.reference.remote) { + // See if there is a local branch that tracks the remote branch const { values: branches } = await this.container.git.getBranches(state.reference.repoPath, { filter: b => b.upstream?.name === state.reference!.name, - sort: { orderBy: BranchSorting.DateDesc }, + sort: { orderBy: 'date:desc' }, }); - if (branches.length === 0) { - const result = yield* inputBranchNameStep(state as SwitchStepState, context, { - placeholder: 'Please provide a name for the new branch', - titleContext: ` based on ${GitReference.toString(state.reference, { - icon: false, - })}`, - value: state.createBranch ?? GitReference.getNameWithoutRemote(state.reference), - }); - if (result === StepResultBreak) continue; + if (branches.length) { + context.canSwitchToLocalBranch = branches[0]; - state.createBranch = result; - } else { - context.title = `${this.title} to Local Branch`; - context.switchToLocalFrom = state.reference; - state.reference = branches[0]; state.createBranch = undefined; + context.promptToCreateBranch = false; + if (state.skipWorktreeConfirmations) { + state.reference = context.canSwitchToLocalBranch; + continue outer; + } + } else { + context.promptToCreateBranch = true; } - } else { - state.createBranch = undefined; } - if (this.confirm(state.confirm || context.switchToLocalFrom != null)) { + if ( + state.skipWorktreeConfirmations || + this.confirm(context.promptToCreateBranch || context.canSwitchToLocalBranch ? true : state.confirm) + ) { const result = yield* this.confirmStep(state as SwitchStepState, context); if (result === StepResultBreak) continue; - if (result === 'switch+fast-forward') { - state.fastForwardTo = context.switchToLocalFrom; + switch (result) { + case 'switchToLocalBranch': + state.reference = context.canSwitchToLocalBranch!; + continue outer; + + case 'switchToLocalBranchAndFastForward': + state.fastForwardTo = state.reference; + state.reference = context.canSwitchToLocalBranch!; + continue outer; + + case 'switchToNewBranch': { + context.title = `Switch to New Branch`; + this._canConfirmOverride = false; + + const result = yield* inputBranchNameStep(state as SwitchStepState, context, { + titleContext: ` from ${getReferenceLabel(state.reference, { + capitalize: true, + icon: false, + label: state.reference.refType !== 'branch', + })}`, + value: state.createBranch ?? getNameWithoutRemote(state.reference), + }); + + this._canConfirmOverride = undefined; + + if (result === StepResultBreak) continue outer; + + state.createBranch = result; + break; + } + case 'switchViaWorktree': + case 'switchToLocalBranchViaWorktree': + case 'switchToNewBranchViaWorktree': { + const worktreeResult = yield* getSteps( + this.container, + { + command: 'worktree', + state: { + subcommand: 'create', + reference: + result === 'switchToLocalBranchViaWorktree' + ? context.canSwitchToLocalBranch + : state.reference, + createBranch: + result === 'switchToNewBranchViaWorktree' ? state.createBranch : undefined, + repo: state.repos[0], + onWorkspaceChanging: state.onWorkspaceChanging, + skipWorktreeConfirmations: state.skipWorktreeConfirmations, + }, + }, + this.pickedVia, + ); + if (worktreeResult === StepResultBreak && !state.skipWorktreeConfirmations) continue outer; + + endSteps(state); + return; + } } } @@ -201,49 +348,165 @@ export class SwitchGitCommand extends QuickCommand { } private *confirmStep(state: SwitchStepState, context: Context): StepResultGenerator { - let additionalConfirmations: QuickPickItemOfT[]; - if (context.switchToLocalFrom != null && state.repos.length === 1) { - additionalConfirmations = [ - { - label: `${context.title} and Fast-Forward`, - description: '', - detail: `Will switch to and fast-forward local ${GitReference.toString( - state.reference, - )} in $(repo) ${state.repos[0].formattedName}`, - item: 'switch+fast-forward', - }, - ]; - } else { - additionalConfirmations = []; + const isLocalBranch = isBranchReference(state.reference) && !state.reference.remote; + const isRemoteBranch = isBranchReference(state.reference) && state.reference.remote; + + type StepType = QuickPickItemOfT; + if (state.skipWorktreeConfirmations && state.repos.length === 1) { + if (isLocalBranch) { + return 'switchViaWorktree'; + } else if (!state.createBranch && context.canSwitchToLocalBranch != null) { + return 'switchToLocalBranchViaWorktree'; + } + + return 'switchToNewBranchViaWorktree'; + } + + const confirmations: StepType[] = []; + + if (!isBranchReference(state.reference)) { + confirmations.push({ + label: `Checkout to ${getReferenceTypeLabel(state.reference)}`, + description: '(detached)', + detail: `Will checkout to ${getReferenceLabel(state.reference)}${ + state.repos.length > 1 ? ` in ${state.repos.length} repos` : '' + }`, + item: 'switch', + }); } - const step: QuickPickStep> = this.createConfirmStep< - QuickPickItemOfT - >( - appendReposToTitle(`Confirm ${context.title}`, state, context), - [ - { - label: context.title, - description: state.createBranch ? '-b' : '', - detail: `Will ${ - state.createBranch - ? `create and switch to a new branch named ${ - state.createBranch - } from ${GitReference.toString(state.reference)}` - : `switch to ${context.switchToLocalFrom != null ? 'local ' : ''}${GitReference.toString( - state.reference, - )}` - } in ${ - state.repos.length === 1 - ? `$(repo) ${state.repos[0].formattedName}` - : `${state.repos.length} repositories` + if (!state.createBranch) { + if (context.canSwitchToLocalBranch != null) { + confirmations.push(createQuickPickSeparator('Local')); + confirmations.push({ + label: `Switch to Local Branch`, + description: '', + detail: `Will switch to local ${getReferenceLabel( + context.canSwitchToLocalBranch, + )} for ${getReferenceLabel(state.reference)}`, + item: 'switchToLocalBranch', + }); + + if (state.repos.length === 1) { + confirmations.push({ + label: `Switch to Local Branch & Fast-Forward`, + description: '', + detail: `Will switch to and fast-forward local ${getReferenceLabel( + context.canSwitchToLocalBranch, + )}`, + item: 'switchToLocalBranchAndFastForward', + }); + } + } else if (isLocalBranch) { + confirmations.push({ + label: 'Switch to Branch', + description: '', + detail: `Will switch to ${getReferenceLabel(state.reference)}${ + state.repos.length > 1 ? ` in ${state.repos.length} repos` : '' }`, item: 'switch', - }, - ...additionalConfirmations, - ], + }); + } + } + + if (!isLocalBranch || state.createBranch || context.promptToCreateBranch) { + if (isRemoteBranch) { + if (confirmations.length) { + confirmations.push(createQuickPickSeparator('Remote')); + } + confirmations.push({ + label: 'Create & Switch to New Local Branch', + description: '', + detail: `Will create and switch to a new local branch${ + state.createBranch ? ` named ${state.createBranch}` : '' + } from ${getReferenceLabel(state.reference)}${ + state.repos.length > 1 ? ` in ${state.repos.length} repos` : '' + }`, + item: 'switchToNewBranch', + }); + } else { + if (confirmations.length) { + confirmations.push(createQuickPickSeparator('Branch')); + } + confirmations.push({ + label: `Create & Switch to New Branch from ${getReferenceTypeLabel(state.reference)}`, + description: '', + detail: `Will create and switch to a new branch${ + state.createBranch ? ` named ${state.createBranch}` : '' + } from ${getReferenceLabel(state.reference)}${ + state.repos.length > 1 ? ` in ${state.repos.length} repos` : '' + }`, + item: 'switchToNewBranch', + }); + } + } + + if (state.repos.length === 1) { + if (confirmations.length) { + confirmations.push(createQuickPickSeparator('Worktree')); + } + if (isLocalBranch) { + confirmations.push({ + label: `Create Worktree for Branch...`, + description: 'avoids modifying your working tree', + detail: `Will create a new worktree for ${getReferenceLabel(state.reference)}`, + item: 'switchViaWorktree', + }); + } else if (!state.createBranch && context.canSwitchToLocalBranch != null) { + confirmations.push({ + label: `Create Worktree for Local Branch...`, + description: 'avoids modifying your working tree', + detail: `Will create a new worktree for local ${getReferenceLabel(context.canSwitchToLocalBranch)}`, + item: 'switchToLocalBranchViaWorktree', + }); + } else if (isRemoteBranch) { + confirmations.push({ + label: `Create Worktree for New Local Branch...`, + description: 'avoids modifying your working tree', + detail: `Will create a new worktree for a new local branch${ + state.createBranch ? ` named ${state.createBranch}` : '' + } from ${getReferenceLabel(state.reference)}${ + state.repos.length > 1 ? ` in ${state.repos.length} repos` : '' + }`, + item: 'switchToNewBranchViaWorktree', + }); + } else { + confirmations.push({ + label: `Create Worktree for New Branch from ${getReferenceTypeLabel(state.reference)}...`, + description: 'avoids modifying your working tree', + detail: `Will create a new worktree for a new branch${ + state.createBranch ? ` named ${state.createBranch}` : '' + } from ${getReferenceLabel(state.reference)}${ + state.repos.length > 1 ? ` in ${state.repos.length} repos` : '' + }`, + item: 'switchToNewBranchViaWorktree', + }); + } + } + + if (isRemoteBranch && !state.createBranch) { + if (confirmations.length) { + confirmations.push(createQuickPickSeparator('Checkout')); + } + confirmations.push({ + label: `Checkout to Remote Branch`, + description: '(detached)', + detail: `Will checkout to ${getReferenceLabel(state.reference)}`, + item: 'switch', + }); + } + + const step = this.createConfirmStep( + appendReposToTitle( + `Confirm Switch to ${getReferenceLabel(state.reference, { icon: false, capitalize: true })}`, + state, + context, + ), + confirmations, undefined, - { placeholder: `Confirm ${context.title}` }, + { + placeholder: `Confirm ${context.title}`, + }, ); const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; diff --git a/src/commands/git/tag.ts b/src/commands/git/tag.ts index 929e26b1b9fac..bc8ef75e08e61 100644 --- a/src/commands/git/tag.ts +++ b/src/commands/git/tag.ts @@ -1,8 +1,7 @@ -import type { QuickPickItem } from 'vscode'; import { QuickInputButtons } from 'vscode'; import type { Container } from '../../container'; -import type { GitTagReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; +import type { GitReference, GitTagReference } from '../../git/models/reference'; +import { getNameWithoutRemote, getReferenceLabel, isRevisionReference } from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; @@ -19,7 +18,6 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, canInputStepContinue, canPickStepContinue, canStepContinue, @@ -27,13 +25,16 @@ import { createInputStep, createPickStep, endSteps, + QuickCommand, + StepResultBreak, +} from '../quickCommand'; +import { + appendReposToTitle, inputTagNameStep, pickBranchOrTagStep, pickRepositoryStep, pickTagsStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; @@ -169,7 +170,9 @@ export class TagGitCommand extends QuickCommand { skippedStepTwo = false; if (context.repos.length === 1) { skippedStepTwo = true; - state.counter++; + if (state.repo == null) { + state.counter++; + } state.repo = context.repos[0]; } else { @@ -242,7 +245,7 @@ export class TagGitCommand extends QuickCommand { `Choose a branch${context.showTags ? ' or tag' : ''} to create the new tag from`, picked: state.reference?.ref ?? (await state.repo.getBranch())?.ref, titleContext: ' from', - value: GitReference.isRevision(state.reference) ? state.reference.ref : undefined, + value: isRevisionReference(state.reference) ? state.reference.ref : undefined, }); // Always break on the first step (so we will go back) if (result === StepResultBreak) break; @@ -253,8 +256,11 @@ export class TagGitCommand extends QuickCommand { if (state.counter < 4 || state.name == null) { const result = yield* inputTagNameStep(state, context, { placeholder: 'Please provide a name for the new tag', - titleContext: ` at ${GitReference.toString(state.reference, { capitalize: true, icon: false })}`, - value: state.name ?? GitReference.getNameWithoutRemote(state.reference), + titleContext: ` at ${getReferenceLabel(state.reference, { + capitalize: true, + icon: false, + })}`, + value: state.name ?? getNameWithoutRemote(state.reference), }); if (result === StepResultBreak) continue; @@ -295,7 +301,10 @@ export class TagGitCommand extends QuickCommand { ): AsyncStepResultGenerator { const step = createInputStep({ title: appendReposToTitle( - `${context.title} at ${GitReference.toString(state.reference, { capitalize: true, icon: false })}`, + `${context.title} at ${getReferenceLabel(state.reference, { + capitalize: true, + icon: false, + })}`, state, context, ), @@ -313,17 +322,14 @@ export class TagGitCommand extends QuickCommand { return value; } - private *createCommandConfirmStep( - state: CreateStepState, - context: Context, - ): StepResultGenerator { + private *createCommandConfirmStep(state: CreateStepState, context: Context): StepResultGenerator { const step: QuickPickStep> = createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context), [ createFlagsQuickPickItem(state.flags, state.message.length !== 0 ? ['-m'] : [], { label: context.title, description: state.message.length !== 0 ? '-m' : '', - detail: `Will create a new tag named ${state.name} at ${GitReference.toString(state.reference)}`, + detail: `Will create a new tag named ${state.name} at ${getReferenceLabel(state.reference)}`, }), createFlagsQuickPickItem( state.flags, @@ -331,7 +337,7 @@ export class TagGitCommand extends QuickCommand { { label: `Force ${context.title}`, description: `--force${state.message.length !== 0 ? ' -m' : ''}`, - detail: `Will forcibly create a new tag named ${state.name} at ${GitReference.toString( + detail: `Will forcibly create a new tag named ${state.name} at ${getReferenceLabel( state.reference, )}`, }, @@ -343,7 +349,7 @@ export class TagGitCommand extends QuickCommand { return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } - private async *deleteCommandSteps(state: DeleteStepState, context: Context): StepGenerator { + private *deleteCommandSteps(state: DeleteStepState, context: Context): StepGenerator { while (this.canStepsContinue(state)) { if (state.references != null && !Array.isArray(state.references)) { state.references = [state.references]; @@ -372,16 +378,13 @@ export class TagGitCommand extends QuickCommand { } } - private *deleteCommandConfirmStep( - state: DeleteStepState, - context: Context, - ): StepResultGenerator { - const step: QuickPickStep = createConfirmStep( + private *deleteCommandConfirmStep(state: DeleteStepState, context: Context): StepResultGenerator { + const step: QuickPickStep = createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context), [ { label: context.title, - detail: `Will delete ${GitReference.toString(state.references)}`, + detail: `Will delete ${getReferenceLabel(state.references)}`, }, ], context, diff --git a/src/commands/git/worktree.ts b/src/commands/git/worktree.ts index 1fa8cbf59ced5..05bf2e869dbf0 100644 --- a/src/commands/git/worktree.ts +++ b/src/commands/git/worktree.ts @@ -1,29 +1,46 @@ import type { MessageItem } from 'vscode'; import { QuickInputButtons, Uri, window, workspace } from 'vscode'; -import type { Config } from '../../configuration'; -import { configuration } from '../../configuration'; +import type { Config } from '../../config'; +import { proBadge, proBadgeSuperscript } from '../../constants'; import type { Container } from '../../container'; +import { CancellationError } from '../../errors'; import { PlusFeatures } from '../../features'; -import { convertOpenFlagsToLocation, reveal, revealInFileExplorer } from '../../git/actions/worktree'; +import { convertLocationToOpenFlags, convertOpenFlagsToLocation, reveal } from '../../git/actions/worktree'; import { + ApplyPatchCommitError, + ApplyPatchCommitErrorReason, WorktreeCreateError, WorktreeCreateErrorReason, WorktreeDeleteError, WorktreeDeleteErrorReason, } from '../../git/errors'; -import { GitReference } from '../../git/models/reference'; +import { uncommitted, uncommittedStaged } from '../../git/models/constants'; +import type { GitReference } from '../../git/models/reference'; +import { + getNameWithoutRemote, + getReferenceFromBranch, + getReferenceLabel, + isBranchReference, + isRevisionReference, + isSha, +} from '../../git/models/reference'; import type { Repository } from '../../git/models/repository'; -import { GitWorktree } from '../../git/models/worktree'; +import type { GitWorktree } from '../../git/models/worktree'; +import { getWorktreeForBranch } from '../../git/models/worktree'; import { showGenericErrorMessage } from '../../messages'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { createQuickPickSeparator } from '../../quickpicks/items/common'; import { Directive } from '../../quickpicks/items/directive'; import type { FlagsQuickPickItem } from '../../quickpicks/items/flags'; import { createFlagsQuickPickItem } from '../../quickpicks/items/flags'; -import { basename, isDescendent } from '../../system/path'; +import { basename } from '../../system/path'; +import type { Deferred } from '../../system/promise'; import { pluralize, truncateLeft } from '../../system/string'; -import { openWorkspace, OpenWorkspaceLocation } from '../../system/utils'; +import { configuration } from '../../system/vscode/configuration'; +import { isDescendant } from '../../system/vscode/path'; +import { getWorkspaceFriendlyPath, openWorkspace, revealInFileExplorer } from '../../system/vscode/utils'; import type { ViewsWithRepositoryFolders } from '../../views/viewBase'; +import { getSteps } from '../gitCommands.utils'; import type { AsyncStepResultGenerator, CustomStep, @@ -35,54 +52,72 @@ import type { StepState, } from '../quickCommand'; import { - appendReposToTitle, - canInputStepContinue, canPickStepContinue, canStepContinue, createConfirmStep, createCustomStep, createPickStep, endSteps, + QuickCommand, + StepResultBreak, +} from '../quickCommand'; +import { + appendReposToTitle, ensureAccessStep, inputBranchNameStep, pickBranchOrTagStep, pickRepositoryStep, pickWorktreesStep, pickWorktreeStep, - QuickCommand, - StepResultBreak, -} from '../quickCommand'; +} from '../quickCommand.steps'; interface Context { repos: Repository[]; associatedView: ViewsWithRepositoryFolders; defaultUri?: Uri; - pickedUri?: Uri; + pickedRootFolder?: Uri; + pickedSpecificFolder?: Uri; showTags: boolean; title: string; worktrees?: GitWorktree[]; } +type CreateConfirmationChoice = Uri | 'changeRoot' | 'chooseFolder'; type CreateFlags = '--force' | '-b' | '--detach' | '--direct'; interface CreateState { subcommand: 'create'; repo: string | Repository; + worktree?: GitWorktree; uri: Uri; reference?: GitReference; - createBranch: string; + addRemote?: { name: string; url: string }; + createBranch?: string; flags: CreateFlags[]; + result?: Deferred; reveal?: boolean; + + overrides?: { + title?: string; + }; + + onWorkspaceChanging?: (() => Promise) | (() => void); + skipWorktreeConfirmations?: boolean; } -type DeleteFlags = '--force'; +type DeleteFlags = '--force' | '--delete-branches'; interface DeleteState { subcommand: 'delete'; repo: string | Repository; uris: Uri[]; flags: DeleteFlags[]; + + startingFromBranchDelete?: boolean; + overrides?: { + title?: string; + }; } type OpenFlags = '--add-to-workspace' | '--new-window' | '--reveal-explorer'; @@ -90,23 +125,64 @@ type OpenFlags = '--add-to-workspace' | '--new-window' | '--reveal-explorer'; interface OpenState { subcommand: 'open'; repo: string | Repository; - uri: Uri; + worktree: GitWorktree; flags: OpenFlags[]; + + openOnly?: boolean; + overrides?: { + disallowBack?: boolean; + title?: string; + + confirmation?: { + title?: string; + placeholder?: string; + }; + }; + + onWorkspaceChanging?: (() => Promise) | (() => void); + skipWorktreeConfirmations?: boolean; +} + +interface CopyChangesState { + subcommand: 'copy-changes'; + repo: string | Repository; + worktree: GitWorktree; + changes: + | { baseSha?: string; contents?: string; type: 'index' | 'working-tree' } + | { baseSha: string; contents: string; type?: 'index' | 'working-tree' }; + + overrides?: { + title?: string; + }; } -type State = CreateState | DeleteState | OpenState; +type State = CreateState | DeleteState | OpenState | CopyChangesState; type WorktreeStepState = SomeNonNullable, 'subcommand'>; type CreateStepState = WorktreeStepState>; type DeleteStepState = WorktreeStepState>; type OpenStepState = WorktreeStepState>; +type CopyChangesStepState = WorktreeStepState< + ExcludeSome +>; + +function assertStateStepRepository( + state: PartialStepState, +): asserts state is PartialStepState & { repo: Repository } { + if (state.repo != null && typeof state.repo !== 'string') return; + + debugger; + throw new Error('Missing repository'); +} -const subcommandToTitleMap = new Map([ - ['create', 'Create'], - ['delete', 'Delete'], - ['open', 'Open'], +const subcommandToTitleMap = new Map([ + [undefined, `Worktrees ${proBadgeSuperscript}`], + ['create', `Create Worktree`], + ['delete', `Delete Worktrees`], + ['open', `Open Worktree`], + ['copy-changes', 'Copy Changes to'], ]); -function getTitle(title: string, subcommand: State['subcommand'] | undefined) { - return subcommand == null ? title : `${subcommandToTitleMap.get(subcommand)} ${title}`; +function getTitle(subcommand: State['subcommand'] | undefined, suffix?: string) { + return `${subcommandToTitleMap.get(subcommand)}${suffix ?? ''}`; } export interface WorktreeGitCommandArgs { @@ -117,11 +193,10 @@ export interface WorktreeGitCommandArgs { export class WorktreeGitCommand extends QuickCommand { private subcommand: State['subcommand'] | undefined; - private canSkipConfirmOverride: boolean | undefined; constructor(container: Container, args?: WorktreeGitCommandArgs) { - super(container, 'worktree', 'worktree', 'Worktree', { - description: 'open, create, or delete worktrees', + super(container, 'worktree', 'worktree', `Worktrees ${proBadgeSuperscript}`, { + description: `${proBadge}\u00a0\u00a0open, create, or delete worktrees`, }); let counter = 0; @@ -146,7 +221,13 @@ export class WorktreeGitCommand extends QuickCommand { break; case 'open': - if (args.state.uri != null) { + if (args.state.worktree != null) { + counter++; + } + + break; + case 'copy-changes': + if (args.state.worktree != null) { counter++; } @@ -169,8 +250,9 @@ export class WorktreeGitCommand extends QuickCommand { return this.subcommand != null; } + private _canSkipConfirmOverride: boolean | undefined; override get canSkipConfirm(): boolean { - return this.canSkipConfirmOverride ?? false; + return this._canSkipConfirmOverride ?? this.subcommand === 'open'; } override get skipConfirmKey() { @@ -188,7 +270,7 @@ export class WorktreeGitCommand extends QuickCommand { let skippedStepTwo = false; while (this.canStepsContinue(state)) { - context.title = this.title; + context.title = state.overrides?.title ?? this.title; if (state.counter < 1 || state.subcommand == null) { this.subcommand = undefined; @@ -201,12 +283,15 @@ export class WorktreeGitCommand extends QuickCommand { } this.subcommand = state.subcommand; + context.title = state.overrides?.title ?? getTitle(state.subcommand); if (state.counter < 2 || state.repo == null || typeof state.repo === 'string') { skippedStepTwo = false; if (context.repos.length === 1) { skippedStepTwo = true; - state.counter++; + if (state.repo == null) { + state.counter++; + } state.repo = context.repos[0]; } else { @@ -217,13 +302,14 @@ export class WorktreeGitCommand extends QuickCommand { } } - // Ensure we use the "main" repository if we are in a worktree already - state.repo = await state.repo.getMainRepository(); - - const result = yield* ensureAccessStep(state as any, context, PlusFeatures.Worktrees); - if (result === StepResultBreak) break; + if (state.subcommand !== 'copy-changes') { + // Ensure we use the "main" repository if we are in a worktree already + state.repo = (await state.repo.getCommonRepository()) ?? state.repo; + } + assertStateStepRepository(state); - context.title = getTitle(state.subcommand === 'delete' ? 'Worktrees' : this.title, state.subcommand); + const result = yield* ensureAccessStep(state, context, PlusFeatures.Worktrees); + if (result === StepResultBreak) continue; switch (state.subcommand) { case 'create': { @@ -244,6 +330,10 @@ export class WorktreeGitCommand extends QuickCommand { yield* this.openCommandSteps(state as OpenStepState, context); break; } + case 'copy-changes': { + yield* this.copyChangesCommandSteps(state as CopyChangesStepState, context); + break; + } default: endSteps(state); break; @@ -297,11 +387,12 @@ export class WorktreeGitCommand extends QuickCommand { state.flags = []; } - context.pickedUri = undefined; + context.pickedRootFolder = undefined; + context.pickedSpecificFolder = undefined; // Don't allow skipping the confirm step state.confirm = true; - this.canSkipConfirmOverride = undefined; + this._canSkipConfirmOverride = undefined; while (this.canStepsContinue(state)) { if (state.counter < 3 || state.reference == null) { @@ -310,7 +401,7 @@ export class WorktreeGitCommand extends QuickCommand { `Choose a branch${context.showTags ? ' or tag' : ''} to create the new worktree for`, picked: state.reference?.ref ?? (await state.repo.getBranch())?.ref, titleContext: ' for', - value: GitReference.isRevision(state.reference) ? state.reference.ref : undefined, + value: isRevisionReference(state.reference) ? state.reference.ref : undefined, }); // Always break on the first step (so we will go back) if (result === StepResultBreak) break; @@ -318,27 +409,59 @@ export class WorktreeGitCommand extends QuickCommand { state.reference = result; } - if (state.counter < 4 || state.uri == null) { - if ( - state.reference != null && - !configuration.get('worktrees.promptForLocation', state.repo.folder) && - context.defaultUri != null - ) { - state.uri = context.defaultUri; - } else { - const result = yield* this.createCommandChoosePathStep(state, context, { - titleContext: ` for ${GitReference.toString(state.reference, { + if (state.uri == null) { + state.uri = context.defaultUri!; + } + + state.worktree = + isBranchReference(state.reference) && !state.reference.remote + ? await getWorktreeForBranch(state.repo, state.reference.name, undefined, context.worktrees) + : undefined; + + const isRemoteBranch = isBranchReference(state.reference) && state.reference?.remote; + if ((isRemoteBranch || state.worktree != null) && !state.flags.includes('-b')) { + state.flags.push('-b'); + } + + if (isRemoteBranch) { + state.createBranch = getNameWithoutRemote(state.reference); + const branch = await state.repo.getBranch(state.createBranch); + if (branch != null && !branch.remote) { + state.createBranch = branch.name; + } + } + + if (state.flags.includes('-b')) { + let createBranchOverride: string | undefined; + if (state.createBranch != null) { + let valid = await this.container.git.validateBranchOrTagName(state.repo.path, state.createBranch); + if (valid) { + const alreadyExists = await state.repo.getBranch(state.createBranch); + valid = alreadyExists == null; + } + + if (!valid) { + createBranchOverride = state.createBranch; + state.createBranch = undefined; + } + } + + if (state.createBranch == null) { + const result = yield* inputBranchNameStep(state, context, { + titleContext: ` and New Branch from ${getReferenceLabel(state.reference, { capitalize: true, icon: false, label: state.reference.refType !== 'branch', })}`, - defaultUri: context.defaultUri, + value: createBranchOverride ?? getNameWithoutRemote(state.reference), }); - if (result === StepResultBreak) continue; + if (result === StepResultBreak) { + // Clear the flags, since we can backup after the confirm step below (which is non-standard) + state.flags = []; + continue; + } - state.uri = result; - // Keep track of the actual uri they picked, because we will modify it in later steps - context.pickedUri = state.uri; + state.createBranch = result; } } @@ -346,37 +469,47 @@ export class WorktreeGitCommand extends QuickCommand { const result = yield* this.createCommandConfirmStep(state, context); if (result === StepResultBreak) continue; + if (typeof result[0] === 'string') { + switch (result[0]) { + case 'changeRoot': { + const result = yield* this.createCommandChoosePathStep(state, context, { + title: `Choose a Different Root Folder for this Worktree`, + label: 'Choose Root Folder', + pickedUri: context.pickedRootFolder, + defaultUri: context.pickedRootFolder ?? context.defaultUri, + }); + if (result === StepResultBreak) continue; + + state.uri = result; + // Keep track of the actual uri they picked, because we will modify it in later steps + context.pickedRootFolder = state.uri; + context.pickedSpecificFolder = undefined; + continue; + } + case 'chooseFolder': { + const result = yield* this.createCommandChoosePathStep(state, context, { + title: `Choose a Specific Folder for this Worktree`, + label: 'Choose Worktree Folder', + pickedUri: context.pickedRootFolder, + defaultUri: context.pickedSpecificFolder ?? context.defaultUri, + }); + if (result === StepResultBreak) continue; + + state.uri = result; + // Keep track of the actual uri they picked, because we will modify it in later steps + context.pickedRootFolder = undefined; + context.pickedSpecificFolder = state.uri; + continue; + } + } + } + [state.uri, state.flags] = result; } // Reset any confirmation overrides state.confirm = true; - this.canSkipConfirmOverride = undefined; - - const isRemoteBranch = state.reference?.refType === 'branch' && state.reference?.remote; - if (isRemoteBranch && !state.flags.includes('-b')) { - state.flags.push('-b'); - state.createBranch = GitReference.getNameWithoutRemote(state.reference); - } - - if (state.flags.includes('-b') && state.createBranch == null) { - const result = yield* inputBranchNameStep(state, context, { - placeholder: 'Please provide a name for the new branch', - titleContext: ` from ${GitReference.toString(state.reference, { - capitalize: true, - icon: false, - label: state.reference.refType !== 'branch', - })}`, - value: state.createBranch ?? GitReference.getNameWithoutRemote(state.reference), - }); - if (result === StepResultBreak) { - // Clear the flags, since we can backup after the confirm step below (which is non-standard) - state.flags = []; - continue; - } - - state.createBranch = result; - } + this._canSkipConfirmOverride = undefined; const uri = state.flags.includes('--direct') ? state.uri @@ -387,19 +520,17 @@ export class WorktreeGitCommand extends QuickCommand { let worktree: GitWorktree | undefined; try { + if (state.addRemote != null) { + await state.repo.addRemote(state.addRemote.name, state.addRemote.url, { fetch: true }); + } + worktree = await state.repo.createWorktree(uri, { commitish: state.reference?.name, createBranch: state.flags.includes('-b') ? state.createBranch : undefined, detach: state.flags.includes('--detach'), force: state.flags.includes('--force'), }); - - if (state.reveal !== false) { - void reveal(undefined, { - select: true, - focus: true, - }); - } + state.result?.fulfill(worktree); } catch (ex) { if ( WorktreeCreateError.is(ex, WorktreeCreateErrorReason.AlreadyCheckedOut) && @@ -409,7 +540,7 @@ export class WorktreeGitCommand extends QuickCommand { const force: MessageItem = { title: 'Create Anyway' }; const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; const result = await window.showWarningMessage( - `Unable to create the new worktree because ${GitReference.toString(state.reference, { + `Unable to create the new worktree because ${getReferenceLabel(state.reference, { icon: false, quoted: true, })} is already checked out.\n\nWould you like to create a new branch for this worktree or forcibly create it anyway?`, @@ -421,27 +552,36 @@ export class WorktreeGitCommand extends QuickCommand { if (result === createBranch) { state.flags.push('-b'); - this.canSkipConfirmOverride = true; + this._canSkipConfirmOverride = true; state.confirm = false; continue; } if (result === force) { state.flags.push('--force'); - this.canSkipConfirmOverride = true; + this._canSkipConfirmOverride = true; state.confirm = false; continue; } } else if (WorktreeCreateError.is(ex, WorktreeCreateErrorReason.AlreadyExists)) { - void window.showErrorMessage( - `Unable to create a new worktree in '${GitWorktree.getFriendlyPath( - uri, - )}' because the folder already exists and is not empty.`, - 'OK', - ); + const confirm: MessageItem = { title: 'OK' }; + const openFolder: MessageItem = { title: 'Open Folder' }; + void window + .showErrorMessage( + `Unable to create a new worktree in '${getWorkspaceFriendlyPath( + uri, + )}' because the folder already exists and is not empty.`, + confirm, + openFolder, + ) + .then(result => { + if (result === openFolder) { + void revealInFileExplorer(uri); + } + }); } else { void showGenericErrorMessage( - `Unable to create a new worktree in '${GitWorktree.getFriendlyPath(uri)}.`, + `Unable to create a new worktree in '${getWorkspaceFriendlyPath(uri)}.`, ); } } @@ -449,70 +589,68 @@ export class WorktreeGitCommand extends QuickCommand { endSteps(state); if (worktree == null) break; + if (state.reveal !== false) { + setTimeout(() => { + if (this.container.worktreesView.visible) { + void reveal(worktree, { select: true, focus: false }); + } + }, 100); + } + type OpenAction = Config['worktrees']['openAfterCreate']; const action: OpenAction = configuration.get('worktrees.openAfterCreate'); - if (action === 'never') break; + if (action !== 'never') { + let flags: OpenFlags[]; + switch (action) { + case 'always': + flags = convertLocationToOpenFlags('currentWindow'); + break; + case 'alwaysNewWindow': + flags = convertLocationToOpenFlags('newWindow'); + break; + case 'onlyWhenEmpty': + flags = convertLocationToOpenFlags( + workspace.workspaceFolders?.length ? 'newWindow' : 'currentWindow', + ); + break; + default: + flags = []; + break; + } - if (action === 'prompt') { yield* this.openCommandSteps( { subcommand: 'open', repo: state.repo, - uri: worktree.uri, + worktree: worktree, + flags: flags, counter: 3, - confirm: true, - } as OpenStepState, + confirm: action === 'prompt', + openOnly: true, + overrides: { disallowBack: true }, + skipWorktreeConfirmations: state.skipWorktreeConfirmations, + onWorkspaceChanging: state.onWorkspaceChanging, + } satisfies OpenStepState, context, ); - - break; } - - queueMicrotask(() => { - switch (action) { - case 'always': - openWorkspace(worktree!.uri, { location: OpenWorkspaceLocation.CurrentWindow }); - break; - case 'alwaysNewWindow': - openWorkspace(worktree!.uri, { location: OpenWorkspaceLocation.NewWindow }); - break; - case 'onlyWhenEmpty': - openWorkspace(worktree!.uri, { - location: workspace.workspaceFolders?.length - ? OpenWorkspaceLocation.CurrentWindow - : OpenWorkspaceLocation.NewWindow, - }); - break; - } - }); } } - private async *createCommandChoosePathStep( + private *createCommandChoosePathStep( state: CreateStepState, context: Context, - options: { titleContext: string; defaultUri?: Uri }, - ): AsyncStepResultGenerator { + options: { title: string; label: string; pickedUri: Uri | undefined; defaultUri?: Uri }, + ): StepResultGenerator { const step = createCustomStep({ show: async (_step: CustomStep) => { - const hasDefault = options?.defaultUri != null; - const result = await window.showInformationMessage( - `Choose a location in which to create the worktree${options.titleContext}.`, - { modal: true }, - { title: 'Choose Location' }, - ...(hasDefault ? [{ title: 'Use Default Location' }] : []), - ); - - if (result == null) return Directive.Back; - if (result.title === 'Use Default Location') return options.defaultUri!; - const uris = await window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, - defaultUri: context.pickedUri ?? state.uri ?? context.defaultUri, - openLabel: 'Select Worktree Location', - title: `${appendReposToTitle(`Choose a Worktree Location${options.titleContext}`, state, context)}`, + defaultUri: options.pickedUri ?? state.uri ?? context.defaultUri, + openLabel: options.label, + title: options.title, }); if (uris == null || uris.length === 0) return Directive.Back; @@ -522,10 +660,7 @@ export class WorktreeGitCommand extends QuickCommand { }); const value: StepSelection = yield step; - - if (!canStepContinue(step, state, value) || !(await canInputStepContinue(step, state, value))) { - return StepResultBreak; - } + if (!canStepContinue(step, state, value)) return StepResultBreak; return value; } @@ -533,7 +668,7 @@ export class WorktreeGitCommand extends QuickCommand { private *createCommandConfirmStep( state: CreateStepState, context: Context, - ): StepResultGenerator<[Uri, CreateFlags[]]> { + ): StepResultGenerator<[CreateConfirmationChoice, CreateFlags[]]> { /** * Here are the rules for creating the recommended path for the new worktree: * @@ -542,17 +677,21 @@ export class WorktreeGitCommand extends QuickCommand { * If the user picks a folder inside the repo, it will be `/../.worktrees/` */ - const pickedUri = context.pickedUri ?? state.uri; - const pickedFriendlyPath = truncateLeft(GitWorktree.getFriendlyPath(pickedUri), 60); + let createDirectlyInFolder = false; + if (context.pickedSpecificFolder != null) { + createDirectlyInFolder = true; + } + + const pickedUri = context.pickedSpecificFolder ?? context.pickedRootFolder ?? state.uri; + const pickedFriendlyPath = truncateLeft(getWorkspaceFriendlyPath(pickedUri), 60); - let canCreateDirectlyInPicked = true; let recommendedRootUri; const repoUri = state.repo.uri; const trailer = `${basename(repoUri.path)}.worktrees`; if (repoUri.toString() !== pickedUri.toString()) { - if (isDescendent(pickedUri, repoUri)) { + if (isDescendant(pickedUri, repoUri)) { recommendedRootUri = Uri.joinPath(repoUri, '..', trailer); } else if (basename(pickedUri.path) === trailer) { recommendedRootUri = pickedUri; @@ -562,85 +701,126 @@ export class WorktreeGitCommand extends QuickCommand { } else { recommendedRootUri = Uri.joinPath(repoUri, '..', trailer); // Don't allow creating directly into the main worktree folder - canCreateDirectlyInPicked = false; + createDirectlyInFolder = false; } - const recommendedUri = - state.reference != null - ? Uri.joinPath( - recommendedRootUri, - ...GitReference.getNameWithoutRemote(state.reference).replace(/\\/g, '/').split('/'), - ) - : recommendedRootUri; - const recommendedFriendlyPath = truncateLeft(GitWorktree.getFriendlyPath(recommendedUri), 65); - - const recommendedNewBranchFriendlyPath = truncateLeft( - GitWorktree.getFriendlyPath(Uri.joinPath(recommendedRootUri, '')), - 60, + const branchName = state.reference != null ? getNameWithoutRemote(state.reference) : undefined; + + const recommendedFriendlyPath = `/${truncateLeft( + `${trailer}/${branchName?.replace(/\\/g, '/') ?? ''}`, + 65, + )}`; + const recommendedNewBranchFriendlyPath = `/${trailer}/${state.createBranch || ''}`; + + const isBranch = isBranchReference(state.reference); + const isRemoteBranch = isBranchReference(state.reference) && state.reference?.remote; + + type StepType = FlagsQuickPickItem; + const defaultOption = createFlagsQuickPickItem( + state.flags, + state.createBranch ? ['-b'] : [], + { + label: isRemoteBranch + ? 'Create Worktree for New Local Branch' + : isBranch + ? 'Create Worktree for Branch' + : context.title, + description: '', + detail: `Will create worktree in $(folder) ${ + state.createBranch ? recommendedNewBranchFriendlyPath : recommendedFriendlyPath + }`, + }, + recommendedRootUri, ); - const isRemoteBranch = state.reference?.refType === 'branch' && state.reference?.remote; + const confirmations: StepType[] = []; + if (!createDirectlyInFolder) { + if (state.skipWorktreeConfirmations) { + return [defaultOption.context, defaultOption.item]; + } - const step: QuickPickStep> = createConfirmStep( - appendReposToTitle( - `Confirm ${context.title} \u2022 ${GitReference.toString(state.reference, { - icon: false, - label: false, - })}`, - state, - context, - ), - [ + confirmations.push(defaultOption); + } else { + if (!state.createBranch) { + confirmations.push( + createFlagsQuickPickItem( + state.flags, + ['--direct'], + { + label: isRemoteBranch + ? 'Create Worktree for Local Branch' + : isBranch + ? 'Create Worktree for Branch' + : context.title, + description: '', + detail: `Will create worktree directly in $(folder) ${truncateLeft( + pickedFriendlyPath, + 60, + )}`, + }, + pickedUri, + ), + ); + } + + confirmations.push( createFlagsQuickPickItem( state.flags, - [], + ['-b', '--direct'], { - label: isRemoteBranch ? 'Create Local Branch and Worktree' : context.title, - description: ' in subfolder', - detail: `Will create worktree in $(folder) ${recommendedFriendlyPath}`, + label: isRemoteBranch + ? 'Create Worktree for New Local Branch' + : 'Create Worktree for New Branch', + description: '', + detail: `Will create worktree directly in $(folder) ${truncateLeft(pickedFriendlyPath, 60)}`, }, - recommendedRootUri, + pickedUri, ), - createFlagsQuickPickItem( - state.flags, - ['-b'], + ); + } + + if (!createDirectlyInFolder) { + confirmations.push( + createQuickPickSeparator(), + createFlagsQuickPickItem( + [], + [], { - label: isRemoteBranch - ? 'Create New Local Branch and Worktree' - : 'Create New Branch and Worktree', - description: ' in subfolder', - detail: `Will create worktree in $(folder) ${recommendedNewBranchFriendlyPath}`, + label: 'Change Root Folder...', + description: `$(folder) ${truncateLeft(pickedFriendlyPath, 65)}`, + picked: false, }, - recommendedRootUri, + 'changeRoot', ), - ...(canCreateDirectlyInPicked - ? [ - createQuickPickSeparator(), - createFlagsQuickPickItem( - state.flags, - ['--direct'], - { - label: isRemoteBranch ? 'Create Local Branch and Worktree' : context.title, - description: ' directly in folder', - detail: `Will create worktree directly in $(folder) ${pickedFriendlyPath}`, - }, - pickedUri, - ), - createFlagsQuickPickItem( - state.flags, - ['-b', '--direct'], - { - label: isRemoteBranch - ? 'Create New Local Branch and Worktree' - : 'Create New Branch and Worktree', - description: ' directly in folder', - detail: `Will create worktree directly in $(folder) ${pickedFriendlyPath}`, - }, - pickedUri, - ), - ] - : []), - ] as FlagsQuickPickItem[], + ); + } + + confirmations.push( + createFlagsQuickPickItem( + [], + [], + { + label: 'Choose a Specific Folder...', + description: '', + picked: false, + }, + 'chooseFolder', + ), + ); + + const step = createConfirmStep( + appendReposToTitle( + `Confirm ${context.title} \u2022 ${ + state.createBranch || + getReferenceLabel(state.reference, { + icon: false, + label: false, + }) + }`, + state, + context, + ), + confirmations, context, ); const selection: StepSelection = yield step; @@ -658,10 +838,12 @@ export class WorktreeGitCommand extends QuickCommand { while (this.canStepsContinue(state)) { if (state.counter < 3 || state.uris == null || state.uris.length === 0) { - context.title = getTitle('Worktrees', state.subcommand); + context.title = getTitle(state.subcommand); const result = yield* pickWorktreesStep(state, context, { - filter: wt => wt.main || !wt.opened, // Can't delete the main or opened worktree + // Can't delete the main or opened worktree + excludeOpened: true, + filter: wt => !wt.isDefault, includeStatus: true, picked: state.uris?.map(uri => uri.toString()), placeholder: 'Choose worktrees to delete', @@ -672,7 +854,7 @@ export class WorktreeGitCommand extends QuickCommand { state.uris = result.map(w => w.uri); } - context.title = getTitle(pluralize('Worktree', state.uris.length, { only: true }), state.subcommand); + context.title = getTitle(state.subcommand); const result = yield* this.deleteCommandConfirmStep(state, context); if (result === StepResultBreak) continue; @@ -681,17 +863,25 @@ export class WorktreeGitCommand extends QuickCommand { endSteps(state); + const branchesToDelete = []; + for (const uri of state.uris) { let retry = false; + let skipHasChangesPrompt = false; do { retry = false; const force = state.flags.includes('--force'); + const deleteBranches = state.flags.includes('--delete-branches'); try { + const worktree = context.worktrees.find(wt => wt.uri.toString() === uri.toString()); if (force) { - const worktree = context.worktrees.find(wt => wt.uri.toString() === uri.toString()); - const status = await worktree?.getStatus(); - if (status?.hasChanges ?? false) { + let status; + try { + status = await worktree?.getStatus(); + } catch {} + + if ((status?.hasChanges ?? false) && !skipHasChangesPrompt) { const confirm: MessageItem = { title: 'Force Delete' }; const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; const result = await window.showWarningMessage( @@ -706,7 +896,12 @@ export class WorktreeGitCommand extends QuickCommand { } await state.repo.deleteWorktree(uri, { force: force }); + if (deleteBranches && worktree?.branch) { + branchesToDelete.push(getReferenceFromBranch(worktree?.branch)); + } } catch (ex) { + skipHasChangesPrompt = false; + if (WorktreeDeleteError.is(ex)) { if (ex.reason === WorktreeDeleteErrorReason.MainWorkingTree) { void window.showErrorMessage('Unable to delete the main worktree'); @@ -725,6 +920,7 @@ export class WorktreeGitCommand extends QuickCommand { if (result === confirm) { state.flags.push('--force'); retry = true; + skipHasChangesPrompt = ex.reason === WorktreeDeleteErrorReason.HasChanges; } } } else { @@ -733,28 +929,70 @@ export class WorktreeGitCommand extends QuickCommand { } } while (retry); } + + if (branchesToDelete.length) { + yield* getSteps( + this.container, + { + command: 'branch', + state: { + subcommand: 'delete', + repo: state.repo, + references: branchesToDelete, + }, + }, + this.pickedVia, + ); + } } } private *deleteCommandConfirmStep(state: DeleteStepState, context: Context): StepResultGenerator { + context.title = state.uris.length === 1 ? 'Delete Worktree' : 'Delete Worktrees'; + + const label = state.uris.length === 1 ? 'Delete Worktree' : 'Delete Worktrees'; + const branchesLabel = state.uris.length === 1 ? 'Branch' : 'Branches'; + let selectedBranchesLabelSuffix = ''; + if (state.startingFromBranchDelete) { + selectedBranchesLabelSuffix = ` for ${branchesLabel}`; + context.title = `${context.title}${selectedBranchesLabelSuffix}`; + } + + const description = + state.uris.length === 1 + ? `delete worktree in $(folder) ${getWorkspaceFriendlyPath(state.uris[0])}` + : `delete ${state.uris.length} worktrees`; + const descriptionWithBranchDelete = + state.uris.length === 1 + ? 'delete the worktree and then prompt to delete the associated branch' + : `delete ${state.uris.length} worktrees and then prompt to delete the associated branches`; + const step: QuickPickStep> = createConfirmStep( appendReposToTitle(`Confirm ${context.title}`, state, context), [ createFlagsQuickPickItem(state.flags, [], { - label: context.title, - detail: `Will delete ${pluralize('worktree', state.uris.length, { - only: state.uris.length === 1, - })}${state.uris.length === 1 ? ` in $(folder) ${GitWorktree.getFriendlyPath(state.uris[0])}` : ''}`, + label: `${label}${selectedBranchesLabelSuffix}`, + detail: `Will ${description}`, }), createFlagsQuickPickItem(state.flags, ['--force'], { - label: `Force ${context.title}`, - description: 'including ANY UNCOMMITTED changes', - detail: `Will forcibly delete ${pluralize('worktree', state.uris.length, { - only: state.uris.length === 1, - })} ${ - state.uris.length === 1 ? ` in $(folder) ${GitWorktree.getFriendlyPath(state.uris[0])}` : '' - }`, + label: `Force ${label}${selectedBranchesLabelSuffix}`, + description: 'includes ANY UNCOMMITTED changes', + detail: `Will forcibly ${description}`, }), + ...(state.startingFromBranchDelete + ? [] + : [ + createQuickPickSeparator>(), + createFlagsQuickPickItem(state.flags, ['--delete-branches'], { + label: `${label} & ${branchesLabel}`, + detail: `Will ${descriptionWithBranchDelete}`, + }), + createFlagsQuickPickItem(state.flags, ['--force', '--delete-branches'], { + label: `Force ${label} & ${branchesLabel}`, + description: 'includes ANY UNCOMMITTED changes', + detail: `Will forcibly ${descriptionWithBranchDelete}`, + }), + ]), ], context, ); @@ -764,80 +1002,271 @@ export class WorktreeGitCommand extends QuickCommand { } private async *openCommandSteps(state: OpenStepState, context: Context): StepGenerator { - context.worktrees = await state.repo.getWorktrees(); - if (state.flags == null) { state.flags = []; } + // Allow skipping the confirm step + this._canSkipConfirmOverride = true; + while (this.canStepsContinue(state)) { - if (state.counter < 3 || state.uri == null) { - context.title = getTitle('Worktree', state.subcommand); + if (state.counter < 3 || state.worktree == null) { + context.title = getTitle(state.subcommand); + context.worktrees ??= await state.repo.getWorktrees(); const result = yield* pickWorktreeStep(state, context, { + excludeOpened: true, includeStatus: true, - picked: state.uri?.toString(), + picked: state.worktree?.uri?.toString(), placeholder: 'Choose worktree to open', }); // Always break on the first step (so we will go back) if (result === StepResultBreak) break; - state.uri = result.uri; + state.worktree = result; } - context.title = getTitle('Worktree', state.subcommand); + context.title = getTitle(state.subcommand, ` \u2022 ${state.worktree.name}`); - const result = yield* this.openCommandConfirmStep(state, context); - if (result === StepResultBreak) continue; + if (this.confirm(state.confirm)) { + const result = yield* this.openCommandConfirmStep(state, context); + if (result === StepResultBreak) continue; - state.flags = result; + state.flags = result; + } endSteps(state); - const worktree = context.worktrees.find(wt => wt.uri.toString() === state.uri.toString()); - if (worktree == null) break; - if (state.flags.includes('--reveal-explorer')) { - void revealInFileExplorer(worktree); + void revealInFileExplorer(state.worktree.uri); } else { - openWorkspace(worktree.uri, { location: convertOpenFlagsToLocation(state.flags) }); + let name; + + const repo = (await state.repo.getCommonRepository()) ?? state.repo; + if (repo.name !== state.worktree.name) { + name = `${repo.name}: ${state.worktree.name}`; + } else { + name = state.worktree.name; + } + + const location = convertOpenFlagsToLocation(state.flags); + if (location === 'currentWindow' || location === 'newWindow') { + await state.onWorkspaceChanging?.(); + } + + openWorkspace(state.worktree.uri, { location: convertOpenFlagsToLocation(state.flags), name: name }); } } } private *openCommandConfirmStep(state: OpenStepState, context: Context): StepResultGenerator { - const step: QuickPickStep> = createConfirmStep( - appendReposToTitle(`Confirm ${context.title}`, state, context), - [ - createFlagsQuickPickItem(state.flags, [], { - label: context.title, - detail: `Will open in the current window, the worktree in $(folder) ${GitWorktree.getFriendlyPath( - state.uri, - )}`, - }), - createFlagsQuickPickItem(state.flags, ['--new-window'], { - label: `${context.title} in a New Window`, - detail: `Will open in a new window, the worktree in $(folder) ${GitWorktree.getFriendlyPath( - state.uri, - )}`, - }), - createFlagsQuickPickItem(state.flags, ['--add-to-workspace'], { - label: `Add Worktree to Workspace`, - detail: `Will add into the current workspace, the worktree in $(folder) ${GitWorktree.getFriendlyPath( - state.uri, - )}`, - }), + type StepType = FlagsQuickPickItem; + + const newWindowItem = createFlagsQuickPickItem(state.flags, ['--new-window'], { + label: `Open Worktree in a New Window`, + detail: 'Will open the worktree in a new window', + }); + + if (state.skipWorktreeConfirmations) { + return newWindowItem.item; + } + + const confirmations: StepType[] = [ + createFlagsQuickPickItem(state.flags, [], { + label: 'Open Worktree', + detail: 'Will open the worktree in the current window', + }), + newWindowItem, + createFlagsQuickPickItem(state.flags, ['--add-to-workspace'], { + label: `Add Worktree to Workspace`, + detail: 'Will add the worktree into the current workspace', + }), + ]; + + if (!state.openOnly) { + confirmations.push( + createQuickPickSeparator(), createFlagsQuickPickItem(state.flags, ['--reveal-explorer'], { label: `Reveal in File Explorer`, - detail: `Will open in the File Explorer, the worktree in $(folder) ${GitWorktree.getFriendlyPath( - state.uri, - )}`, + description: `$(folder) ${truncateLeft(getWorkspaceFriendlyPath(state.worktree.uri), 40)}`, + detail: 'Will open the worktree in the File Explorer', }), - ], + ); + } + + const step = createConfirmStep( + appendReposToTitle(state.overrides?.confirmation?.title ?? `Confirm ${context.title}`, state, context), + confirmations, context, + undefined, + { + disallowBack: state.overrides?.disallowBack, + placeholder: state.overrides?.confirmation?.placeholder ?? 'Confirm Open Worktree', + }, ); const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } + + private async *copyChangesCommandSteps(state: CopyChangesStepState, context: Context): StepGenerator { + while (this.canStepsContinue(state)) { + context.title = state?.overrides?.title ?? getTitle(state.subcommand); + + if (state.counter < 3 || state.worktree == null) { + context.worktrees ??= await state.repo.getWorktrees(); + + let placeholder; + switch (state.changes.type) { + case 'index': + placeholder = 'Choose a worktree to copy your staged changes to'; + break; + case 'working-tree': + placeholder = 'Choose a worktree to copy your working changes to'; + break; + default: + placeholder = 'Choose a worktree to copy changes to'; + break; + } + + const result = yield* pickWorktreeStep(state, context, { + excludeOpened: true, + includeStatus: true, + picked: state.worktree?.uri?.toString(), + placeholder: placeholder, + }); + // Always break on the first step (so we will go back) + if (result === StepResultBreak) break; + + state.worktree = result; + } + + if (!state.changes.contents || !state.changes.baseSha) { + const diff = await this.container.git.getDiff( + state.repo.uri, + state.changes.type === 'index' ? uncommittedStaged : uncommitted, + 'HEAD', + ); + if (!diff?.contents) { + void window.showErrorMessage(`No changes to copy`); + + endSteps(state); + break; + } + + state.changes.contents = diff.contents; + state.changes.baseSha = diff.from; + } + + if (!isSha(state.changes.baseSha)) { + const commit = await this.container.git.getCommit(state.repo.uri, state.changes.baseSha); + if (commit != null) { + state.changes.baseSha = commit.sha; + } + } + + if (this.confirm(state.confirm)) { + const result = yield* this.copyChangesCommandConfirmStep(state, context); + if (result === StepResultBreak) continue; + } + + endSteps(state); + + try { + const commit = await this.container.git.createUnreachableCommitForPatch( + state.worktree.uri, + state.changes.contents, + state.changes.baseSha, + 'Copied Changes', + ); + if (commit == null) return; + + await this.container.git.applyUnreachableCommitForPatch(state.worktree.uri, commit.sha, { + stash: false, + }); + void window.showInformationMessage(`Changes copied successfully`); + } catch (ex) { + if (ex instanceof CancellationError) return; + + if (ex instanceof ApplyPatchCommitError) { + if (ex.reason === ApplyPatchCommitErrorReason.AppliedWithConflicts) { + void window.showWarningMessage('Changes copied with conflicts'); + } else if (ex.reason === ApplyPatchCommitErrorReason.ApplyAbortedWouldOverwrite) { + void window.showErrorMessage( + 'Unable to copy changes as some local changes would be overwritten', + ); + return; + } else { + void window.showErrorMessage(`Unable to copy changes: ${ex.message}`); + return; + } + } else { + void window.showErrorMessage(`Unable to copy changes: ${ex.message}`); + return; + } + } + + yield* this.openCommandSteps( + { + subcommand: 'open', + repo: state.repo, + worktree: state.worktree, + flags: [], + counter: 3, + confirm: true, + openOnly: true, + overrides: { disallowBack: true }, + } satisfies OpenStepState, + context, + ); + } + } + + private async *copyChangesCommandConfirmStep( + state: CopyChangesStepState, + context: Context, + ): AsyncStepResultGenerator { + const files = await this.container.git.getDiffFiles(state.repo.uri, state.changes.contents!); + const count = files?.files.length ?? 0; + + const confirmations = []; + switch (state.changes.type) { + case 'index': + confirmations.push({ + label: 'Copy Staged Changes to Worktree', + detail: `Will copy the staged changes${ + count > 0 ? ` (${pluralize('file', count)})` : '' + } to worktree '${state.worktree.name}'`, + }); + break; + case 'working-tree': + confirmations.push({ + label: 'Copy Working Changes to Worktree', + detail: `Will copy the working changes${ + count > 0 ? ` (${pluralize('file', count)})` : '' + } to worktree '${state.worktree.name}'`, + }); + break; + + default: + confirmations.push( + createFlagsQuickPickItem([], [], { + label: 'Copy Changes to Worktree', + detail: `Will copy the changes${ + count > 0 ? ` (${pluralize('file', count)})` : '' + } to worktree '${state.worktree.name}'`, + }), + ); + break; + } + + const step = createConfirmStep( + `Confirm ${context.title} \u2022 ${state.worktree.name}`, + confirmations, + context, + ); + + const selection: StepSelection = yield step; + return canPickStepContinue(step, state, selection) ? undefined : StepResultBreak; + } } diff --git a/src/commands/gitCommands.ts b/src/commands/gitCommands.ts index 015e444e35b6b..4cb035c1a43df 100644 --- a/src/commands/gitCommands.ts +++ b/src/commands/gitCommands.ts @@ -1,14 +1,15 @@ import type { Disposable, InputBox, QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; import { InputBoxValidationSeverity, QuickInputButtons, window } from 'vscode'; -import { configuration } from '../configuration'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import { Container } from '../container'; -import type { KeyMapping } from '../keyboard'; +import type { LaunchpadCommandArgs } from '../plus/launchpad/launchpad'; import { Directive, isDirective, isDirectiveQuickPickItem } from '../quickpicks/items/directive'; -import { command } from '../system/command'; import { log } from '../system/decorators/log'; import type { Deferred } from '../system/promise'; import { isPromise } from '../system/promise'; +import { command } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import type { KeyMapping } from '../system/vscode/keyboard'; import type { CommandContext } from './base'; import { Command } from './base'; import type { BranchGitCommandArgs } from './git/branch'; @@ -33,7 +34,12 @@ import type { WorktreeGitCommandArgs } from './git/worktree'; import { PickCommandStep } from './gitCommands.utils'; import type { CustomStep, QuickCommand, QuickInputStep, QuickPickStep, StepSelection } from './quickCommand'; import { isCustomStep, isQuickCommand, isQuickInputStep, isQuickPickStep, StepResultBreak } from './quickCommand'; -import { QuickCommandButtons, ToggleQuickInputButton } from './quickCommand.buttons'; +import { + LoadMoreQuickInputButton, + ToggleQuickInputButton, + WillConfirmForcedQuickInputButton, + WillConfirmToggleQuickInputButton, +} from './quickCommand.buttons'; const sanitizeLabel = /\$\(.+?\)|\s/g; const showLoadingSymbol = Symbol('ShowLoading'); @@ -57,7 +63,8 @@ export type GitCommandsCommandArgs = | StatusGitCommandArgs | SwitchGitCommandArgs | TagGitCommandArgs - | WorktreeGitCommandArgs; + | WorktreeGitCommandArgs + | LaunchpadCommandArgs; export type GitCommandsCommandArgsWithCompletion = GitCommandsCommandArgs & { completion?: Deferred }; @@ -69,14 +76,40 @@ export class GitCommandsCommand extends Command { super([ Commands.GitCommands, Commands.GitCommandsBranch, + Commands.GitCommandsBranchCreate, + Commands.GitCommandsBranchDelete, + Commands.GitCommandsBranchPrune, + Commands.GitCommandsBranchRename, + Commands.GitCommandsCheckout, Commands.GitCommandsCherryPick, + Commands.GitCommandsHistory, Commands.GitCommandsMerge, Commands.GitCommandsRebase, + Commands.GitCommandsRemote, + Commands.GitCommandsRemoteAdd, + Commands.GitCommandsRemotePrune, + Commands.GitCommandsRemoteRemove, Commands.GitCommandsReset, Commands.GitCommandsRevert, + Commands.GitCommandsShow, + Commands.GitCommandsStash, + Commands.GitCommandsStashDrop, + Commands.GitCommandsStashList, + Commands.GitCommandsStashPop, + Commands.GitCommandsStashPush, + Commands.GitCommandsStashRename, + Commands.GitCommandsStatus, Commands.GitCommandsSwitch, Commands.GitCommandsTag, + Commands.GitCommandsTagCreate, + Commands.GitCommandsTagDelete, Commands.GitCommandsWorktree, + Commands.GitCommandsWorktreeCreate, + Commands.GitCommandsWorktreeDelete, + Commands.GitCommandsWorktreeOpen, + + Commands.CopyWorkingChangesToWorktree, + Commands.ShowLaunchpad, ]); } @@ -85,30 +118,107 @@ export class GitCommandsCommand extends Command { case Commands.GitCommandsBranch: args = { command: 'branch' }; break; + case Commands.GitCommandsBranchCreate: + args = { command: 'branch', state: { subcommand: 'create' } }; + break; + case Commands.GitCommandsBranchDelete: + args = { command: 'branch', state: { subcommand: 'delete' } }; + break; + case Commands.GitCommandsBranchPrune: + args = { command: 'branch', state: { subcommand: 'prune' } }; + break; + case Commands.GitCommandsBranchRename: + args = { command: 'branch', state: { subcommand: 'rename' } }; + break; case Commands.GitCommandsCherryPick: args = { command: 'cherry-pick' }; break; + case Commands.GitCommandsHistory: + args = { command: 'log' }; + break; case Commands.GitCommandsMerge: args = { command: 'merge' }; break; case Commands.GitCommandsRebase: args = { command: 'rebase' }; break; + case Commands.GitCommandsRemote: + args = { command: 'remote' }; + break; + case Commands.GitCommandsRemoteAdd: + args = { command: 'remote', state: { subcommand: 'add' } }; + break; + case Commands.GitCommandsRemotePrune: + args = { command: 'remote', state: { subcommand: 'prune' } }; + break; + case Commands.GitCommandsRemoteRemove: + args = { command: 'remote', state: { subcommand: 'remove' } }; + break; case Commands.GitCommandsReset: args = { command: 'reset' }; break; case Commands.GitCommandsRevert: args = { command: 'revert' }; break; + case Commands.GitCommandsShow: + args = { command: 'show' }; + break; + case Commands.GitCommandsStash: + args = { command: 'stash' }; + break; + case Commands.GitCommandsStashDrop: + args = { command: 'stash', state: { subcommand: 'drop' } }; + break; + case Commands.GitCommandsStashList: + args = { command: 'stash', state: { subcommand: 'list' } }; + break; + case Commands.GitCommandsStashPop: + args = { command: 'stash', state: { subcommand: 'pop' } }; + break; + case Commands.GitCommandsStashPush: + args = { command: 'stash', state: { subcommand: 'push' } }; + break; + case Commands.GitCommandsStashRename: + args = { command: 'stash', state: { subcommand: 'rename' } }; + break; + case Commands.GitCommandsStatus: + args = { command: 'status' }; + break; case Commands.GitCommandsSwitch: + case Commands.GitCommandsCheckout: args = { command: 'switch' }; break; case Commands.GitCommandsTag: args = { command: 'tag' }; break; + case Commands.GitCommandsTagCreate: + args = { command: 'tag', state: { subcommand: 'create' } }; + break; + case Commands.GitCommandsTagDelete: + args = { command: 'tag', state: { subcommand: 'delete' } }; + break; case Commands.GitCommandsWorktree: args = { command: 'worktree' }; break; + case Commands.GitCommandsWorktreeCreate: + args = { command: 'worktree', state: { subcommand: 'create' } }; + break; + case Commands.GitCommandsWorktreeDelete: + args = { command: 'worktree', state: { subcommand: 'delete' } }; + break; + case Commands.GitCommandsWorktreeOpen: + args = { command: 'worktree', state: { subcommand: 'open' } }; + break; + + case Commands.CopyWorkingChangesToWorktree: + args = { + command: 'worktree', + state: { subcommand: 'copy-changes', changes: { type: 'working-tree' } }, + }; + break; + case Commands.ShowLaunchpad: + args = { command: 'launchpad', ...args }; + break; } return this.execute(args); @@ -123,7 +233,7 @@ export class GitCommandsCommand extends Command { let ignoreFocusOut; - let step: QuickPickStep | QuickInputStep | CustomStep | undefined; + let step: QuickPickStep | QuickInputStep | CustomStep | undefined; if (command == null) { step = commandsStep; } else { @@ -180,9 +290,9 @@ export class GitCommandsCommand extends Command { } private async showLoadingIfNeeded( - command: QuickCommand, - stepPromise: Promise | QuickInputStep | CustomStep | undefined>, - ): Promise | QuickInputStep | CustomStep | undefined> { + command: QuickCommand, + stepPromise: Promise, + ): Promise { const stepOrTimeout = await Promise.race([ stepPromise, new Promise(resolve => setTimeout(resolve, 250, showLoadingSymbol)), @@ -197,9 +307,9 @@ export class GitCommandsCommand extends Command { const disposables: Disposable[] = []; - let step: QuickPickStep | QuickInputStep | CustomStep | undefined; + let step: QuickPickStep | QuickInputStep | CustomStep | undefined; try { - return await new Promise | QuickInputStep | CustomStep | undefined>( + return await new Promise( // eslint-disable-next-line no-async-promise-executor async resolve => { disposables.push(quickpick.onDidHide(() => resolve(step))); @@ -231,7 +341,9 @@ export class GitCommandsCommand extends Command { return buttons; } - buttons.push(QuickInputButtons.Back); + if (step.disallowBack !== true) { + buttons.push(QuickInputButtons.Back); + } if (step.additionalButtons != null) { buttons.push(...step.additionalButtons); @@ -240,23 +352,27 @@ export class GitCommandsCommand extends Command { if (command?.canConfirm) { if (command.canSkipConfirm) { - const willConfirmToggle = new QuickCommandButtons.WillConfirmToggle(command.confirm(), async () => { - if (command?.skipConfirmKey == null) return; + const willConfirmToggle = new WillConfirmToggleQuickInputButton( + command.confirm(), + step?.isConfirmationStep ?? false, + async () => { + if (command?.skipConfirmKey == null) return; - const skipConfirmations = configuration.get('gitCommands.skipConfirmations') ?? []; + const skipConfirmations = configuration.get('gitCommands.skipConfirmations') ?? []; - const index = skipConfirmations.indexOf(command.skipConfirmKey); - if (index !== -1) { - skipConfirmations.splice(index, 1); - } else { - skipConfirmations.push(command.skipConfirmKey); - } + const index = skipConfirmations.indexOf(command.skipConfirmKey); + if (index !== -1) { + skipConfirmations.splice(index, 1); + } else { + skipConfirmations.push(command.skipConfirmKey); + } - await configuration.updateEffective('gitCommands.skipConfirmations', skipConfirmations); - }); + await configuration.updateEffective('gitCommands.skipConfirmations', skipConfirmations); + }, + ); buttons.push(willConfirmToggle); - } else { - buttons.push(QuickCommandButtons.WillConfirmForced); + } else if (!step?.isConfirmationStep) { + buttons.push(WillConfirmForcedQuickInputButton); } } @@ -266,6 +382,11 @@ export class GitCommandsCommand extends Command { private async getCommandStep(command: QuickCommand, commandsStep: PickCommandStep) { commandsStep.setCommand(command, 'command'); + // Ensure we've finished discovering repositories before continuing + if (this.container.git.isDiscoveringRepositories != null) { + await this.container.git.isDiscoveringRepositories; + } + const next = await command.next(); if (next.done) return undefined; @@ -300,6 +421,7 @@ export class GitCommandsCommand extends Command { case Directive.Back: return (await commandsStep?.command?.previous()) ?? commandsStep; case Directive.Noop: + case Directive.Reload: return commandsStep.command?.retry(); case Directive.Cancel: default: @@ -330,8 +452,10 @@ export class GitCommandsCommand extends Command { try { // eslint-disable-next-line no-async-promise-executor - return await new Promise(async resolve => { + return await new Promise(async resolve => { const goBack = async () => { + if (step.disallowBack === true) return; + input.value = ''; if (commandsStep.command != null) { input.busy = true; @@ -339,9 +463,7 @@ export class GitCommandsCommand extends Command { } }; - const mapping: KeyMapping = { - left: { onDidPressKey: goBack }, - }; + const mapping: KeyMapping = {}; if (step.onDidPressKey != null && step.keys != null && step.keys.length !== 0) { for (const key of step.keys) { mapping[key] = { @@ -352,6 +474,9 @@ export class GitCommandsCommand extends Command { const scope = this.container.keyboard.createScope(mapping); void scope.start(); + if (step.value != null) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } disposables.push( scope, @@ -362,7 +487,7 @@ export class GitCommandsCommand extends Command { return; } - if (e === QuickCommandButtons.WillConfirmForced) return; + if (e === WillConfirmForcedQuickInputButton) return; if (e instanceof ToggleQuickInputButton && e.onDidClick != null) { const result = e.onDidClick(input); @@ -390,12 +515,16 @@ export class GitCommandsCommand extends Command { } }), input.onDidChangeValue(async e => { + if (!input.ignoreFocusOut) { + input.ignoreFocusOut = true; + } + if (scope != null) { // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly if (e.length !== 0) { - await scope.pause(['left', 'right']); + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); } else { - await scope.resume(); + void scope.resume(); } } @@ -424,11 +553,6 @@ export class GitCommandsCommand extends Command { } } - // If we are starting over clear the previously active command - if (commandsStep.command != null && step === commandsStep) { - commandsStep.setCommand(undefined, 'menu'); - } - input.show(); // Manually trigger `onDidChangeValue`, because the InputBox fails to call it if the value is set before it is shown @@ -448,19 +572,19 @@ export class GitCommandsCommand extends Command { } private async showPickStep(step: QuickPickStep, commandsStep: PickCommandStep) { - const originalIgnoreFocusOut = !configuration.get('gitCommands.closeOnFocusOut') + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = !configuration.get('gitCommands.closeOnFocusOut') ? true : step.ignoreFocusOut ?? false; - const originalStepIgnoreFocusOut = step.ignoreFocusOut; - - const quickpick = window.createQuickPick(); - quickpick.ignoreFocusOut = originalIgnoreFocusOut; const disposables: Disposable[] = []; try { - return await new Promise(resolve => { + // eslint-disable-next-line no-async-promise-executor + return await new Promise(async resolve => { async function goBack() { + if (step.disallowBack === true) return; + quickpick.value = ''; if (commandsStep.command != null) { quickpick.busy = true; @@ -500,22 +624,37 @@ export class GitCommandsCommand extends Command { const mapping: KeyMapping = { left: { onDidPressKey: goBack }, }; - if (step.onDidPressKey != null && step.keys != null && step.keys.length !== 0) { + if (step.onDidPressKey != null && step.keys?.length) { for (const key of step.keys) { mapping[key] = { - onDidPressKey: key => step.onDidPressKey!(quickpick, key), + onDidPressKey: key => { + if (!quickpick.activeItems.length) return; + + const item = quickpick.activeItems[0]; + if (isDirectiveQuickPickItem(item)) return; + + return step.onDidPressKey!(quickpick, key, item); + }, }; } } const scope = this.container.keyboard.createScope(mapping); void scope.start(); + if (step.value != null) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } + let firstActiveChange = true; let overrideItems = false; disposables.push( scope, - quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidHide(() => { + if (step.frozen) return; + + resolve(undefined); + }), quickpick.onDidTriggerItemButton(async e => { if ((await step.onDidClickItemButton?.(quickpick, e.button, e.item)) === true) { resolve(await this.nextStep(commandsStep.command!, [e.item], quickpick)); @@ -527,15 +666,15 @@ export class GitCommandsCommand extends Command { return; } - if (e === QuickCommandButtons.WillConfirmForced) return; + if (e === WillConfirmForcedQuickInputButton) return; - if (e === QuickCommandButtons.LoadMore) { + if (e === LoadMoreQuickInputButton) { void loadMore(); return; } if (e instanceof ToggleQuickInputButton && e.onDidClick != null) { - let activeCommand; + let activeCommand: QuickCommand | undefined; if (commandsStep.command == null && quickpick.activeItems.length !== 0) { const active = quickpick.activeItems[0]; if (isQuickCommand(active)) { @@ -546,7 +685,7 @@ export class GitCommandsCommand extends Command { const result = e.onDidClick(quickpick); quickpick.buttons = this.getButtons( - activeCommand != null ? activeCommand.value : step, + activeCommand?.value && !isCustomStep(activeCommand.value) ? activeCommand.value : step, activeCommand ?? commandsStep.command, ); @@ -557,7 +696,9 @@ export class GitCommandsCommand extends Command { if (isPromise(result)) { quickpick.buttons = this.getButtons( - activeCommand != null ? activeCommand.value : step, + activeCommand?.value && !isCustomStep(activeCommand.value) + ? activeCommand.value + : step, activeCommand ?? commandsStep.command, ); } @@ -566,20 +707,29 @@ export class GitCommandsCommand extends Command { } if (step.onDidClickButton != null) { - const result = step.onDidClickButton(quickpick, e); + const resultPromise = step.onDidClickButton(quickpick, e); quickpick.buttons = this.getButtons(step, commandsStep.command); - if ((await result) === true) { + const result = await resultPromise; + if (result === true) { resolve(commandsStep.command?.retry()); + } else if (result !== false && result != null) { + resolve(result.value); } } }), quickpick.onDidChangeValue(async e => { + // If something was typed, keep the quick pick open on focus loss + if (!quickpick.ignoreFocusOut) { + quickpick.ignoreFocusOut = true; + step.ignoreFocusOut = true; + } + if (scope != null) { // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly if (e.length !== 0) { - await scope.pause(['left', 'right']); + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); } else { - await scope.resume(); + void scope.resume(); } } @@ -588,17 +738,6 @@ export class GitCommandsCommand extends Command { if (cancel) return; } - // If something was typed, keep the quick pick open on focus loss - if (e.length !== 0 && !quickpick.ignoreFocusOut) { - quickpick.ignoreFocusOut = true; - step.ignoreFocusOut = true; - } - // If something typed was cleared, and we changed the behavior, then allow the quick pick close on focus loss - else if (e.length === 0 && quickpick.ignoreFocusOut && !originalIgnoreFocusOut) { - quickpick.ignoreFocusOut = originalIgnoreFocusOut; - step.ignoreFocusOut = originalStepIgnoreFocusOut; - } - if (!overrideItems) { if (quickpick.canSelectMany && e === ' ') { quickpick.value = ''; @@ -621,7 +760,7 @@ export class GitCommandsCommand extends Command { commandsStep.setCommand(command, this.startedWith); } else { const cmd = quickpick.value.trim().toLowerCase(); - const item = step.items.find( + const item = (await step.items).find( i => i.label.replace(sanitizeLabel, '').toLowerCase() === cmd, ); if (item == null) return; @@ -643,17 +782,30 @@ export class GitCommandsCommand extends Command { ) { if (step.onValidateValue == null) return; - overrideItems = await step.onValidateValue(quickpick, e.trim(), step.items); + overrideItems = await step.onValidateValue(quickpick, e.trim(), await step.items); } else { overrideItems = false; } // If we are no longer overriding the items, put them back (only if we need to) - if (!overrideItems && quickpick.items.length !== step.items.length) { - quickpick.items = step.items; + if (!overrideItems) { + step.items = await step.items; + if (quickpick.items.length !== step.items.length) { + quickpick.items = step.items; + } } }), quickpick.onDidChangeActive(() => { + // If something changed (after the first time which happens on open), keep the quick pick open on focus loss + if (!firstActiveChange && !quickpick.ignoreFocusOut) { + quickpick.ignoreFocusOut = true; + step.ignoreFocusOut = true; + } + + if (firstActiveChange) { + firstActiveChange = false; + } + if (commandsStep.command != null || quickpick.activeItems.length === 0) return; const command = quickpick.activeItems[0]; @@ -664,16 +816,13 @@ export class GitCommandsCommand extends Command { quickpick.onDidChangeSelection(e => { if (!quickpick.canSelectMany) return; - // If something was selected, keep the quick pick open on focus loss - if (e.length !== 0 && !quickpick.ignoreFocusOut) { + // If something is selected, keep the quick pick open on focus loss + if (!quickpick.ignoreFocusOut) { quickpick.ignoreFocusOut = true; step.ignoreFocusOut = true; } - // If the selection was cleared, and we changed the behavior, then allow the quick pick close on focus loss - else if (e?.length === 0 && quickpick.ignoreFocusOut && !originalIgnoreFocusOut) { - quickpick.ignoreFocusOut = originalIgnoreFocusOut; - step.ignoreFocusOut = originalStepIgnoreFocusOut; - } + + step.onDidChangeSelection?.(quickpick, e); }), quickpick.onDidAccept(async () => { let items = quickpick.selectedItems; @@ -706,6 +855,8 @@ export class GitCommandsCommand extends Command { if (items.length === 1) { const [item] = items; if (isDirectiveQuickPickItem(item)) { + await item.onDidSelect?.(); + switch (item.directive) { case Directive.Cancel: resolve(undefined); @@ -719,23 +870,68 @@ export class GitCommandsCommand extends Command { void loadMore(); return; - case Directive.StartPreviewTrial: - void Container.instance.subscription.startPreviewTrial(); - resolve(undefined); + case Directive.Noop: return; - case Directive.RequiresVerification: - void Container.instance.subscription.resendVerification(); - resolve(undefined); + case Directive.Reload: + resolve(await commandsStep.command?.retry()); return; - case Directive.ExtendTrial: - void Container.instance.subscription.loginOrSignUp(); - resolve(undefined); + case Directive.SignIn: { + const result = await Container.instance.subscription.loginOrSignUp(false, { + source: 'git-commands', + detail: { + action: commandsStep.command?.key, + 'step.title': step.title, + }, + }); + resolve(result ? await commandsStep.command?.retry() : undefined); + return; + } + + case Directive.StartPreview: + await Container.instance.subscription.startPreviewTrial({ + source: 'git-commands', + detail: { + action: commandsStep.command?.key, + 'step.title': step.title, + }, + }); + resolve(await commandsStep.command?.retry()); + return; + + case Directive.RequiresVerification: { + const result = await Container.instance.subscription.resendVerification({ + source: 'git-commands', + detail: { + action: commandsStep.command?.key, + 'step.title': step.title, + }, + }); + resolve(result ? await commandsStep.command?.retry() : undefined); return; + } + + case Directive.StartProTrial: { + const result = await Container.instance.subscription.loginOrSignUp(true, { + source: 'git-commands', + detail: { + action: commandsStep.command?.key, + 'step.title': step.title, + }, + }); + resolve(result ? await commandsStep.command?.retry() : undefined); + return; + } case Directive.RequiresPaidSubscription: - void Container.instance.subscription.purchase(); + void Container.instance.subscription.upgrade({ + source: 'git-commands', + detail: { + action: commandsStep.command?.key, + 'step.title': step.title, + }, + }); resolve(undefined); return; } @@ -766,16 +962,40 @@ export class GitCommandsCommand extends Command { ); quickpick.title = step.title; - quickpick.placeholder = step.placeholder; quickpick.matchOnDescription = Boolean(step.matchOnDescription); quickpick.matchOnDetail = Boolean(step.matchOnDetail); - quickpick.canSelectMany = Boolean(step.multiselect); - quickpick.items = step.items; + const selectValueWhenShown = step.selectValueWhenShown ?? true; + + let items; + let shown = false; + if (isPromise(step.items)) { + quickpick.placeholder = 'Loading...'; + + quickpick.busy = true; + + // If we set the value before showing the quickpick, VS Code will select the entire value + if (step.value != null && selectValueWhenShown) { + quickpick.value = step.value; + } + + quickpick.show(); + + shown = true; + items = await step.items; + } else { + items = step.items; + } + + quickpick.canSelectMany = + Boolean(step.multiselect) && items.filter(i => !isDirectiveQuickPickItem(i)).length > 1; + quickpick.placeholder = + typeof step.placeholder === 'function' ? step.placeholder(items.length) : step.placeholder; + quickpick.items = items; + quickpick.busy = false; if (quickpick.canSelectMany) { quickpick.selectedItems = step.selectedItems ?? quickpick.items.filter(i => i.picked); - quickpick.activeItems = quickpick.selectedItems; } else { quickpick.activeItems = step.selectedItems ?? quickpick.items.filter(i => i.picked); } @@ -788,12 +1008,14 @@ export class GitCommandsCommand extends Command { // Needs to be after we reset the command quickpick.buttons = this.getButtons(step, commandsStep.command); - const selectValueWhenShown = step.selectValueWhenShown ?? true; - if (step.value != null && selectValueWhenShown) { - quickpick.value = step.value; - } + if (!shown) { + // If we set the value before showing the quickpick, VS Code will select the entire value + if (step.value != null && selectValueWhenShown) { + quickpick.value = step.value; + } - quickpick.show(); + quickpick.show(); + } if (step.value != null && !selectValueWhenShown) { quickpick.value = step.value; @@ -808,6 +1030,8 @@ export class GitCommandsCommand extends Command { debugger; } } + + step.onDidActivate?.(quickpick); }); } finally { quickpick.dispose(); diff --git a/src/commands/gitCommands.utils.ts b/src/commands/gitCommands.utils.ts index 5cf610a35d5f2..903872be45639 100644 --- a/src/commands/gitCommands.utils.ts +++ b/src/commands/gitCommands.utils.ts @@ -1,9 +1,8 @@ -import { GitCommandSorting } from '../config'; -import { configuration } from '../configuration'; -import { ContextKeys } from '../constants'; +import type { StoredRecentUsage } from '../constants.storage'; import type { Container } from '../container'; -import { getContext } from '../context'; -import type { RecentUsage } from '../storage'; +import { LaunchpadCommand } from '../plus/launchpad/launchpad'; +import { configuration } from '../system/vscode/configuration'; +import { getContext } from '../system/vscode/context'; import { BranchGitCommand } from './git/branch'; import { CherryPickGitCommand } from './git/cherry-pick'; import { CoAuthorsGitCommand } from './git/coauthors'; @@ -45,7 +44,8 @@ export function getSteps( return command.executeSteps(); } -export class PickCommandStep implements QuickPickStep { +export class PickCommandStep implements QuickPickStep { + readonly type = 'pick'; readonly buttons = []; private readonly hiddenItems: QuickCommand[]; ignoreFocusOut = false; @@ -54,12 +54,13 @@ export class PickCommandStep implements QuickPickStep { readonly placeholder = 'Choose a git command'; readonly title = 'GitLens'; - constructor(private readonly container: Container, args?: GitCommandsCommandArgs) { - const hasVirtualFolders = getContext(ContextKeys.HasVirtualFolders, false); + constructor( + private readonly container: Container, + args?: GitCommandsCommandArgs, + ) { + const hasVirtualFolders = getContext('gitlens:hasVirtualFolders', false); const readonly = - hasVirtualFolders || - getContext(ContextKeys.Readonly, false) || - getContext(ContextKeys.Untrusted, false); + hasVirtualFolders || getContext('gitlens:readonly', false) || getContext('gitlens:untrusted', false); this.items = [ readonly ? undefined : new BranchGitCommand(container, args?.command === 'branch' ? args : undefined), @@ -99,7 +100,7 @@ export class PickCommandStep implements QuickPickStep { : new WorktreeGitCommand(container, args?.command === 'worktree' ? args : undefined), ].filter((i: T | undefined): i is T => i != null); - if (configuration.get('gitCommands.sortBy') === GitCommandSorting.Usage) { + if (configuration.get('gitCommands.sortBy') === 'usage') { const usage = this.container.storage.getWorkspace('gitComandPalette:usage'); if (usage != null) { this.items.sort((a, b) => (usage[b.key] ?? 0) - (usage[a.key] ?? 0)); @@ -107,6 +108,9 @@ export class PickCommandStep implements QuickPickStep { } this.hiddenItems = []; + if (args?.command === 'launchpad') { + this.hiddenItems.push(new LaunchpadCommand(container, args)); + } } private _command: QuickCommand | undefined; @@ -142,7 +146,7 @@ export class PickCommandStep implements QuickPickStep { private async updateCommandUsage(id: string, timestamp: number) { let usage = this.container.storage.getWorkspace(`gitComandPalette:usage`); if (usage === undefined) { - usage = Object.create(null) as RecentUsage; + usage = Object.create(null) as StoredRecentUsage; } usage[id] = timestamp; diff --git a/src/commands/inspect.ts b/src/commands/inspect.ts new file mode 100644 index 0000000000000..feb4787b96d5f --- /dev/null +++ b/src/commands/inspect.ts @@ -0,0 +1,87 @@ +import type { TextEditor, Uri } from 'vscode'; +import { Commands } from '../constants.commands'; +import type { Container } from '../container'; +import { showDetailsView } from '../git/actions/commit'; +import { GitUri } from '../git/gitUri'; +import type { GitRevisionReference } from '../git/models/reference'; +import { createReference, getReferenceFromRevision } from '../git/models/reference'; +import { + showFileNotUnderSourceControlWarningMessage, + showGenericErrorMessage, + showLineUncommittedWarningMessage, +} from '../messages'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; +import type { CommandContext } from './base'; +import { ActiveEditorCommand, getCommandUri, isCommandContextViewNodeHasCommit } from './base'; + +export interface InspectCommandArgs { + ref?: GitRevisionReference; +} + +@command() +export class InspectCommand extends ActiveEditorCommand { + static getMarkdownCommandArgs(sha: string, repoPath: string): string; + static getMarkdownCommandArgs(args: InspectCommandArgs): string; + static getMarkdownCommandArgs(argsOrSha: InspectCommandArgs | string, repoPath?: string): string { + const args = + typeof argsOrSha === 'string' + ? { ref: createReference(argsOrSha, repoPath!, { refType: 'revision' }), repoPath: repoPath } + : argsOrSha; + return super.getMarkdownCommandArgsCore(Commands.ShowCommitInView, args); + } + + constructor(private readonly container: Container) { + super([Commands.ShowCommitInView, Commands.ShowInDetailsView, Commands.ShowLineCommitInView]); + } + + protected override preExecute(context: CommandContext, args?: InspectCommandArgs) { + if (context.type === 'viewItem') { + args = { ...args }; + if (isCommandContextViewNodeHasCommit(context)) { + args.ref = getReferenceFromRevision(context.node.commit); + } + } + + return this.execute(context.editor, context.uri, args); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: InspectCommandArgs) { + args = { ...args }; + + if (args.ref == null) { + uri = getCommandUri(uri, editor); + if (uri == null) return undefined; + + const gitUri = await GitUri.fromUri(uri); + + const blameLine = editor?.selection.active.line; + if (blameLine == null) return; + + try { + const blame = await this.container.git.getBlameForLine(gitUri, blameLine); + if (blame == null) { + void showFileNotUnderSourceControlWarningMessage('Unable to inspect commit details'); + + return; + } + + // Because the previous sha of an uncommitted file isn't trust worthy we just have to kick out + if (blame.commit.isUncommitted) { + void showLineUncommittedWarningMessage('Unable to inspect commit details'); + + return; + } + + args.ref = blame.commit; + } catch (ex) { + Logger.error(ex, 'InspectCommand', `getBlameForLine(${blameLine})`); + void showGenericErrorMessage('Unable to inspect commit details'); + + return; + } + } + + return showDetailsView(args.ref); + } +} diff --git a/src/commands/inviteToLiveShare.ts b/src/commands/inviteToLiveShare.ts index c62db15adb7e7..f9c03d655f8e6 100644 --- a/src/commands/inviteToLiveShare.ts +++ b/src/commands/inviteToLiveShare.ts @@ -1,6 +1,6 @@ -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import { Command, isCommandContextViewNodeHasContributor } from './base'; diff --git a/src/commands/logging.ts b/src/commands/logging.ts index 3ec8fe1b38c17..1e1759825e16b 100644 --- a/src/commands/logging.ts +++ b/src/commands/logging.ts @@ -1,7 +1,7 @@ -import { configuration, OutputLevel } from '../configuration'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; import { Command } from './base'; @command() @@ -11,7 +11,7 @@ export class EnableDebugLoggingCommand extends Command { } async execute() { - await configuration.updateEffective('outputLevel', OutputLevel.Debug); + await configuration.updateEffective('outputLevel', 'debug'); } } @@ -22,6 +22,6 @@ export class DisableDebugLoggingCommand extends Command { } async execute() { - await configuration.updateEffective('outputLevel', OutputLevel.Errors); + await configuration.updateEffective('outputLevel', 'error'); } } diff --git a/src/commands/openAssociatedPullRequestOnRemote.ts b/src/commands/openAssociatedPullRequestOnRemote.ts index 431f53f8090c2..5910371a3db4c 100644 --- a/src/commands/openAssociatedPullRequestOnRemote.ts +++ b/src/commands/openAssociatedPullRequestOnRemote.ts @@ -1,9 +1,10 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; -import { Logger } from '../logger'; -import { command, executeCommand } from '../system/command'; +import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { OpenPullRequestOnRemoteCommandArgs } from './openPullRequestOnRemote'; @@ -14,27 +15,44 @@ export class OpenAssociatedPullRequestOnRemoteCommand extends ActiveEditorComman } async execute(editor?: TextEditor, uri?: Uri) { - if (editor == null) return; - uri = getCommandUri(uri, editor); - if (uri == null) return; - const gitUri = await GitUri.fromUri(uri); + const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; + + let args: OpenPullRequestOnRemoteCommandArgs; + if (editor != null && gitUri != null) { + const blameline = editor.selection.active.line; + if (blameline < 0) return; - const blameline = editor.selection.active.line; - if (blameline < 0) return; + try { + const blame = await this.container.git.getBlameForLine(gitUri, blameline); + if (blame == null) return; - try { - const blame = await this.container.git.getBlameForLine(gitUri, blameline); - if (blame == null) return; + args = { clipboard: false, ref: blame.commit.sha, repoPath: blame.commit.repoPath }; + } catch (ex) { + Logger.error(ex, 'OpenAssociatedPullRequestOnRemoteCommand', `getBlameForLine(${blameline})`); + return; + } + } else { + try { + const repo = await getRepositoryOrShowPicker('Open Associated Pull Request', undefined, undefined, { + filter: async r => (await this.container.git.getBestRemoteWithIntegration(r.uri)) != null, + }); + if (repo == null) return; - await executeCommand(Commands.OpenPullRequestOnRemote, { - clipboard: false, - ref: blame.commit.sha, - repoPath: blame.commit.repoPath, - }); - } catch (ex) { - Logger.error(ex, 'OpenAssociatedPullRequestOnRemoteCommand', `getBlameForLine(${blameline})`); + const branch = await repo?.getBranch(); + const pr = await branch?.getAssociatedPullRequest({ expiryOverride: true }); + + args = + pr != null + ? { clipboard: false, pr: { url: pr.url } } + : { clipboard: false, ref: branch?.name ?? 'HEAD', repoPath: repo.path }; + } catch (ex) { + Logger.error(ex, 'OpenAssociatedPullRequestOnRemoteCommand', 'No editor opened'); + return; + } } + + await executeCommand(Commands.OpenPullRequestOnRemote, args); } } diff --git a/src/commands/openBranchOnRemote.ts b/src/commands/openBranchOnRemote.ts index 4e7b743ad2b9f..7a14d37ccfb4a 100644 --- a/src/commands/openBranchOnRemote.ts +++ b/src/commands/openBranchOnRemote.ts @@ -1,14 +1,14 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import { RemoteResourceType } from '../git/models/remoteResource'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; import { CommandQuickPickItem } from '../quickpicks/items/common'; -import { ReferencePicker, ReferencesQuickPickIncludes } from '../quickpicks/referencePicker'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; -import { command, executeCommand } from '../system/command'; +import { ReferencesQuickPickIncludes, showReferencePicker } from '../quickpicks/referencePicker'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri, isCommandContextViewNodeHasBranch } from './base'; import type { OpenOnRemoteCommandArgs } from './openOnRemote'; @@ -47,7 +47,7 @@ export class OpenBranchOnRemoteCommand extends ActiveEditorCommand { const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; const repoPath = ( - await RepositoryPicker.getBestRepositoryOrShow( + await getBestRepositoryOrShowPicker( gitUri, editor, args?.clipboard ? 'Copy Remote Branch URL' : 'Open Branch On Remote', @@ -59,7 +59,7 @@ export class OpenBranchOnRemoteCommand extends ActiveEditorCommand { try { if (args.branch == null) { - const pick = await ReferencePicker.show( + const pick = await showReferencePicker( repoPath, args.clipboard ? 'Copy Remote Branch URL' : 'Open Branch On Remote', args.clipboard ? 'Choose a branch to copy the URL from' : 'Choose a branch to open', diff --git a/src/commands/openBranchesOnRemote.ts b/src/commands/openBranchesOnRemote.ts index aab92ecb5c12f..39894df3606bf 100644 --- a/src/commands/openBranchesOnRemote.ts +++ b/src/commands/openBranchesOnRemote.ts @@ -1,12 +1,12 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import { RemoteResourceType } from '../git/models/remoteResource'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; -import { command, executeCommand } from '../system/command'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri, isCommandContextViewNodeHasRemote } from './base'; import type { OpenOnRemoteCommandArgs } from './openOnRemote'; @@ -44,7 +44,7 @@ export class OpenBranchesOnRemoteCommand extends ActiveEditorCommand { const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; const repoPath = ( - await RepositoryPicker.getBestRepositoryOrShow( + await getBestRepositoryOrShowPicker( gitUri, editor, args?.clipboard ? 'Copy Remote Branches URL' : 'Open Branches on Remote', diff --git a/src/commands/openChangedFiles.ts b/src/commands/openChangedFiles.ts index cd9374aad9713..b536d82a1099e 100644 --- a/src/commands/openChangedFiles.ts +++ b/src/commands/openChangedFiles.ts @@ -1,13 +1,13 @@ import type { Uri } from 'vscode'; import { window } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; +import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; import { filterMap } from '../system/array'; -import { command } from '../system/command'; -import { findOrOpenEditors } from '../system/utils'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; +import { findOrOpenEditors } from '../system/vscode/utils'; import { Command } from './base'; export interface OpenChangedFilesCommandArgs { @@ -25,7 +25,7 @@ export class OpenChangedFilesCommand extends Command { try { if (args.uris == null) { - const repository = await RepositoryPicker.getRepositoryOrShow('Open All Changed Files'); + const repository = await getRepositoryOrShowPicker('Open All Changed Files'); if (repository == null) return; const status = await this.container.git.getStatusForRepo(repository.uri); diff --git a/src/commands/openCommitOnRemote.ts b/src/commands/openCommitOnRemote.ts index fbed8a47811c0..d2a462035566a 100644 --- a/src/commands/openCommitOnRemote.ts +++ b/src/commands/openCommitOnRemote.ts @@ -1,13 +1,18 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; -import { GitRevision } from '../git/models/reference'; +import { deletedOrMissing } from '../git/models/constants'; +import { isUncommitted } from '../git/models/reference'; import { RemoteResourceType } from '../git/models/remoteResource'; -import { Logger } from '../logger'; -import { showFileNotUnderSourceControlWarningMessage, showGenericErrorMessage } from '../messages'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; -import { command, executeCommand } from '../system/command'; +import { + showCommitNotFoundWarningMessage, + showFileNotUnderSourceControlWarningMessage, + showGenericErrorMessage, +} from '../messages'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; import type { CommandContext } from './base'; import { ActiveEditorCommand, @@ -19,6 +24,7 @@ import type { OpenOnRemoteCommandArgs } from './openOnRemote'; export interface OpenCommitOnRemoteCommandArgs { clipboard?: boolean; + line?: number; sha?: string; } @@ -38,6 +44,10 @@ export class OpenCommitOnRemoteCommand extends ActiveEditorCommand { protected override preExecute(context: CommandContext, args?: OpenCommitOnRemoteCommandArgs) { let uri = context.uri; + if (context.type === 'editorLine') { + args = { ...args, line: context.line }; + } + if (isCommandContextViewNodeHasCommit(context)) { if (context.node.commit.isUncommitted) return Promise.resolve(undefined); @@ -63,7 +73,7 @@ export class OpenCommitOnRemoteCommand extends ActiveEditorCommand { let gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; const repoPath = ( - await RepositoryPicker.getBestRepositoryOrShow( + await getBestRepositoryOrShowPicker( gitUri, editor, args?.clipboard ? 'Copy Remote Commit URL' : 'Open Commit On Remote', @@ -79,22 +89,36 @@ export class OpenCommitOnRemoteCommand extends ActiveEditorCommand { try { if (args.sha == null) { - const blameline = editor == null ? 0 : editor.selection.active.line; - if (blameline < 0) return; + const blameLine = args.line ?? editor?.selection.active.line; + if (blameLine == null) return; - const blame = await this.container.git.getBlameForLine(gitUri, blameline, editor?.document); + const blame = await this.container.git.getBlameForLine(gitUri, blameLine, editor?.document); if (blame == null) { - void showFileNotUnderSourceControlWarningMessage('Unable to open commit on remote provider'); + void showFileNotUnderSourceControlWarningMessage( + args?.clipboard + ? 'Unable to copy the commit SHA' + : 'Unable to open the commit on the remote provider', + ); return; } // If the line is uncommitted, use previous commit args.sha = blame.commit.isUncommitted - ? (await blame.commit.getPreviousSha()) ?? GitRevision.deletedOrMissing + ? (await blame.commit.getPreviousSha()) ?? deletedOrMissing : blame.commit.sha; } + if (args.sha == null || args.sha === deletedOrMissing || isUncommitted(args.sha)) { + void showCommitNotFoundWarningMessage( + args?.clipboard + ? 'Unable to copy the commit SHA' + : 'Unable to open the commit on the remote provider', + ); + + return; + } + void (await executeCommand(Commands.OpenOnRemote, { resource: { type: RemoteResourceType.Commit, diff --git a/src/commands/openComparisonOnRemote.ts b/src/commands/openComparisonOnRemote.ts index e83e7d7629053..16aa7c48df276 100644 --- a/src/commands/openComparisonOnRemote.ts +++ b/src/commands/openComparisonOnRemote.ts @@ -1,10 +1,9 @@ -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { RemoteResourceType } from '../git/models/remoteResource'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { command, executeCommand } from '../system/command'; -import { ResultsCommitsNode } from '../views/nodes/resultsCommitsNode'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; import type { CommandContext } from './base'; import { Command } from './base'; import type { OpenOnRemoteCommandArgs } from './openOnRemote'; @@ -25,17 +24,31 @@ export class OpenComparisonOnRemoteCommand extends Command { protected override preExecute(context: CommandContext, args?: OpenComparisonOnRemoteCommandArgs) { if (context.type === 'viewItem') { - if (context.node instanceof ResultsCommitsNode) { + if (context.node.isAny('results-commits')) { args = { ...args, repoPath: context.node.repoPath, - ref1: context.node.ref1, - ref2: context.node.ref2, + ref1: context.node.ref1 || 'HEAD', + ref2: context.node.ref2 || 'HEAD', + }; + } else if (context.node.is('compare-results')) { + args = { + ...args, + repoPath: context.node.repoPath, + ref1: context.node.ahead.ref1, + ref2: context.node.ahead.ref2, + }; + } else if (context.node.is('compare-branch')) { + args = { + ...args, + repoPath: context.node.repoPath, + ref1: context.node.ahead.ref1, + ref2: context.node.ahead.ref2, }; } } - if (context.command === Commands.CopyRemoteBranchesUrl) { + if (context.command === Commands.CopyRemoteComparisonUrl) { args = { ...args, clipboard: true }; } diff --git a/src/commands/openCurrentBranchOnRemote.ts b/src/commands/openCurrentBranchOnRemote.ts index 878bb2601c858..8b575a474fcb1 100644 --- a/src/commands/openCurrentBranchOnRemote.ts +++ b/src/commands/openCurrentBranchOnRemote.ts @@ -1,12 +1,12 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import { RemoteResourceType } from '../git/models/remoteResource'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; -import { command, executeCommand } from '../system/command'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { OpenOnRemoteCommandArgs } from './openOnRemote'; @@ -21,7 +21,7 @@ export class OpenCurrentBranchOnRemoteCommand extends ActiveEditorCommand { const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; - const repository = await RepositoryPicker.getBestRepositoryOrShow(gitUri, editor, 'Open Current Branch Name'); + const repository = await getBestRepositoryOrShowPicker(gitUri, editor, 'Open Current Branch Name'); if (repository == null) return; try { diff --git a/src/commands/openDirectoryCompare.ts b/src/commands/openDirectoryCompare.ts index f167bfd82355e..5cf97d388e32f 100644 --- a/src/commands/openDirectoryCompare.ts +++ b/src/commands/openDirectoryCompare.ts @@ -1,12 +1,12 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { openDirectoryCompare } from '../git/actions/commit'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { ReferencePicker } from '../quickpicks/referencePicker'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; -import { command } from '../system/command'; +import { showReferencePicker } from '../quickpicks/referencePicker'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; import { CompareResultsNode } from '../views/nodes/compareResultsNode'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri, isCommandContextViewNodeHasRef } from './base'; @@ -46,7 +46,7 @@ export class OpenDirectoryCompareCommand extends ActiveEditorCommand { if (isCommandContextViewNodeHasRef(context)) { args = { ...args }; args.ref1 = context.node.ref.ref; - args.ref2 = undefined; + args.ref2 = ''; } break; } @@ -59,19 +59,17 @@ export class OpenDirectoryCompareCommand extends ActiveEditorCommand { args = { ...args }; try { - const repoPath = ( - await RepositoryPicker.getBestRepositoryOrShow(uri, editor, 'Directory Compare Working Tree With') - )?.path; + const repoPath = (await getBestRepositoryOrShowPicker(uri, editor, 'Directory Compare Working Tree With')) + ?.path; if (!repoPath) return; if (!args.ref1) { - const pick = await ReferencePicker.show( + const pick = await showReferencePicker( repoPath, 'Directory Compare Working Tree with', 'Choose a branch or tag to compare with', { - allowEnteringRefs: true, - // checkmarks: false, + allowRevisions: true, }, ); if (pick == null) return; diff --git a/src/commands/openFileAtRevision.ts b/src/commands/openFileAtRevision.ts index 8b50cf9083da6..2796824a457b2 100644 --- a/src/commands/openFileAtRevision.ts +++ b/src/commands/openFileAtRevision.ts @@ -1,17 +1,21 @@ import type { TextDocumentShowOptions, TextEditor } from 'vscode'; import { Uri } from 'vscode'; -import { FileAnnotationType } from '../configuration'; -import { Commands, GlyphChars, quickPickTitleMaxChars } from '../constants'; +import type { FileAnnotationType } from '../config'; +import { GlyphChars, quickPickTitleMaxChars } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { openFileAtRevision } from '../git/actions/commit'; import { GitUri } from '../git/gitUri'; -import { GitRevision } from '../git/models/reference'; -import { Logger } from '../logger'; +import { shortenRevision } from '../git/models/reference'; import { showCommitHasNoPreviousCommitWarningMessage, showGenericErrorMessage } from '../messages'; -import { CommitPicker } from '../quickpicks/commitPicker'; +import { showCommitPicker } from '../quickpicks/commitPicker'; import { CommandQuickPickItem } from '../quickpicks/items/common'; -import { command } from '../system/command'; +import type { DirectiveQuickPickItem } from '../quickpicks/items/directive'; +import { createDirectiveQuickPickItem, Directive } from '../quickpicks/items/directive'; +import { Logger } from '../system/logger'; import { pad } from '../system/string'; +import { command } from '../system/vscode/command'; +import { splitPath } from '../system/vscode/path'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri } from './base'; import type { OpenFileAtRevisionFromCommandArgs } from './openFileAtRevisionFrom'; @@ -55,7 +59,7 @@ export class OpenFileAtRevisionCommand extends ActiveEditorCommand { protected override async preExecute(context: CommandContext, args?: OpenFileAtRevisionCommandArgs) { if (context.command === Commands.OpenBlamePriorToChange) { - args = { ...args, annotationType: FileAnnotationType.Blame }; + args = { ...args, annotationType: 'blame' }; if (args.revisionUri == null && context.editor != null) { const editorLine = context.editor.selection.active.line; if (editorLine >= 0) { @@ -119,38 +123,94 @@ export class OpenFileAtRevisionCommand extends ActiveEditorCommand { : undefined), ); - const title = `Open ${ - args.annotationType === FileAnnotationType.Blame ? 'Blame' : 'File' - } at Revision${pad(GlyphChars.Dot, 2, 2)}`; - const pick = await CommitPicker.show( + const title = `Open ${args.annotationType === 'blame' ? 'Blame' : 'File'} at Revision${pad( + GlyphChars.Dot, + 2, + 2, + )}`; + const titleWithContext = `${title}${gitUri.getFormattedFileName({ + suffix: gitUri.sha ? `:${shortenRevision(gitUri.sha)}` : undefined, + truncateTo: quickPickTitleMaxChars - title.length, + })}`; + const pick = await showCommitPicker( log, - `${title}${gitUri.getFormattedFileName({ - suffix: gitUri.sha ? `:${GitRevision.shorten(gitUri.sha)}` : undefined, - truncateTo: quickPickTitleMaxChars - title.length, - })}`, - `Choose a commit to ${ - args.annotationType === FileAnnotationType.Blame ? 'blame' : 'open' - } the file revision from`, + titleWithContext, + `Choose a commit to ${args.annotationType === 'blame' ? 'blame' : 'open'} the file revision from`, { + empty: !gitUri.sha + ? { + getState: async () => { + const items: (CommandQuickPickItem | DirectiveQuickPickItem)[] = []; + + const status = await this.container.git.getStatusForRepo(gitUri.repoPath); + if (status != null) { + for (const f of status.files) { + if (f.workingTreeStatus === '?' || f.workingTreeStatus === '!') { + continue; + } + + const [label, description] = splitPath(f.path, undefined, true); + + items.push( + new CommandQuickPickItem<[Uri]>( + { + label: label, + description: description, + }, + undefined, + Commands.OpenFileAtRevision, + [this.container.git.getAbsoluteUri(f.path, gitUri.repoPath)], + ), + ); + } + } + + let newPlaceholder; + let newTitle; + + if (items.length) { + newPlaceholder = `${gitUri.getFormattedFileName()} is likely untracked, choose a different file?`; + newTitle = `${titleWithContext} (Untracked?)`; + } else { + newPlaceholder = 'No commits found'; + } + + items.push( + createDirectiveQuickPickItem(Directive.Cancel, undefined, { + label: items.length ? 'Cancel' : 'OK', + }), + ); + + return { + items: items, + placeholder: newPlaceholder, + title: newTitle, + }; + }, + } + : undefined, picked: gitUri.sha, - keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (key, item) => { - await openFileAtRevision(item.item.file!, item.item, { - annotationType: args!.annotationType, - line: args!.line, - preserveFocus: true, - preview: false, - }); + keyboard: { + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: async (_key, item) => { + await openFileAtRevision(item.item.file!, item.item, { + annotationType: args.annotationType, + line: args.line, + preserveFocus: true, + preview: true, + }); + }, }, showOtherReferences: [ - CommandQuickPickItem.fromCommand( + CommandQuickPickItem.fromCommand<[Uri]>( 'Choose a Branch or Tag...', Commands.OpenFileAtRevisionFrom, + [uri], ), - CommandQuickPickItem.fromCommand( + CommandQuickPickItem.fromCommand<[Uri, OpenFileAtRevisionFromCommandArgs]>( 'Choose a Stash...', Commands.OpenFileAtRevisionFrom, - { stash: true }, + [uri, { stash: true }], ), ], }, diff --git a/src/commands/openFileAtRevisionFrom.ts b/src/commands/openFileAtRevisionFrom.ts index 2dfedff1d2c43..463fcdef426db 100644 --- a/src/commands/openFileAtRevisionFrom.ts +++ b/src/commands/openFileAtRevisionFrom.ts @@ -1,15 +1,16 @@ import type { TextDocumentShowOptions, TextEditor, Uri } from 'vscode'; -import type { FileAnnotationType } from '../configuration'; -import { Commands, GlyphChars, quickPickTitleMaxChars } from '../constants'; +import type { FileAnnotationType } from '../config'; +import { GlyphChars, quickPickTitleMaxChars } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { openFileAtRevision } from '../git/actions/commit'; import { GitUri } from '../git/gitUri'; import type { GitReference } from '../git/models/reference'; import { showNoRepositoryWarningMessage } from '../messages'; -import { StashPicker } from '../quickpicks/commitPicker'; -import { ReferencePicker } from '../quickpicks/referencePicker'; -import { command } from '../system/command'; +import { showStashPicker } from '../quickpicks/commitPicker'; +import { showReferencePicker } from '../quickpicks/referencePicker'; import { pad } from '../system/string'; +import { command } from '../system/vscode/command'; import { ActiveEditorCommand, getCommandUri } from './base'; export interface OpenFileAtRevisionFromCommandArgs { @@ -47,7 +48,7 @@ export class OpenFileAtRevisionFromCommand extends ActiveEditorCommand { const path = this.container.git.getRelativePath(gitUri, gitUri.repoPath); const title = `Open Changes with Stash${pad(GlyphChars.Dot, 2, 2)}`; - const pick = await StashPicker.show( + const pick = await showStashPicker( this.container.git.getStash(gitUri.repoPath), `${title}${gitUri.getFormattedFileName({ truncateTo: quickPickTitleMaxChars - title.length })}`, 'Choose a stash to compare with', @@ -59,26 +60,25 @@ export class OpenFileAtRevisionFromCommand extends ActiveEditorCommand { args.reference = pick; } else { const title = `Open File at Branch or Tag${pad(GlyphChars.Dot, 2, 2)}`; - const pick = await ReferencePicker.show( + const pick = await showReferencePicker( gitUri.repoPath, `${title}${gitUri.getFormattedFileName({ truncateTo: quickPickTitleMaxChars - title.length })}`, 'Choose a branch or tag to open the file revision from', { - allowEnteringRefs: true, - keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (key, quickpick) => { - const [item] = quickpick.activeItems; - if (item != null) { + allowRevisions: true, + keyboard: { + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: async (_key, item) => { await openFileAtRevision( this.container.git.getRevisionUri(item.ref, gitUri.fsPath, gitUri.repoPath!), { - annotationType: args!.annotationType, - line: args!.line, + annotationType: args.annotationType, + line: args.line, preserveFocus: true, - preview: false, + preview: true, }, ); - } + }, }, }, ); diff --git a/src/commands/openFileFromRemote.ts b/src/commands/openFileFromRemote.ts index b5d62386442c6..fc4b18fc81df0 100644 --- a/src/commands/openFileFromRemote.ts +++ b/src/commands/openFileFromRemote.ts @@ -1,8 +1,8 @@ import { env, Range, Uri, window } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { command } from '../system/command'; -import { openEditor } from '../system/utils'; +import { command } from '../system/vscode/command'; +import { openEditor } from '../system/vscode/utils'; import { Command } from './base'; @command() @@ -54,7 +54,7 @@ export class OpenFileFromRemoteCommand extends Command { } try { - await openEditor(local.uri, { selection: selection, rethrow: true }); + await openEditor(local.uri, { selection: selection, throwOnError: true }); } catch { const uris = await window.showOpenDialog({ title: 'Open local file', diff --git a/src/commands/openFileOnRemote.ts b/src/commands/openFileOnRemote.ts index a8caf367b9072..3d788ceed19bf 100644 --- a/src/commands/openFileOnRemote.ts +++ b/src/commands/openFileOnRemote.ts @@ -1,18 +1,18 @@ import type { TextEditor, Uri } from 'vscode'; import { Range } from 'vscode'; -import { UriComparer } from '../comparers'; -import { BranchSorting, TagSorting } from '../configuration'; -import { Commands, GlyphChars } from '../constants'; +import { GlyphChars } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import { getBranchNameWithoutRemote, getRemoteNameFromBranchName } from '../git/models/branch'; -import { GitRevision } from '../git/models/reference'; +import { isSha } from '../git/models/reference'; import { RemoteResourceType } from '../git/models/remoteResource'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { ReferencePicker } from '../quickpicks/referencePicker'; -import { command, executeCommand } from '../system/command'; +import { showReferencePicker } from '../quickpicks/referencePicker'; +import { UriComparer } from '../system/comparers'; +import { Logger } from '../system/logger'; import { pad, splitSingle } from '../system/string'; +import { command, executeCommand } from '../system/vscode/command'; import { StatusFileNode } from '../views/nodes/statusFileNode'; import type { CommandContext } from './base'; import { @@ -26,6 +26,7 @@ import type { OpenOnRemoteCommandArgs } from './openOnRemote'; export interface OpenFileOnRemoteCommandArgs { branchOrTag?: string; clipboard?: boolean; + line?: number; range?: boolean; sha?: string; pickBranchOrTag?: boolean; @@ -47,6 +48,10 @@ export class OpenFileOnRemoteCommand extends ActiveEditorCommand { protected override async preExecute(context: CommandContext, args?: OpenFileOnRemoteCommandArgs) { let uri = context.uri; + if (context.type === 'editorLine') { + args = { ...args, line: context.line, range: true }; + } + if (context.command === Commands.CopyRemoteFileUrlWithoutRange) { args = { ...args, range: false }; } @@ -100,7 +105,7 @@ export class OpenFileOnRemoteCommand extends ActiveEditorCommand { } if (context.command === Commands.OpenFileOnRemoteFrom || context.command === Commands.CopyRemoteFileUrlFrom) { - args = { ...args, pickBranchOrTag: true, range: false }; + args = { ...args, pickBranchOrTag: true, range: false }; // Override range since it can be wrong at a different commit } return this.execute(context.editor, uri, args); @@ -116,19 +121,25 @@ export class OpenFileOnRemoteCommand extends ActiveEditorCommand { args = { range: true, ...args }; try { - let remotes = await this.container.git.getRemotesWithProviders(gitUri.repoPath); - const range = - args.range && editor != null && UriComparer.equals(editor.document.uri, uri) - ? new Range( - editor.selection.start.with({ line: editor.selection.start.line + 1 }), - editor.selection.end.with({ - line: editor.selection.end.line + (editor.selection.end.character === 0 ? 0 : 1), - }), - ) - : undefined; + let remotes = await this.container.git.getRemotesWithProviders(gitUri.repoPath, { sort: true }); + + let range: Range | undefined; + if (args.range) { + if (editor != null && UriComparer.equals(editor.document.uri, uri)) { + range = new Range( + editor.selection.start.with({ line: editor.selection.start.line + 1 }), + editor.selection.end.with({ + line: editor.selection.end.line + (editor.selection.end.character === 0 ? 0 : 1), + }), + ); + } else if (args.line != null) { + range = new Range(args.line + 1, 0, args.line + 1, 0); + } + } + let sha = args.sha ?? gitUri.sha; - if (args.branchOrTag == null && sha != null && !GitRevision.isSha(sha) && remotes.length !== 0) { + if (args.branchOrTag == null && sha != null && !isSha(sha) && remotes.length !== 0) { const [remoteName, branchName] = splitSingle(sha, '/'); if (branchName != null) { const remote = remotes.find(r => r.name === remoteName); @@ -148,21 +159,20 @@ export class OpenFileOnRemoteCommand extends ActiveEditorCommand { } if (branch?.upstream == null) { - const pick = await ReferencePicker.show( + const pick = await showReferencePicker( gitUri.repoPath, args.clipboard ? `Copy Remote File URL From${pad(GlyphChars.Dot, 2, 2)}${gitUri.relativePath}` : `Open File on Remote From${pad(GlyphChars.Dot, 2, 2)}${gitUri.relativePath}`, `Choose a branch or tag to ${args.clipboard ? 'copy' : 'open'} the file revision from`, { - allowEnteringRefs: true, + allowRevisions: true, autoPick: true, - // checkmarks: false, filter: { branches: b => b.remote || b.upstream != null }, picked: args.branchOrTag, sort: { - branches: { current: true, orderBy: BranchSorting.DateDesc }, - tags: { orderBy: TagSorting.DateDesc }, + branches: { current: true, orderBy: 'date:desc' }, + tags: { orderBy: 'date:desc' }, }, }, ); diff --git a/src/commands/openIssueOnRemote.ts b/src/commands/openIssueOnRemote.ts deleted file mode 100644 index cd4660b817b8f..0000000000000 --- a/src/commands/openIssueOnRemote.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { env, Uri } from 'vscode'; -import { Commands } from '../constants'; -import type { Container } from '../container'; -import { command } from '../system/command'; -import { AutolinkedItemNode } from '../views/nodes/autolinkedItemNode'; -import type { CommandContext } from './base'; -import { Command } from './base'; - -export interface OpenIssueOnRemoteCommandArgs { - clipboard?: boolean; - issue: { url: string }; -} - -@command() -export class OpenIssueOnRemoteCommand extends Command { - constructor(private readonly container: Container) { - super([ - Commands.OpenIssueOnRemote, - Commands.CopyRemoteIssueUrl, - Commands.OpenAutolinkUrl, - Commands.CopyAutolinkUrl, - ]); - } - - protected override preExecute(context: CommandContext, args: OpenIssueOnRemoteCommandArgs) { - if (context.type === 'viewItem' && context.node instanceof AutolinkedItemNode) { - args = { - ...args, - issue: { url: context.node.item.url }, - clipboard: - context.command === Commands.CopyRemoteIssueUrl || context.command === Commands.CopyAutolinkUrl, - }; - } - - return this.execute(args); - } - - async execute(args: OpenIssueOnRemoteCommandArgs) { - if (args.clipboard) { - await env.clipboard.writeText(args.issue.url); - } else { - void env.openExternal(Uri.parse(args.issue.url)); - } - } -} diff --git a/src/commands/openOnRemote.ts b/src/commands/openOnRemote.ts index 7381d7f5f39ee..dc05a35daa54b 100644 --- a/src/commands/openOnRemote.ts +++ b/src/commands/openOnRemote.ts @@ -1,27 +1,30 @@ -import { Commands, GlyphChars } from '../constants'; +import { GlyphChars } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { GitRevision } from '../git/models/reference'; -import { GitRemote } from '../git/models/remote'; +import { createRevisionRange, shortenRevision } from '../git/models/reference'; +import type { GitRemote } from '../git/models/remote'; +import { getHighlanderProviders } from '../git/models/remote'; import type { RemoteResource } from '../git/models/remoteResource'; import { RemoteResourceType } from '../git/models/remoteResource'; import type { RemoteProvider } from '../git/remotes/remoteProvider'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { RemoteProviderPicker } from '../quickpicks/remoteProviderPicker'; -import { command } from '../system/command'; +import { showRemoteProviderPicker } from '../quickpicks/remoteProviderPicker'; +import { ensureArray } from '../system/array'; +import { Logger } from '../system/logger'; import { pad, splitSingle } from '../system/string'; +import { command } from '../system/vscode/command'; import { Command } from './base'; export type OpenOnRemoteCommandArgs = | { - resource: RemoteResource; + resource: RemoteResource | RemoteResource[]; repoPath: string; remote?: string; clipboard?: boolean; } | { - resource: RemoteResource; + resource: RemoteResource | RemoteResource[]; remotes: GitRemote[]; remote?: string; @@ -38,7 +41,9 @@ export class OpenOnRemoteCommand extends Command { if (args?.resource == null) return; let remotes = - 'remotes' in args ? args.remotes : await this.container.git.getRemotesWithProviders(args.repoPath); + 'remotes' in args + ? args.remotes + : await this.container.git.getRemotesWithProviders(args.repoPath, { sort: true }); if (args.remote != null) { const filtered = remotes.filter(r => r.name === args.remote); @@ -48,53 +53,73 @@ export class OpenOnRemoteCommand extends Command { } } - try { - if (args.resource.type === RemoteResourceType.Branch) { - // Check to see if the remote is in the branch - const [remoteName, branchName] = splitSingle(args.resource.branch, '/'); - if (branchName != null) { - const remote = remotes.find(r => r.name === remoteName); - if (remote != null) { - args.resource.branch = branchName; - remotes = [remote]; + async function processResource(this: OpenOnRemoteCommand, resource: RemoteResource) { + try { + if (resource.type === RemoteResourceType.Branch) { + // Check to see if the remote is in the branch + const [remoteName, branchName] = splitSingle(resource.branch, '/'); + if (branchName != null) { + const remote = remotes.find(r => r.name === remoteName); + if (remote != null) { + resource.branch = branchName; + remotes = [remote]; + } } - } - } else if (args.resource.type === RemoteResourceType.Revision) { - const { commit, fileName } = args.resource; - if (commit != null) { - const file = await commit.findFile(fileName); - if (file?.status === 'D') { - // Resolve to the previous commit to that file - args.resource.sha = await this.container.git.resolveReference( - commit.repoPath, - `${commit.sha}^`, - fileName, - ); - } else { - args.resource.sha = commit.sha; + } else if (resource.type === RemoteResourceType.Revision) { + const { commit, fileName } = resource; + if (commit != null) { + const file = await commit.findFile(fileName); + if (file?.status === 'D') { + // Resolve to the previous commit to that file + resource.sha = await this.container.git.resolveReference( + commit.repoPath, + `${commit.sha}^`, + fileName, + ); + } else { + resource.sha = commit.sha; + } } } + } catch (ex) { + debugger; + Logger.error(ex, 'OpenOnRemoteCommand.processResource'); + } + } + + try { + const resources = ensureArray(args.resource); + for (const resource of resources) { + await processResource.call(this, resource); } - const providers = GitRemote.getHighlanderProviders(remotes); + const providers = getHighlanderProviders(remotes); const provider = providers?.length ? providers[0].name : 'Remote'; - const options: Parameters[4] = { + const options: Parameters[4] = { autoPick: 'default', clipboard: args.clipboard, setDefault: true, }; let title; - let placeHolder = `Choose which remote to ${args.clipboard ? 'copy the link for' : 'open on'}`; + let placeholder = `Choose which remote to ${ + args.clipboard ? `copy the link${resources.length > 1 ? 's' : ''} for` : 'open on' + }`; function getTitlePrefix(type: string): string { - return args?.clipboard ? `Copy Link to ${type} for ${provider}` : `Open Branch on ${provider}`; + return args?.clipboard + ? `Copy ${provider} ${type} Link${resources.length > 1 ? 's' : ''}` + : `Open ${type} on ${provider}`; } - switch (args.resource.type) { + const [resource] = resources; + switch (resource.type) { case RemoteResourceType.Branch: - title = `${getTitlePrefix('Branch')}${pad(GlyphChars.Dot, 2, 2)}${args.resource.branch}`; + title = getTitlePrefix('Branch'); + if (resources.length === 1) { + title += `${pad(GlyphChars.Dot, 2, 2)}${resource.branch}`; + } break; case RemoteResourceType.Branches: @@ -102,40 +127,57 @@ export class OpenOnRemoteCommand extends Command { break; case RemoteResourceType.Commit: - title = `${getTitlePrefix('Commit')}${pad(GlyphChars.Dot, 2, 2)}${GitRevision.shorten( - args.resource.sha, - )}`; + title = getTitlePrefix('Commit'); + if (resources.length === 1) { + title += `${pad(GlyphChars.Dot, 2, 2)}${shortenRevision(resource.sha)}`; + } break; case RemoteResourceType.Comparison: - title = `${getTitlePrefix('Comparison')}${pad(GlyphChars.Dot, 2, 2)}${GitRevision.createRange( - args.resource.base, - args.resource.compare, - args.resource.notation ?? '...', - )}`; + title = getTitlePrefix('Comparisons'); + if (resources.length === 1) { + title += `${pad(GlyphChars.Dot, 2, 2)}${createRevisionRange( + resource.base, + resource.compare, + resource.notation ?? '...', + )}`; + } break; case RemoteResourceType.CreatePullRequest: options.autoPick = true; options.setDefault = false; - title = `${ - args.clipboard - ? `Copy Create Pull Request Link for ${provider}` - : `Create Pull Request on ${provider}` - }${pad(GlyphChars.Dot, 2, 2)}${ - args.resource.base?.branch - ? GitRevision.createRange(args.resource.base.branch, args.resource.compare.branch, '...') - : args.resource.compare.branch - }`; - - placeHolder = `Choose which remote to ${ - args.clipboard ? 'copy the create pull request link for' : 'create the pull request on' - }`; + if (resources.length > 1) { + title = args.clipboard + ? `Copy ${provider} Create Pull Request Links` + : `Create Pull Requests on ${provider}`; + + placeholder = `Choose which remote to ${ + args.clipboard ? 'copy the create pull request links for' : 'create the pull requests on' + }`; + } else { + title = `${ + args.clipboard + ? `Copy ${provider} Create Pull Request Link` + : `Create Pull Request on ${provider}` + }${pad(GlyphChars.Dot, 2, 2)}${ + resource.base?.branch + ? createRevisionRange(resource.base.branch, resource.compare.branch, '...') + : resource.compare.branch + }`; + + placeholder = `Choose which remote to ${ + args.clipboard ? 'copy the create pull request link for' : 'create the pull request on' + }`; + } break; case RemoteResourceType.File: - title = `${getTitlePrefix('File')}${pad(GlyphChars.Dot, 2, 2)}${args.resource.fileName}`; + title = getTitlePrefix('File'); + if (resources.length === 1) { + title += `${pad(GlyphChars.Dot, 2, 2)}${resource.fileName}`; + } break; case RemoteResourceType.Repo: @@ -143,19 +185,27 @@ export class OpenOnRemoteCommand extends Command { break; case RemoteResourceType.Revision: { - title = `${getTitlePrefix('File')}${pad(GlyphChars.Dot, 2, 2)}${GitRevision.shorten( - args.resource.sha, - )}${pad(GlyphChars.Dot, 1, 1)}${args.resource.fileName}`; + title = getTitlePrefix('File'); + if (resources.length === 1) { + title += `${pad(GlyphChars.Dot, 2, 2)}${shortenRevision(resource.sha)}${pad( + GlyphChars.Dot, + 1, + 1, + )}${resource.fileName}`; + } break; } // case RemoteResourceType.Tag: { - // title = `${getTitlePrefix('Tag')}${pad(GlyphChars.Dot, 2, 2)}${args.resource.tag}`; + // title = getTitlePrefix('Tag'); + // if (resources.length === 1) { + // title += `${pad(GlyphChars.Dot, 2, 2)}${args.resource.tag}`; + // } // break; // } } - const pick = await RemoteProviderPicker.show(title, placeHolder, args.resource, remotes, options); + const pick = await showRemoteProviderPicker(title, placeholder, resources, remotes, options); await pick?.execute(); } catch (ex) { Logger.error(ex, 'OpenOnRemoteCommand'); diff --git a/src/commands/openOnlyChangedFiles.ts b/src/commands/openOnlyChangedFiles.ts new file mode 100644 index 0000000000000..59c8ab39d8c97 --- /dev/null +++ b/src/commands/openOnlyChangedFiles.ts @@ -0,0 +1,85 @@ +import type { Uri } from 'vscode'; +import { TabInputCustom, TabInputNotebook, TabInputNotebookDiff, TabInputText, TabInputTextDiff, window } from 'vscode'; +import { Commands } from '../constants.commands'; +import type { Container } from '../container'; +import { showGenericErrorMessage } from '../messages'; +import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { filterMap } from '../system/array'; +import { UriComparer } from '../system/comparers'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; +import { findOrOpenEditors } from '../system/vscode/utils'; +import { Command } from './base'; + +export interface OpenOnlyChangedFilesCommandArgs { + uris?: Uri[]; +} + +@command() +export class OpenOnlyChangedFilesCommand extends Command { + constructor(private readonly container: Container) { + super(Commands.OpenOnlyChangedFiles); + } + + async execute(args?: OpenOnlyChangedFilesCommandArgs) { + args = { ...args }; + + try { + if (args.uris == null) { + const repository = await getRepositoryOrShowPicker('Open Changed & Close Unchanged Files'); + if (repository == null) return; + + const status = await this.container.git.getStatusForRepo(repository.uri); + if (status == null) { + void window.showWarningMessage('Unable to open changed & close unchanged files'); + + return; + } + + args.uris = filterMap(status.files, f => (f.status !== 'D' ? f.uri : undefined)); + } + + const hasNoChangedFiles = args.uris.length === 0; + const openUris = new Set(args.uris); + let inputUri: Uri | undefined = undefined; + let matchingUri: Uri | undefined; + + for (const group of window.tabGroups.all) { + for (const tab of group.tabs) { + if (hasNoChangedFiles) { + void window.tabGroups.close(tab, true); + continue; + } + + if ( + tab.input instanceof TabInputText || + tab.input instanceof TabInputCustom || + tab.input instanceof TabInputNotebook + ) { + inputUri = tab.input.uri; + } else if (tab.input instanceof TabInputTextDiff || tab.input instanceof TabInputNotebookDiff) { + inputUri = tab.input.modified; + } else { + inputUri = undefined; + } + + if (inputUri == null) continue; + // eslint-disable-next-line no-loop-func + matchingUri = args.uris.find(uri => UriComparer.equals(uri, inputUri)); + if (matchingUri != null) { + openUris.delete(matchingUri); + } else { + void window.tabGroups.close(tab, true); + } + } + } + + if (openUris.size > 0) { + findOrOpenEditors([...openUris]); + } + } catch (ex) { + Logger.error(ex, 'OpenOnlyChangedFilesCommand'); + void showGenericErrorMessage('Unable to open changed & close unchanged files'); + } + } +} diff --git a/src/commands/openPullRequestOnRemote.ts b/src/commands/openPullRequestOnRemote.ts index b687f79355c89..173e5abb79d71 100644 --- a/src/commands/openPullRequestOnRemote.ts +++ b/src/commands/openPullRequestOnRemote.ts @@ -1,8 +1,9 @@ -import { env, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { env, window } from 'vscode'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { command } from '../system/command'; -import { PullRequestNode } from '../views/nodes/pullRequestNode'; +import { shortenRevision } from '../git/models/reference'; +import { command } from '../system/vscode/command'; +import { openUrl } from '../system/vscode/utils'; import type { CommandContext } from './base'; import { Command } from './base'; @@ -20,10 +21,10 @@ export class OpenPullRequestOnRemoteCommand extends Command { } protected override preExecute(context: CommandContext, args?: OpenPullRequestOnRemoteCommandArgs) { - if (context.type === 'viewItem' && context.node instanceof PullRequestNode) { + if (context.type === 'viewItem' && (context.node.is('pullrequest') || context.node.is('launchpad-item'))) { args = { ...args, - pr: { url: context.node.pullRequest.url }, + pr: context.node.pullRequest != null ? { url: context.node.pullRequest.url } : undefined, clipboard: context.command === Commands.CopyRemotePullRequestUrl, }; } @@ -35,11 +36,17 @@ export class OpenPullRequestOnRemoteCommand extends Command { if (args?.pr == null) { if (args?.repoPath == null || args?.ref == null) return; - const remote = await this.container.git.getBestRemoteWithRichProvider(args.repoPath); - if (remote?.provider == null) return; + const remote = await this.container.git.getBestRemoteWithIntegration(args.repoPath); + if (remote == null) return; - const pr = await this.container.git.getPullRequestForCommit(args.ref, remote.provider); - if (pr == null) return; + const provider = await this.container.integrations.getByRemote(remote); + if (provider == null) return; + + const pr = await provider.getPullRequestForCommit(remote.provider.repoDesc, args.ref); + if (pr == null) { + void window.showInformationMessage(`No pull request associated with '${shortenRevision(args.ref)}'`); + return; + } args = { ...args }; args.pr = pr; @@ -48,7 +55,7 @@ export class OpenPullRequestOnRemoteCommand extends Command { if (args.clipboard) { await env.clipboard.writeText(args.pr.url); } else { - void env.openExternal(Uri.parse(args.pr.url)); + void openUrl(args.pr.url); } } } diff --git a/src/commands/openRepoOnRemote.ts b/src/commands/openRepoOnRemote.ts index 59e91043078c5..b40382af95cc4 100644 --- a/src/commands/openRepoOnRemote.ts +++ b/src/commands/openRepoOnRemote.ts @@ -1,12 +1,12 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import { RemoteResourceType } from '../git/models/remoteResource'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; -import { command, executeCommand } from '../system/command'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { Logger } from '../system/logger'; +import { command, executeCommand } from '../system/vscode/command'; import type { CommandContext } from './base'; import { ActiveEditorCommand, getCommandUri, isCommandContextViewNodeHasRemote } from './base'; import type { OpenOnRemoteCommandArgs } from './openOnRemote'; @@ -40,7 +40,7 @@ export class OpenRepoOnRemoteCommand extends ActiveEditorCommand { const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; const repoPath = ( - await RepositoryPicker.getBestRepositoryOrShow( + await getBestRepositoryOrShowPicker( gitUri, editor, args?.clipboard diff --git a/src/commands/openRevisionFile.ts b/src/commands/openRevisionFile.ts index d1395572ed5f1..7c59ae0216451 100644 --- a/src/commands/openRevisionFile.ts +++ b/src/commands/openRevisionFile.ts @@ -1,13 +1,13 @@ import type { TextDocumentShowOptions, TextEditor, Uri } from 'vscode'; -import type { FileAnnotationType } from '../configuration'; -import { Commands } from '../constants'; +import type { FileAnnotationType } from '../config'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { openFileAtRevision } from '../git/actions/commit'; import { GitUri } from '../git/gitUri'; -import { GitRevision } from '../git/models/reference'; -import { Logger } from '../logger'; +import { deletedOrMissing } from '../git/models/constants'; import { showGenericErrorMessage } from '../messages'; -import { command } from '../system/command'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; import { ActiveEditorCommand, getCommandUri } from './base'; export interface OpenRevisionFileCommandArgs { @@ -43,7 +43,7 @@ export class OpenRevisionFileCommand extends ActiveEditorCommand { args.revisionUri = commit?.file?.status === 'D' ? this.container.git.getRevisionUri( - (await commit.getPreviousSha()) ?? GitRevision.deletedOrMissing, + (await commit.getPreviousSha()) ?? deletedOrMissing, commit.file, commit.repoPath, ) diff --git a/src/commands/openWorkingFile.ts b/src/commands/openWorkingFile.ts index 8231c00fa66c9..6bdfa8022a30e 100644 --- a/src/commands/openWorkingFile.ts +++ b/src/commands/openWorkingFile.ts @@ -1,13 +1,13 @@ import type { TextDocumentShowOptions, TextEditor, Uri } from 'vscode'; import { Range, window } from 'vscode'; -import type { FileAnnotationType } from '../configuration'; -import { Commands } from '../constants'; +import type { FileAnnotationType } from '../config'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri, isGitUri } from '../git/gitUri'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { command } from '../system/command'; -import { findOrOpenEditor } from '../system/utils'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; +import { findOrOpenEditor } from '../system/vscode/utils'; import { ActiveEditorCommand, getCommandUri } from './base'; export interface OpenWorkingFileCommandArgs { diff --git a/src/commands/patches.ts b/src/commands/patches.ts new file mode 100644 index 0000000000000..bca66f647cb0e --- /dev/null +++ b/src/commands/patches.ts @@ -0,0 +1,421 @@ +import { EntityIdentifierUtils } from '@gitkraken/provider-apis'; +import type { TextEditor } from 'vscode'; +import { env, Uri, window, workspace } from 'vscode'; +import type { ScmResource } from '../@types/vscode.git.resources'; +import { ScmResourceGroupType } from '../@types/vscode.git.resources.enums'; +import { Commands } from '../constants.commands'; +import type { Container } from '../container'; +import { CancellationError } from '../errors'; +import { ApplyPatchCommitError, ApplyPatchCommitErrorReason } from '../git/errors'; +import { uncommitted, uncommittedStaged } from '../git/models/constants'; +import type { GitDiff } from '../git/models/diff'; +import { isSha, shortenRevision } from '../git/models/reference'; +import type { Repository } from '../git/models/repository'; +import { splitGitCommitMessage } from '../git/utils/commit-utils'; +import type { Draft, LocalDraft } from '../gk/models/drafts'; +import { showPatchesView } from '../plus/drafts/actions'; +import type { ProviderAuth } from '../plus/drafts/draftsService'; +import type { IntegrationId } from '../plus/integrations/providers/models'; +import { getProviderIdFromEntityIdentifier } from '../plus/integrations/providers/utils'; +import type { Change, CreateDraft } from '../plus/webviews/patchDetails/protocol'; +import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { map } from '../system/iterable'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; +import type { CommandContext } from './base'; +import { + ActiveEditorCommand, + Command, + isCommandContextViewNodeHasCommit, + isCommandContextViewNodeHasComparison, + isCommandContextViewNodeHasFileCommit, +} from './base'; + +export interface CreatePatchCommandArgs { + to?: string; + from?: string; + repoPath?: string; + uris?: Uri[]; + + title?: string; + description?: string; +} + +abstract class CreatePatchCommandBase extends Command { + constructor( + protected readonly container: Container, + command: Commands | Commands[], + ) { + super(command); + } + + protected override async preExecute(context: CommandContext, args?: CreatePatchCommandArgs) { + if (args == null) { + if (context.type === 'scm-states') { + const resourcesByGroup = new Map(); + const uris = new Set(); + + let repo; + for (const resource of context.scmResourceStates as ScmResource[]) { + repo ??= await this.container.git.getOrOpenRepository(resource.resourceUri); + + uris.add(resource.resourceUri.toString()); + + let groupResources = resourcesByGroup.get(resource.resourceGroupType!); + if (groupResources == null) { + groupResources = []; + resourcesByGroup.set(resource.resourceGroupType!, groupResources); + } else { + groupResources.push(resource); + } + } + + const to = + resourcesByGroup.size == 1 && resourcesByGroup.has(ScmResourceGroupType.Index) + ? uncommittedStaged + : uncommitted; + args = { + repoPath: repo?.path, + to: to, + from: 'HEAD', + uris: [...map(uris, u => Uri.parse(u))], + title: to === uncommittedStaged ? 'Staged Changes' : 'Uncommitted Changes', + }; + } else if (context.type === 'scm-groups') { + const group = context.scmResourceGroups[0]; + if (!group?.resourceStates?.length) return; + + const repo = await this.container.git.getOrOpenRepository(group.resourceStates[0].resourceUri); + + const to = group.id === 'index' ? uncommittedStaged : uncommitted; + args = { + repoPath: repo?.path, + to: to, + from: 'HEAD', + title: to === uncommittedStaged ? 'Staged Changes' : 'Uncommitted Changes', + }; + } else if (context.type === 'viewItem') { + if (isCommandContextViewNodeHasCommit(context)) { + const { commit } = context.node; + if (commit.message == null) { + await commit.ensureFullDetails(); + } + + const { title, description } = splitGitCommitMessage(commit.message); + + args = { + repoPath: context.node.commit.repoPath, + to: context.node.commit.ref, + from: `${context.node.commit.ref}^`, + title: title, + description: description, + }; + if (isCommandContextViewNodeHasFileCommit(context)) { + args.uris = [context.node.uri]; + } + } else if (isCommandContextViewNodeHasComparison(context)) { + args = { + repoPath: context.node.uri.fsPath, + to: context.node.compareRef.ref, + from: context.node.compareWithRef.ref, + title: `Changes between ${shortenRevision(context.node.compareRef.ref)} and ${shortenRevision( + context.node.compareWithRef.ref, + )}`, + }; + } + } + } + + return this.execute(args); + } + + protected async getDiff(title: string, args?: CreatePatchCommandArgs): Promise { + let repo; + if (args?.repoPath != null) { + repo = this.container.git.getRepository(args.repoPath); + } + repo ??= await getRepositoryOrShowPicker(title); + if (repo == null) return; + + return this.container.git.getDiff( + repo.uri, + args?.to ?? uncommitted, + args?.from ?? 'HEAD', + args?.uris?.length ? { uris: args.uris } : undefined, + ); + } + + abstract override execute(args?: CreatePatchCommandArgs): Promise; +} + +@command() +export class CreatePatchCommand extends CreatePatchCommandBase { + constructor(container: Container) { + super(container, Commands.CreatePatch); + } + + async execute(args?: CreatePatchCommandArgs) { + const diff = await this.getDiff('Create Patch', args); + if (diff == null) return; + + debugger; + const d = await workspace.openTextDocument({ content: diff.contents, language: 'diff' }); + await window.showTextDocument(d); + + // const uri = await window.showSaveDialog({ + // filters: { Patches: ['patch'] }, + // saveLabel: 'Create Patch', + // }); + // if (uri == null) return; + + // await workspace.fs.writeFile(uri, new TextEncoder().encode(patch.contents)); + } +} + +@command() +export class CopyPatchToClipboardCommand extends CreatePatchCommandBase { + constructor(container: Container) { + super(container, Commands.CopyPatchToClipboard); + } + + async execute(args?: CreatePatchCommandArgs) { + const diff = await this.getDiff('Copy as Patch', args); + if (diff == null) return; + + await env.clipboard.writeText(diff.contents); + void window.showInformationMessage( + "Copied patch \u2014 use 'Apply Copied Patch' in another window to apply it", + ); + } +} + +@command() +export class ApplyPatchFromClipboardCommand extends Command { + constructor(private readonly container: Container) { + super([Commands.ApplyPatchFromClipboard, Commands.PastePatchFromClipboard]); + } + + async execute() { + const patch = await env.clipboard.readText(); + let repo = this.container.git.highlander; + + // Make sure it looks like a valid patch + const valid = patch.length ? await this.container.git.validatePatch(repo?.uri ?? Uri.file(''), patch) : false; + if (!valid) { + void window.showWarningMessage('No valid patch found in the clipboard'); + return; + } + + repo ??= await getRepositoryOrShowPicker('Apply Copied Patch'); + if (repo == null) return; + + try { + const commit = await this.container.git.createUnreachableCommitForPatch( + repo.uri, + patch, + 'HEAD', + 'Pasted Patch', + ); + if (commit == null) return; + + await this.container.git.applyUnreachableCommitForPatch(repo.uri, commit.sha, { stash: false }); + void window.showInformationMessage(`Patch applied successfully`); + } catch (ex) { + if (ex instanceof CancellationError) return; + + if (ex instanceof ApplyPatchCommitError) { + if (ex.reason === ApplyPatchCommitErrorReason.AppliedWithConflicts) { + void window.showWarningMessage('Patch applied with conflicts'); + } else { + void window.showErrorMessage(ex.message); + } + } else { + void window.showErrorMessage(`Unable to apply patch: ${ex.message}`); + } + } + } +} + +@command() +export class CreateCloudPatchCommand extends CreatePatchCommandBase { + constructor(container: Container) { + super(container, [Commands.CreateCloudPatch, Commands.ShareAsCloudPatch]); + } + + async execute(args?: CreatePatchCommandArgs) { + if (args?.repoPath == null) { + return showPatchesView({ mode: 'create' }); + } + + const repo = this.container.git.getRepository(args.repoPath); + if (repo == null) { + return showPatchesView({ mode: 'create' }); + } + + const create = await createDraft(this.container, repo, args); + if (create == null) { + return showPatchesView({ mode: 'create', create: { repositories: [repo] } }); + } + return showPatchesView({ mode: 'create', create: create }); + } +} + +@command() +export class OpenPatchCommand extends ActiveEditorCommand { + constructor(private readonly container: Container) { + super(Commands.OpenPatch); + } + + async execute(editor?: TextEditor) { + let document; + if (editor?.document?.languageId === 'diff') { + document = editor.document; + } else { + const uris = await window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { Patches: ['diff', 'patch'] }, + openLabel: 'Open Patch', + title: 'Open Patch File', + }); + const uri = uris?.[0]; + if (uri == null) return; + + document = await workspace.openTextDocument(uri); + await window.showTextDocument(document); + } + + const patch: LocalDraft = { + draftType: 'local', + patch: { + type: 'local', + uri: document.uri, + contents: document.getText(), + }, + }; + + void showPatchesView({ mode: 'view', draft: patch }); + } +} + +export interface OpenCloudPatchCommandArgs { + type: 'patch' | 'code_suggestion'; + id: string; + patchId?: string; + draft?: Draft; + prEntityId?: string; +} + +@command() +export class OpenCloudPatchCommand extends Command { + constructor(private readonly container: Container) { + super(Commands.OpenCloudPatch); + } + + async execute(args?: OpenCloudPatchCommandArgs) { + const type = args?.type === 'code_suggestion' ? 'Code Suggestion' : 'Cloud Patch'; + if (args?.id == null && args?.draft == null) { + void window.showErrorMessage(`Cannot open ${type}; no patch or patch id provided`); + return; + } + + let providerAuth: ProviderAuth | undefined; + if (args?.prEntityId != null && args?.type === 'code_suggestion') { + let providerId: IntegrationId | undefined; + let providerDomain: string | undefined; + try { + const identifier = EntityIdentifierUtils.decode(args.prEntityId); + providerId = getProviderIdFromEntityIdentifier(identifier); + providerDomain = identifier.domain ?? undefined; + } catch { + void window.showErrorMessage(`Cannot open ${type}; invalid provider details.`); + return; + } + + if (providerId == null) { + void window.showErrorMessage(`Cannot open ${type}; unsupported provider.`); + return; + } + + const integration = await this.container.integrations.get(providerId, providerDomain); + if (integration == null) { + void window.showErrorMessage(`Cannot open ${type}; provider not found.`); + return; + } + + const session = await integration.getSession('cloud-patches'); + if (session == null) { + void window.showErrorMessage(`Cannot open ${type}; provider not connected.`); + return; + } + + providerAuth = { provider: integration.id, token: session.accessToken }; + } + + try { + const draft = + args?.draft ?? (await this.container.drafts.getDraft(args?.id, { providerAuth: providerAuth })); + void showPatchesView({ mode: 'view', draft: draft }); + } catch (ex) { + Logger.error(ex, 'OpenCloudPatchCommand'); + void window.showErrorMessage(`Unable to open ${type} '${args.id}'`); + } + } +} + +async function createDraft( + container: Container, + repository: Repository, + args: CreatePatchCommandArgs, +): Promise { + if (args.to == null) return undefined; + + const to = args.to ?? 'HEAD'; + + const change: Change = { + type: 'revision', + repository: { + name: repository.name, + path: repository.path, + uri: repository.uri.toString(), + }, + files: undefined!, + revision: { to: to, from: args.from ?? `${to}^` }, + }; + + const create: CreateDraft = { changes: [change], title: args.title, description: args.description }; + + const commit = await container.git.getCommit(repository.uri, to); + if (commit == null) return undefined; + + if (args.from == null) { + if (commit.files == null) return; + + change.files = [...commit.files]; + } else { + const diff = await container.git.getDiff(repository.uri, to, args.from); + if (diff == null) return; + + const result = await container.git.getDiffFiles(repository.uri, diff.contents); + if (result?.files == null) return; + + change.files = result.files; + + if (!isSha(args.to)) { + const commit = await container.git.getCommit(repository.uri, args.to); + if (commit != null) { + change.revision.to = commit.sha; + } + } + + if (!isSha(args.from)) { + const commit = await container.git.getCommit(repository.uri, args.from); + if (commit != null) { + change.revision.from = commit.sha; + } + } + } + + return create; +} diff --git a/src/commands/quickCommand.buttons.ts b/src/commands/quickCommand.buttons.ts index a6d67c38e7fcd..a17c71cfb0933 100644 --- a/src/commands/quickCommand.buttons.ts +++ b/src/commands/quickCommand.buttons.ts @@ -57,118 +57,191 @@ export class SelectableQuickInputButton extends ToggleQuickInputButton { } } -export namespace QuickCommandButtons { - export const Fetch: QuickInputButton = { - iconPath: new ThemeIcon('sync'), - tooltip: 'Fetch', - }; - - export const LoadMore: QuickInputButton = { - iconPath: new ThemeIcon('refresh'), - tooltip: 'Load More', - }; - - export const MatchCaseToggle = class extends SelectableQuickInputButton { - constructor(on = false) { - super('Match Case', { off: 'icon-match-case', on: 'icon-match-case-selected' }, on); - } - }; - - export const MatchAllToggle = class extends SelectableQuickInputButton { - constructor(on = false) { - super('Match All', { off: 'icon-match-all', on: 'icon-match-all-selected' }, on); - } - }; - - export const MatchRegexToggle = class extends SelectableQuickInputButton { - constructor(on = false) { - super('Match using Regular Expressions', { off: 'icon-match-regex', on: 'icon-match-regex-selected' }, on); - } - }; - - export const PickCommit: QuickInputButton = { - iconPath: new ThemeIcon('git-commit'), - tooltip: 'Choose a Specific Commit', - }; - - export const PickCommitToggle = class extends ToggleQuickInputButton { - constructor(on = false, context: { showTags: boolean }, onDidClick?: (quickInput: QuickInput) => void) { - super( - () => ({ - on: { tooltip: 'Choose a Specific Commit', icon: new ThemeIcon('git-commit') }, - off: { - tooltip: `Choose a Branch${context.showTags ? ' or Tag' : ''}`, - icon: new ThemeIcon('git-branch'), - }, - }), - on, - ); - - this.onDidClick = onDidClick; - } - }; - - export const OpenInNewWindow: QuickInputButton = { - iconPath: new ThemeIcon('empty-window'), - tooltip: 'Open in New Window', - }; - - export const RevealInSideBar: QuickInputButton = { - iconPath: new ThemeIcon('search'), - tooltip: 'Reveal in Side Bar', - }; - - export const SetRemoteAsDefault: QuickInputButton = { - iconPath: new ThemeIcon('settings-gear'), - tooltip: 'Set as Default Remote', - }; - - export const ShowDetailsView: QuickInputButton = { - iconPath: new ThemeIcon('eye'), - tooltip: 'Open Details', - }; - - export const ShowResultsInSideBar: QuickInputButton = { - iconPath: new ThemeIcon('link-external'), - tooltip: 'Show Results in Side Bar', - }; - - export const ShowTagsToggle = class extends SelectableQuickInputButton { - constructor(on = false) { - super('Show Tags', { off: new ThemeIcon('tag'), on: 'icon-tag-selected' }, on); - } - }; - - export const WillConfirmForced: QuickInputButton = { - iconPath: new ThemeIcon('check'), - tooltip: 'Will always confirm', - }; - - export const WillConfirmToggle = class extends ToggleQuickInputButton { - constructor(on = false, onDidClick?: (quickInput: QuickInput) => void) { - super( - () => ({ - on: { - tooltip: 'Will confirm', - icon: { - dark: Uri.file(Container.instance.context.asAbsolutePath('images/dark/icon-check.svg')), - light: Uri.file(Container.instance.context.asAbsolutePath('images/light/icon-check.svg')), - }, - }, - off: { - tooltip: 'Skips confirm', - icon: { - dark: Uri.file(Container.instance.context.asAbsolutePath('images/dark/icon-no-check.svg')), - light: Uri.file( - Container.instance.context.asAbsolutePath('images/light/icon-no-check.svg'), - ), - }, - }, - }), - on, - ); - - this.onDidClick = onDidClick; - } - }; -} +export const ClearQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('clear-all'), + tooltip: 'Clear', +}; + +export const ConnectIntegrationButton: QuickInputButton = { + iconPath: new ThemeIcon('plug'), + tooltip: 'Connect Additional Integrations', +}; + +export const FeedbackQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('feedback'), + tooltip: 'Give Us Feedback', +}; + +export const FetchQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('gitlens-repo-fetch'), + tooltip: 'Fetch', +}; + +export const LoadMoreQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('refresh'), + tooltip: 'Load More', +}; + +export const MatchCaseToggleQuickInputButton = class extends SelectableQuickInputButton { + constructor(on = false) { + super('Match Case', { off: 'icon-match-case', on: 'icon-match-case-selected' }, on); + } +}; + +export const MatchAllToggleQuickInputButton = class extends SelectableQuickInputButton { + constructor(on = false) { + super('Match All', { off: 'icon-match-all', on: 'icon-match-all-selected' }, on); + } +}; + +export const MatchRegexToggleQuickInputButton = class extends SelectableQuickInputButton { + constructor(on = false) { + super('Match using Regular Expressions', { off: 'icon-match-regex', on: 'icon-match-regex-selected' }, on); + } +}; + +export const PickCommitQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('git-commit'), + tooltip: 'Choose a Specific Commit', +}; + +export const PickCommitToggleQuickInputButton = class extends ToggleQuickInputButton { + constructor(on = false, context: { showTags: boolean }, onDidClick?: (quickInput: QuickInput) => void) { + super( + () => ({ + on: { tooltip: 'Choose a Specific Commit', icon: new ThemeIcon('git-commit') }, + off: { + tooltip: `Choose a Branch${context.showTags ? ' or Tag' : ''}`, + icon: new ThemeIcon('git-branch'), + }, + }), + on, + ); + + this.onDidClick = onDidClick; + } +}; + +export const LearnAboutProQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('info'), + tooltip: 'Learn about GitLens Pro', +}; + +export const MergeQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('merge'), + tooltip: 'Merge...', +}; + +export const OpenOnGitHubQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('globe'), + tooltip: 'Open on GitHub', +}; + +export const OpenOnGitLabQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('globe'), + tooltip: 'Open on GitLab', +}; + +export const OpenOnWebQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('globe'), + tooltip: 'Open on gitkraken.dev', +}; + +export const LaunchpadSettingsQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('gear'), + tooltip: 'Launchpad Settings', +}; + +export const PinQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('pinned'), + tooltip: 'Pin', +}; + +export const UnpinQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('pin'), + tooltip: 'Unpin', +}; + +export const SnoozeQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('bell-slash'), + tooltip: 'Snooze', +}; + +export const RefreshQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('refresh'), + tooltip: 'Refresh', +}; + +export const UnsnoozeQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('bell'), + tooltip: 'Unsnooze', +}; +export const OpenInNewWindowQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('empty-window'), + tooltip: 'Open in New Window', +}; + +export const RevealInSideBarQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('search'), + tooltip: 'Reveal in Side Bar', +}; + +export const SetRemoteAsDefaultQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('settings-gear'), + tooltip: 'Set as Default Remote', +}; + +export const ShowDetailsViewQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('eye'), + tooltip: 'Inspect Details', +}; + +export const OpenChangesViewQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('compare-changes'), + tooltip: 'Open Changes', +}; + +export const ShowResultsInSideBarQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('link-external'), + tooltip: 'Show Results in Side Bar', +}; + +export const OpenWorktreeInNewWindowQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('empty-window'), + tooltip: 'Open in Worktree', +}; + +export const ShowTagsToggleQuickInputButton = class extends SelectableQuickInputButton { + constructor(on = false) { + super('Show Tags', { off: new ThemeIcon('tag'), on: 'icon-tag-selected' }, on); + } +}; + +export const WillConfirmForcedQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('gitlens-confirm-checked'), + tooltip: 'You will be presented with a required confirmation step before the action is performed', +}; + +export const WillConfirmToggleQuickInputButton = class extends ToggleQuickInputButton { + constructor(on = false, isConfirmationStep: boolean, onDidClick?: (quickInput: QuickInput) => void) { + super( + () => ({ + on: { + tooltip: isConfirmationStep + ? 'For future actions, you will be presented with confirmation step before the action is performed\nClick to toggle' + : 'You will be presented with confirmation step before the action is performed\nClick to toggle', + icon: new ThemeIcon('gitlens-confirm-checked'), + }, + off: { + tooltip: isConfirmationStep + ? "For future actions, you won't be presented with confirmation step before the action is performed\nClick to toggle" + : "You won't be presented with confirmation step before the action is performed\nClick to toggle", + icon: new ThemeIcon('gitlens-confirm-unchecked'), + }, + }), + on, + ); + + this.onDidClick = onDidClick; + } +}; diff --git a/src/commands/quickCommand.steps.ts b/src/commands/quickCommand.steps.ts index d05189c003626..a49acb5c4eabc 100644 --- a/src/commands/quickCommand.steps.ts +++ b/src/commands/quickCommand.steps.ts @@ -1,8 +1,10 @@ -import type { QuickInputButton, QuickPick } from 'vscode'; -import { BranchSorting, configuration, TagSorting } from '../configuration'; -import { Commands, GlyphChars, quickPickTitleMaxChars } from '../constants'; +import type { QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; +import { ThemeIcon } from 'vscode'; +import { GlyphChars, quickPickTitleMaxChars } from '../constants'; +import { Commands } from '../constants.commands'; import { Container } from '../container'; -import type { PlusFeatures } from '../features'; +import type { FeatureAccess, RepoFeatureAccess } from '../features'; +import { PlusFeatures } from '../features'; import * as BranchActions from '../git/actions/branch'; import * as CommitActions from '../git/actions/commit'; import * as ContributorActions from '../git/actions/contributor'; @@ -16,18 +18,34 @@ import type { BranchSortOptions, GitBranch } from '../git/models/branch'; import { sortBranches } from '../git/models/branch'; import type { GitCommit, GitStashCommit } from '../git/models/commit'; import { isCommit, isStash } from '../git/models/commit'; -import type { GitContributor } from '../git/models/contributor'; +import type { ContributorQuickPickItem, GitContributor } from '../git/models/contributor'; +import { createContributorQuickPickItem, sortContributors } from '../git/models/contributor'; import type { GitLog } from '../git/models/log'; -import type { GitBranchReference, GitRevisionReference, GitTagReference } from '../git/models/reference'; -import { GitReference, GitRevision } from '../git/models/reference'; -import { GitRemote } from '../git/models/remote'; +import type { GitBranchReference, GitReference, GitRevisionReference, GitTagReference } from '../git/models/reference'; +import { + createReference, + createRevisionRange, + getReferenceLabel, + isBranchReference, + isRevisionRange, + isRevisionReference, + isStashReference, + isTagReference, +} from '../git/models/reference'; +import type { GitRemote } from '../git/models/remote'; +import { getHighlanderProviderName } from '../git/models/remote'; import { RemoteResourceType } from '../git/models/remoteResource'; import { Repository } from '../git/models/repository'; import type { GitStash } from '../git/models/stash'; import type { GitStatus } from '../git/models/status'; import type { GitTag, TagSortOptions } from '../git/models/tag'; import { sortTags } from '../git/models/tag'; -import type { GitWorktree } from '../git/models/worktree'; +import type { GitWorktree, WorktreeQuickPickItem } from '../git/models/worktree'; +import { createWorktreeQuickPickItem, getWorktreesByBranch, sortWorktrees } from '../git/models/worktree'; +import { remoteUrlRegex } from '../git/parsers/remoteParser'; +import { getApplicablePromo } from '../plus/gk/account/promos'; +import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../plus/gk/account/subscription'; +import type { LaunchpadCommandArgs } from '../plus/launchpad/launchpad'; import { CommitApplyFileChangesCommandQuickPickItem, CommitBrowseRepositoryFromHereCommandQuickPickItem, @@ -53,29 +71,27 @@ import { CommitOpenRevisionsCommandQuickPickItem, CommitRestoreFileChangesCommandQuickPickItem, OpenChangedFilesCommandQuickPickItem, + OpenOnlyChangedFilesCommandQuickPickItem, } from '../quickpicks/items/commits'; -import type { QuickPickSeparator } from '../quickpicks/items/common'; +import type { QuickPickItemOfT, QuickPickSeparator } from '../quickpicks/items/common'; import { CommandQuickPickItem, createQuickPickSeparator } from '../quickpicks/items/common'; import type { DirectiveQuickPickItem } from '../quickpicks/items/directive'; -import { createDirectiveQuickPickItem, Directive } from '../quickpicks/items/directive'; +import { createDirectiveQuickPickItem, Directive, isDirectiveQuickPickItem } from '../quickpicks/items/directive'; import type { BranchQuickPickItem, CommitQuickPickItem, - ContributorQuickPickItem, RemoteQuickPickItem, RepositoryQuickPickItem, TagQuickPickItem, - WorktreeQuickPickItem, } from '../quickpicks/items/gitCommands'; import { createBranchQuickPickItem, createCommitQuickPickItem, - createContributorQuickPickItem, createRefQuickPickItem, createRemoteQuickPickItem, createRepositoryQuickPickItem, + createStashQuickPickItem, createTagQuickPickItem, - createWorktreeQuickPickItem, GitCommandQuickPickItem, } from '../quickpicks/items/gitCommands'; import type { ReferencesQuickPickItem } from '../quickpicks/referencePicker'; @@ -83,16 +99,21 @@ import { CopyRemoteResourceCommandQuickPickItem, OpenRemoteResourceCommandQuickPickItem, } from '../quickpicks/remoteProviderPicker'; -import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../subscription'; -import { filterMap, intersection, isStringArray } from '../system/array'; -import { formatPath } from '../system/formatPath'; -import { map } from '../system/iterable'; +import { filterMap, filterMapAsync, intersection, isStringArray } from '../system/array'; +import { debounce } from '../system/function'; +import { first, map } from '../system/iterable'; +import { Logger } from '../system/logger'; import { getSettledValue } from '../system/promise'; import { pad, pluralize, truncate } from '../system/string'; -import { openWorkspace, OpenWorkspaceLocation } from '../system/utils'; +import { executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { formatPath } from '../system/vscode/formatPath'; +import { openWorkspace } from '../system/vscode/utils'; +import { getIconPathUris } from '../system/vscode/vscode'; import type { ViewsWithRepositoryFolders } from '../views/viewBase'; import type { AsyncStepResultGenerator, + CrossCommandReference, PartialStepState, QuickPickStep, StepResultGenerator, @@ -103,12 +124,23 @@ import { canInputStepContinue, canPickStepContinue, canStepContinue, + createCrossCommandReference, createInputStep, createPickStep, endSteps, - QuickCommandButtons, + isCrossCommandReference, StepResultBreak, } from './quickCommand'; +import { + LoadMoreQuickInputButton, + OpenChangesViewQuickInputButton, + OpenInNewWindowQuickInputButton, + PickCommitQuickInputButton, + RevealInSideBarQuickInputButton, + ShowDetailsViewQuickInputButton, + ShowTagsToggleQuickInputButton, +} from './quickCommand.buttons'; +import type { OpenWalkthroughCommandArgs } from './walkthroughs'; export function appendReposToTitle< State extends { repo: Repository } | { repos: Repository[] }, @@ -198,33 +230,49 @@ export async function getWorktrees( repoOrWorktrees: Repository | GitWorktree[], { buttons, + excludeOpened, filter, includeStatus, picked, }: { buttons?: QuickInputButton[]; + excludeOpened?: boolean; filter?: (t: GitWorktree) => boolean; includeStatus?: boolean; picked?: string | string[]; }, ): Promise { const worktrees = repoOrWorktrees instanceof Repository ? await repoOrWorktrees.getWorktrees() : repoOrWorktrees; - return Promise.all([ - ...worktrees - .filter(w => filter == null || filter(w)) - .map(async w => - createWorktreeQuickPickItem( - w, - picked != null && - (typeof picked === 'string' ? w.uri.toString() === picked : picked.includes(w.uri.toString())), - { - buttons: buttons, - path: true, - status: includeStatus ? await w.getStatus() : undefined, - }, - ), - ), - ]); + + const items = await filterMapAsync(worktrees, async w => { + if ((excludeOpened && w.opened) || filter?.(w) === false) return undefined; + + let missing = false; + let status; + if (includeStatus) { + try { + status = await w.getStatus(); + } catch (ex) { + Logger.error(ex, `Worktree status failed: ${w.uri.toString(true)}`); + missing = true; + } + } + + return createWorktreeQuickPickItem( + w, + picked != null && + (typeof picked === 'string' ? w.uri.toString() === picked : picked.includes(w.uri.toString())), + missing, + { + buttons: buttons, + includeStatus: includeStatus, + path: true, + status: status, + }, + ); + }); + + return sortWorktrees(items); } export async function getBranchesAndOrTags( @@ -244,6 +292,8 @@ export async function getBranchesAndOrTags( ): Promise<(BranchQuickPickItem | TagQuickPickItem)[]> { if (repos == null) return []; + let worktreesByBranch: Map | undefined; + let branches: GitBranch[] | undefined; let tags: GitTag[] | undefined; @@ -253,7 +303,8 @@ export async function getBranchesAndOrTags( const repo = repos instanceof Repository ? repos : repos[0]; // TODO@eamodio handle paging - const [branchesResult, tagsResult] = await Promise.allSettled([ + const [worktreesByBranchResult, branchesResult, tagsResult] = await Promise.allSettled([ + include.includes('branches') ? getWorktreesByBranch(repo) : undefined, include.includes('branches') ? repo.getBranches({ filter: filter?.branches, @@ -263,11 +314,13 @@ export async function getBranchesAndOrTags( include.includes('tags') ? repo.getTags({ filter: filter?.tags, sort: true }) : undefined, ]); + worktreesByBranch = getSettledValue(worktreesByBranchResult); branches = getSettledValue(branchesResult)?.values ?? []; tags = getSettledValue(tagsResult)?.values ?? []; } else { // TODO@eamodio handle paging - const [branchesByRepoResult, tagsByRepoResult] = await Promise.allSettled([ + const [worktreesByBranchResult, branchesByRepoResult, tagsByRepoResult] = await Promise.allSettled([ + include.includes('branches') ? getWorktreesByBranch(repos) : undefined, include.includes('branches') ? Promise.allSettled( repos.map(r => @@ -287,6 +340,7 @@ export async function getBranchesAndOrTags( : undefined, ]); + worktreesByBranch = getSettledValue(worktreesByBranchResult); const branchesByRepo = branchesByRepoResult.status === 'fulfilled' ? branchesByRepoResult.value @@ -329,6 +383,7 @@ export async function getBranchesAndOrTags( ref: singleRepo, status: singleRepo, type: 'remote', + worktree: worktreesByBranch?.has(b.id), }, ), ), @@ -382,6 +437,7 @@ export async function getBranchesAndOrTags( current: singleRepo ? 'checkmark' : false, ref: singleRepo, status: singleRepo, + worktree: worktreesByBranch?.has(b.id), }, ), ), @@ -438,7 +494,7 @@ export function getValidateGitReferenceFn( repos = repos[0]; } - if (inRefMode && options?.ranges && GitRevision.isRange(value)) { + if (inRefMode && options?.ranges && isRevisionRange(value)) { quickpick.items = [ createRefQuickPickItem(value, repos.path, true, { alwaysShow: true, @@ -475,11 +531,11 @@ export function getValidateGitReferenceFn( const commit = await Container.instance.git.getCommit(repos.path, value); quickpick.items = [ - createCommitQuickPickItem(commit!, true, { + await createCommitQuickPickItem(commit!, true, { alwaysShow: true, buttons: options?.buttons, compact: true, - icon: true, + icon: 'avatar', }), ]; return true; @@ -492,13 +548,13 @@ export async function* inputBranchNameStep< >( state: State, context: Context, - options: { placeholder: string; titleContext?: string; value?: string }, + options: { placeholder?: string; prompt?: string; titleContext?: string; value?: string }, ): AsyncStepResultGenerator { const step = createInputStep({ title: appendReposToTitle(`${context.title}${options.titleContext ?? ''}`, state, context), - placeholder: options.placeholder, + placeholder: options.placeholder ?? 'Branch name', value: options.value, - prompt: 'Enter branch name', + prompt: options.prompt ?? 'Please provide a new branch name', validate: async (value: string | undefined): Promise<[boolean, string | undefined]> => { if (value == null) return [false, undefined]; @@ -507,7 +563,16 @@ export async function* inputBranchNameStep< if ('repo' in state) { const valid = await Container.instance.git.validateBranchOrTagName(state.repo.path, value); - return [valid, valid ? undefined : `'${value}' isn't a valid branch name`]; + if (!valid) { + return [false, `'${value}' isn't a valid branch name`]; + } + + const alreadyExists = await state.repo.getBranch(value); + if (alreadyExists) { + return [false, `A branch named '${value}' already exists`]; + } + + return [true, undefined]; } let valid = true; @@ -517,6 +582,11 @@ export async function* inputBranchNameStep< if (!valid) { return [false, `'${value}' isn't a valid branch name`]; } + + const alreadyExists = await repo.getBranch(value); + if (alreadyExists) { + return [false, `A branch named '${value}' already exists`]; + } } return [true, undefined]; @@ -556,7 +626,7 @@ export async function* inputRemoteNameStep< if ('repo' in state) { const alreadyExists = (await state.repo.getRemotes({ filter: r => r.name === value })).length !== 0; if (alreadyExists) { - return [false, `Remote named '${value}' already exists`]; + return [false, `A remote named '${value}' already exists`]; } } @@ -591,7 +661,7 @@ export async function* inputRemoteUrlStep< value = value.trim(); if (value.length === 0) return [false, 'Please enter a valid remote URL']; - const valid = /^(https?|git|ssh|rsync):\/\//.test(value); + const valid = remoteUrlRegex.test(value); return [valid, valid ? undefined : `'${value}' isn't a valid remote URL`]; }, }); @@ -649,7 +719,7 @@ export async function* inputTagNameStep< return value; } -export async function* pickBranchStep< +export function* pickBranchStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; showTags?: boolean; title: string }, >( @@ -666,42 +736,42 @@ export async function* pickBranchStep< placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const branches = await getBranches(state.repo, { - buttons: [QuickCommandButtons.RevealInSideBar], +): StepResultGenerator { + const items = getBranches(state.repo, { + buttons: [RevealInSideBarQuickInputButton], filter: filter, picked: picked, - }); + }).then(branches => + branches.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : branches, + ); const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: branches.length === 0 ? `No branches found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No branches found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - branches.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : branches, - onDidClickItemButton: (quickpick, button, { item }) => { - if (button === QuickCommandButtons.RevealInSideBar) { + items: items, + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === RevealInSideBarQuickInputButton) { void BranchActions.reveal(item, { select: true, focus: false, expand: true }); } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await BranchActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await BranchActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } -export async function* pickBranchesStep< +export function* pickBranchesStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; showTags?: boolean; title: string }, >( @@ -711,53 +781,56 @@ export async function* pickBranchesStep< filter, picked, placeholder, + emptyPlaceholder, sort, titleContext, }: { filter?: (b: GitBranch) => boolean; picked?: string | string[]; placeholder: string; + emptyPlaceholder?: string; sort?: BranchSortOptions; titleContext?: string; }, -): AsyncStepResultGenerator { - const branches = await getBranches(state.repo, { - buttons: [QuickCommandButtons.RevealInSideBar], +): StepResultGenerator { + const items = getBranches(state.repo, { + buttons: [RevealInSideBarQuickInputButton], filter: filter, picked: picked, sort: sort, - }); + }).then(branches => + !branches.length + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : branches, + ); const step = createPickStep({ - multiselect: branches.length !== 0, + multiselect: true, title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: branches.length === 0 ? `No branches found in ${state.repo.formattedName}` : placeholder, + placeholder: count => + !count ? emptyPlaceholder ?? `No branches found in ${state.repo.formattedName}` : placeholder, matchOnDetail: true, - items: - branches.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : branches, - onDidClickItemButton: (quickpick, button, { item }) => { - if (button === QuickCommandButtons.RevealInSideBar) { + items: items, + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === RevealInSideBarQuickInputButton) { void BranchActions.reveal(item, { select: true, focus: false, expand: true }); } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await BranchActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await BranchActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } -export async function* pickBranchOrTagStep< +export function* pickBranchOrTagStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; pickCommitForItem?: boolean; showTags?: boolean; title: string }, >( @@ -780,53 +853,54 @@ export async function* pickBranchOrTagStep< additionalButtons?: QuickInputButton[]; ranges?: boolean; }, -): AsyncStepResultGenerator { +): StepResultGenerator { context.showTags = true; - const showTagsButton = new QuickCommandButtons.ShowTagsToggle(context.showTags); + const showTagsButton = new ShowTagsToggleQuickInputButton(context.showTags); const getBranchesAndOrTagsFn = async () => { return getBranchesAndOrTags(state.repo, context.showTags ? ['branches', 'tags'] : ['branches'], { buttons: typeof context.pickCommitForItem === 'boolean' - ? [QuickCommandButtons.PickCommit, QuickCommandButtons.RevealInSideBar] - : [QuickCommandButtons.RevealInSideBar], + ? [PickCommitQuickInputButton, RevealInSideBarQuickInputButton] + : [RevealInSideBarQuickInputButton], filter: filter, picked: picked, sort: true, }); }; - const branchesAndOrTags = await getBranchesAndOrTagsFn(); + const items = getBranchesAndOrTagsFn().then(branchesAndOrTags => + branchesAndOrTags.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : branchesAndOrTags, + ); const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: - branchesAndOrTags.length === 0 + placeholder: count => + !count ? `No branches${context.showTags ? ' or tags' : ''} found in ${state.repo.formattedName}` - : `${typeof placeholder === 'string' ? placeholder : placeholder(context)}${GlyphChars.Space.repeat( - 3, - )}(or enter a reference using #)`, + : `${ + typeof placeholder === 'string' ? placeholder : placeholder(context) + } (or enter a revision using #)`, matchOnDescription: true, matchOnDetail: true, value: value, selectValueWhenShown: true, - items: - branchesAndOrTags.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : branchesAndOrTags, + items: items, additionalButtons: [...(additionalButtons ?? []), showTagsButton], - onDidClickItemButton: (quickpick, button, { item }) => { - if (button === QuickCommandButtons.PickCommit) { + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === PickCommitQuickInputButton) { context.pickCommitForItem = true; return true; } - if (button === QuickCommandButtons.RevealInSideBar) { - if (GitReference.isBranch(item)) { + if (button === RevealInSideBarQuickInputButton) { + if (isBranchReference(item)) { void BranchActions.reveal(item, { select: true, focus: false, expand: true }); - } else if (GitReference.isTag(item)) { + } else if (isTagReference(item)) { void TagActions.reveal(item, { select: true, focus: false, expand: true }); - } else if (GitReference.isRevision(item)) { + } else if (isRevisionReference(item)) { void CommitActions.showDetailsView(item, { pin: false, preserveFocus: true }); } } @@ -846,7 +920,7 @@ export async function* pickBranchOrTagStep< ? `${state.repo.formattedName} has no branches${context.showTags ? ' or tags' : ''}` : `${ typeof placeholder === 'string' ? placeholder : placeholder(context) - }${GlyphChars.Space.repeat(3)}(or enter a reference using #)`; + } (or enter a revision using #)`; quickpick.items = branchesAndOrTags; } finally { quickpick.busy = false; @@ -854,87 +928,110 @@ export async function* pickBranchOrTagStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: quickpick => { - if (quickpick.activeItems.length === 0) return; - - const item = quickpick.activeItems[0].item; - if (GitReference.isBranch(item)) { + onDidPressKey: (_quickpick, _key, { item }) => { + if (isBranchReference(item)) { void BranchActions.reveal(item, { select: true, focus: false, expand: true }); - } else if (GitReference.isTag(item)) { + } else if (isTagReference(item)) { void TagActions.reveal(item, { select: true, focus: false, expand: true }); - } else if (GitReference.isRevision(item)) { + } else if (isRevisionReference(item)) { void CommitActions.showDetailsView(item, { pin: false, preserveFocus: true }); } }, onValidateValue: getValidateGitReferenceFn(state.repo, { ranges: ranges }), }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } -export async function* pickBranchOrTagStepMultiRepo< +export function* pickBranchOrTagStepMultiRepo< State extends StepState & { repos: Repository[]; reference?: GitReference }, - Context extends { repos: Repository[]; showTags?: boolean; title: string }, + Context extends { allowCreate?: boolean; repos: Repository[]; showTags?: boolean; title: string }, >( state: State, context: Context, { + allowCreate, filter, picked, placeholder, titleContext, value, }: { + allowCreate?: boolean; filter?: { branches?: (b: GitBranch) => boolean; tags?: (t: GitTag) => boolean }; picked?: string | string[]; placeholder: string | ((context: Context) => string); titleContext?: string; value?: string; }, -): AsyncStepResultGenerator { +): StepResultGenerator { context.showTags = state.repos.length === 1; - const showTagsButton = new QuickCommandButtons.ShowTagsToggle(context.showTags); + const showTagsButton = new ShowTagsToggleQuickInputButton(context.showTags); + + const createNewBranchItem: QuickPickItem & { item: string } = { + label: 'Create New Branch...', + iconPath: new ThemeIcon('plus'), + alwaysShow: true, + item: '', + }; + + const choosePullRequestItem: QuickPickItemOfT = { + label: 'Choose a Pull Request...', + iconPath: new ThemeIcon('git-pull-request'), + alwaysShow: true, + item: createCrossCommandReference>(Commands.ShowLaunchpad, { + source: 'git-commands', + }), + }; const getBranchesAndOrTagsFn = () => { return getBranchesAndOrTags(state.repos, context.showTags ? ['branches', 'tags'] : ['branches'], { - buttons: [QuickCommandButtons.RevealInSideBar], + buttons: [RevealInSideBarQuickInputButton], // Filter out remote branches if we are going to affect multiple repos filter: { branches: state.repos.length === 1 ? undefined : b => !b.remote, ...filter }, picked: picked ?? state.reference?.ref, - sort: { branches: { orderBy: BranchSorting.DateDesc }, tags: { orderBy: TagSorting.DateDesc } }, + sort: { branches: { orderBy: 'date:desc' }, tags: { orderBy: 'date:desc' } }, }); }; - const branchesAndOrTags = await getBranchesAndOrTagsFn(); + const items = getBranchesAndOrTagsFn().then(branchesAndOrTags => + branchesAndOrTags.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : allowCreate + ? [createNewBranchItem, choosePullRequestItem, ...branchesAndOrTags] + : [choosePullRequestItem, ...branchesAndOrTags], + ); - const step = createPickStep({ + const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: - branchesAndOrTags.length === 0 + placeholder: count => + !count ? `No ${state.repos.length === 1 ? '' : 'common '}branches${ context.showTags ? ' or tags' : '' - } found in ${ - state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repositories` - }` - : `${typeof placeholder === 'string' ? placeholder : placeholder(context)}${GlyphChars.Space.repeat( - 3, - )}(or enter a reference using #)`, + } found in ${state.repos.length === 1 ? state.repos[0].formattedName : `${state.repos.length} repos`}` + : `${ + typeof placeholder === 'string' ? placeholder : placeholder(context) + } (or enter a revision using #)`, matchOnDescription: true, matchOnDetail: true, - value: value ?? (GitReference.isRevision(state.reference) ? state.reference.ref : undefined), + value: value ?? (isRevisionReference(state.reference) ? state.reference.ref : undefined), selectValueWhenShown: true, - items: - branchesAndOrTags.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : branchesAndOrTags, + items: items, additionalButtons: [showTagsButton], - onDidClickItemButton: (quickpick, button, { item }) => { - if (button === QuickCommandButtons.RevealInSideBar) { - if (GitReference.isBranch(item)) { + onDidChangeValue: quickpick => { + createNewBranchItem.item = quickpick.value; + return true; + }, + onDidClickItemButton: (_quickpick, button, { item }) => { + if (typeof item === 'string' || isCrossCommandReference(item)) return; + + if (button === RevealInSideBarQuickInputButton) { + if (isBranchReference(item)) { void BranchActions.reveal(item, { select: true, focus: false, expand: true }); - } else if (GitReference.isTag(item)) { + } else if (isTagReference(item)) { void TagActions.reveal(item, { select: true, focus: false, expand: true }); - } else if (GitReference.isRevision(item)) { + } else if (isRevisionReference(item)) { void CommitActions.showDetailsView(item, { pin: false, preserveFocus: true }); } } @@ -955,11 +1052,11 @@ export async function* pickBranchOrTagStepMultiRepo< } found in ${ state.repos.length === 1 ? state.repos[0].formattedName - : `${state.repos.length} repositories` + : `${state.repos.length} repos` }` : `${ typeof placeholder === 'string' ? placeholder : placeholder(context) - }${GlyphChars.Space.repeat(3)}(or enter a reference using #)`; + } (or enter a revision using #)`; quickpick.items = branchesAndOrTags; } finally { quickpick.busy = false; @@ -967,15 +1064,14 @@ export async function* pickBranchOrTagStepMultiRepo< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: quickpick => { - if (quickpick.activeItems.length === 0) return; + onDidPressKey: (_quickpick, _key, { item }) => { + if (typeof item === 'string' || isCrossCommandReference(item)) return; - const item = quickpick.activeItems[0].item; - if (GitReference.isBranch(item)) { + if (isBranchReference(item)) { void BranchActions.reveal(item, { select: true, focus: false, expand: true }); - } else if (GitReference.isTag(item)) { + } else if (isTagReference(item)) { void TagActions.reveal(item, { select: true, focus: false, expand: true }); - } else if (GitReference.isRevision(item)) { + } else if (isRevisionReference(item)) { void CommitActions.showDetailsView(item, { pin: false, preserveFocus: true }); } }, @@ -1010,31 +1106,50 @@ export async function* pickCommitStep< showInSideBarCommand?: CommandQuickPickItem; showInSideBarButton?: { button: QuickInputButton; - onDidClick: (items: Readonly[]>) => void; + onDidClick: (items: Readonly) => void; }; titleContext?: string; }, ): AsyncStepResultGenerator { - function getItems(log: GitLog | undefined) { - return log == null - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : [ - ...map(log.commits.values(), commit => - createCommitQuickPickItem( - commit, - picked != null && - (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), - { - buttons: [QuickCommandButtons.ShowDetailsView, QuickCommandButtons.RevealInSideBar], - compact: true, - icon: true, - }, - ), - ), - ...(log?.hasMore ? [createDirectiveQuickPickItem(Directive.LoadMore)] : []), - ]; + async function getItems(log: GitLog | undefined) { + if (log == null) { + return [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)]; + } + + const buttons = [ShowDetailsViewQuickInputButton, RevealInSideBarQuickInputButton]; + + // If these are "file" commits, then add an Open Changes button + if (first(log.commits)?.[1].file != null) { + buttons.splice(0, 0, OpenChangesViewQuickInputButton); + } + + const items = []; + + for await (const item of map(log.commits.values(), async commit => + createCommitQuickPickItem( + commit, + picked != null && (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), + { + buttons: buttons, + compact: true, + icon: 'avatar', + }, + ), + )) { + items.push(item); + } + + if (log.hasMore) { + items.push(createDirectiveQuickPickItem(Directive.LoadMore)); + } + + return items; } + const items = getItems(log).then(items => + showInSideBarCommand != null ? [showInSideBarCommand, ...items] : items, + ); + const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), placeholder: typeof placeholder === 'string' ? placeholder : placeholder(context, log), @@ -1043,7 +1158,7 @@ export async function* pickCommitStep< matchOnDetail: true, value: typeof picked === 'string' && log?.count === 0 ? picked : undefined, selectValueWhenShown: true, - items: showInSideBarCommand != null ? [showInSideBarCommand, ...getItems(log)] : getItems(log), + items: items, onDidLoadMore: async quickpick => { quickpick.keepScrollPosition = true; log = await log?.more?.(configuration.get('advanced.maxListItems')); @@ -1055,30 +1170,37 @@ export async function* pickCommitStep< }, additionalButtons: [ ...(showInSideBar?.button != null ? [showInSideBar?.button] : []), - ...(log?.hasMore ? [QuickCommandButtons.LoadMore] : []), + ...(log?.hasMore ? [LoadMoreQuickInputButton] : []), ], - onDidClickItemButton: (quickpick, button, item) => { + onDidClickItemButton: (_quickpick, button, item) => { if (CommandQuickPickItem.is(item)) return; switch (button) { - case QuickCommandButtons.ShowDetailsView: + case ShowDetailsViewQuickInputButton: void CommitActions.showDetailsView(item.item, { pin: false, preserveFocus: true }); break; - case QuickCommandButtons.RevealInSideBar: + case RevealInSideBarQuickInputButton: void CommitActions.reveal(item.item, { select: true, focus: false, expand: true, }); break; + case OpenChangesViewQuickInputButton: { + const path = item.item.file?.path; + if (path != null) { + void CommitActions.openChanges(path, item.item); + } + break; + } } }, onDidClickButton: (quickpick, button) => { if (log == null) return; - const items = quickpick.activeItems.filter>( - (i): i is CommitQuickPickItem => !CommandQuickPickItem.is(i), + const items = quickpick.activeItems.filter( + (i): i is CommitQuickPickItem => !CommandQuickPickItem.is(i), ); if (button === showInSideBar?.button) { @@ -1087,10 +1209,8 @@ export async function* pickCommitStep< }, keys: ['right', 'alt+right', 'ctrl+right'], onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - - const items = quickpick.activeItems.filter>( - (i): i is CommitQuickPickItem => !CommandQuickPickItem.is(i), + const items = quickpick.activeItems.filter( + (i): i is CommitQuickPickItem => !CommandQuickPickItem.is(i), ); if (key === 'ctrl+right') { @@ -1104,9 +1224,10 @@ export async function* pickCommitStep< } }, onValidateValue: getValidateGitReferenceFn(state.repo, { - buttons: [QuickCommandButtons.ShowDetailsView, QuickCommandButtons.RevealInSideBar], + buttons: [ShowDetailsViewQuickInputButton, RevealInSideBarQuickInputButton], }), }); + const selection: StepSelection = yield step; if (!canPickStepContinue(step, state, selection)) return StepResultBreak; @@ -1140,25 +1261,32 @@ export function* pickCommitsStep< titleContext?: string; }, ): StepResultGenerator { - function getItems(log: GitLog | undefined) { - return log == null - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : [ - ...map(log.commits.values(), commit => - createCommitQuickPickItem( - commit, - picked != null && - (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), - { - buttons: [QuickCommandButtons.ShowDetailsView, QuickCommandButtons.RevealInSideBar], - compact: true, - icon: true, - }, - ), - ), - // Since this is multi-select, we can't have a "Load more" item - // ...(log?.hasMore ? [DirectiveQuickPickItem.create(Directive.LoadMore)] : []), - ]; + async function getItems(log: GitLog | undefined) { + if (log == null) + return [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)]; + + const items = []; + + for await (const item of map(log.commits.values(), async commit => + createCommitQuickPickItem( + commit, + picked != null && (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), + { + buttons: [ShowDetailsViewQuickInputButton, RevealInSideBarQuickInputButton], + compact: true, + icon: 'avatar', + }, + ), + )) { + items.push(item); + } + + // Since this is multi-select, we can't have a "Load more" item + // if (log.hasMore) { + // items.push(createDirectiveQuickPickItem(Directive.LoadMore)); + // } + + return items; } const step = createPickStep({ @@ -1177,14 +1305,14 @@ export function* pickCommitsStep< } return getItems(log); }, - additionalButtons: [...(log?.hasMore ? [QuickCommandButtons.LoadMore] : [])], - onDidClickItemButton: (quickpick, button, { item }) => { + additionalButtons: [...(log?.hasMore ? [LoadMoreQuickInputButton] : [])], + onDidClickItemButton: (_quickpick, button, { item }) => { switch (button) { - case QuickCommandButtons.ShowDetailsView: + case ShowDetailsViewQuickInputButton: void CommitActions.showDetailsView(item, { pin: false, preserveFocus: true }); break; - case QuickCommandButtons.RevealInSideBar: + case RevealInSideBarQuickInputButton: void CommitActions.reveal(item, { select: true, focus: false, @@ -1194,16 +1322,14 @@ export function* pickCommitsStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - + onDidPressKey: async (_quickpick, key, { item }) => { if (key === 'ctrl+right') { - void CommitActions.showDetailsView(quickpick.activeItems[0].item, { + void CommitActions.showDetailsView(item, { pin: false, preserveFocus: true, }); } else { - await CommitActions.reveal(quickpick.activeItems[0].item, { + await CommitActions.reveal(item, { select: true, focus: false, expand: true, @@ -1211,19 +1337,30 @@ export function* pickCommitsStep< } }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } -export async function* pickContributorsStep< +export function* pickContributorsStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; title: string }, ->( - state: State, - context: Context, - placeholder: string = 'Choose contributors', -): AsyncStepResultGenerator { - const message = (await Container.instance.git.getOrOpenScmRepository(state.repo.path))?.inputBox.value; +>(state: State, context: Context, placeholder: string = 'Choose contributors'): StepResultGenerator { + async function getItems() { + const message = (await Container.instance.git.getOrOpenScmRepository(state.repo.path))?.inputBox.value; + + const items = []; + + for (const c of await Container.instance.git.getContributors(state.repo.path)) { + items.push( + await createContributorQuickPickItem(c, message?.includes(c.getCoauthor()), { + buttons: [RevealInSideBarQuickInputButton], + }), + ); + } + + return sortContributors(items); + } const step = createPickStep({ title: appendReposToTitle(context.title, state, context), @@ -1231,32 +1368,46 @@ export async function* pickContributorsStep< multiselect: true, placeholder: placeholder, matchOnDescription: true, - items: (await Container.instance.git.getContributors(state.repo.path)).map(c => - createContributorQuickPickItem(c, message?.includes(c.getCoauthor()), { - buttons: [QuickCommandButtons.RevealInSideBar], - }), - ), - onDidClickItemButton: (quickpick, button, { item }) => { - if (button === QuickCommandButtons.RevealInSideBar) { + items: getItems(), + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === RevealInSideBarQuickInputButton) { void ContributorActions.reveal(item, { select: true, focus: false, expand: true }); } }, - keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: quickpick => { - if (quickpick.activeItems.length === 0) return; + onDidChangeSelection: debounce((quickpick, e) => { + if (!quickpick.canSelectMany || quickpick.busy) return; + + let update = false; + for (const item of quickpick.items) { + if (isDirectiveQuickPickItem(item)) continue; - void ContributorActions.reveal(quickpick.activeItems[0].item, { + const picked = e.includes(item); + if (item.picked !== picked || item.alwaysShow !== picked) { + item.alwaysShow = item.picked = picked; + update = true; + } + } + + if (update) { + quickpick.items = sortContributors([...quickpick.items]); + quickpick.selectedItems = e; + } + }, 10), + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: (_quickpick, _key, { item }) => { + void ContributorActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } -export async function* pickRemoteStep< +export function* pickRemoteStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; title: string }, >( @@ -1273,42 +1424,42 @@ export async function* pickRemoteStep< placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const remotes = await getRemotes(state.repo, { - buttons: [QuickCommandButtons.RevealInSideBar], +): StepResultGenerator { + const items = getRemotes(state.repo, { + buttons: [RevealInSideBarQuickInputButton], filter: filter, picked: picked, - }); + }).then(remotes => + remotes.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : remotes, + ); const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: remotes.length === 0 ? `No remotes found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No remotes found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - remotes.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : remotes, - onDidClickItemButton: (quickpick, button, { item }) => { - if (button === QuickCommandButtons.RevealInSideBar) { + items: items, + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === RevealInSideBarQuickInputButton) { void RemoteActions.reveal(item, { select: true, focus: false, expand: true }); } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await RemoteActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await RemoteActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } -export async function* pickRemotesStep< +export function* pickRemotesStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; title: string }, >( @@ -1325,38 +1476,39 @@ export async function* pickRemotesStep< placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const remotes = await getRemotes(state.repo, { - buttons: [QuickCommandButtons.RevealInSideBar], +): StepResultGenerator { + const items = getRemotes(state.repo, { + buttons: [RevealInSideBarQuickInputButton], filter: filter, picked: picked, - }); + }).then(remotes => + remotes.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : remotes, + ); const step = createPickStep({ - multiselect: remotes.length !== 0, + multiselect: true, title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: remotes.length === 0 ? `No remotes found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No remotes found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - remotes.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : remotes, - onDidClickItemButton: (quickpick, button, { item }) => { - if (button === QuickCommandButtons.RevealInSideBar) { + items: items, + + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === RevealInSideBarQuickInputButton) { void RemoteActions.reveal(item, { select: true, focus: false, expand: true }); } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await RemoteActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await RemoteActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } @@ -1377,18 +1529,18 @@ export async function* pickRepositoryStep< items: context.repos.length === 0 ? [createDirectiveQuickPickItem(Directive.Cancel)] - : await Promise.all( + : Promise.all( context.repos.map(r => createRepositoryQuickPickItem(r, r.id === active?.id, { branch: true, - buttons: [QuickCommandButtons.RevealInSideBar], + buttons: [RevealInSideBarQuickInputButton], fetched: true, status: true, }), ), ), - onDidClickItemButton: (quickpick, button, { item }) => { - if (button === QuickCommandButtons.RevealInSideBar) { + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === RevealInSideBarQuickInputButton) { void RepositoryActions.reveal(item.path, context.associatedView, { select: true, focus: false, @@ -1397,16 +1549,15 @@ export async function* pickRepositoryStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: quickpick => { - if (quickpick.activeItems.length === 0) return; - - void RepositoryActions.reveal(quickpick.activeItems[0].item.path, context.associatedView, { + onDidPressKey: (_quickpick, _key, { item }) => { + void RepositoryActions.reveal(item.path, context.associatedView, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } @@ -1443,22 +1594,22 @@ export async function* pickRepositoriesStep< items: context.repos.length === 0 ? [createDirectiveQuickPickItem(Directive.Cancel)] - : await Promise.all( + : Promise.all( context.repos.map(repo => createRepositoryQuickPickItem( repo, actives.some(r => r.id === repo.id), { branch: true, - buttons: [QuickCommandButtons.RevealInSideBar], + buttons: [RevealInSideBarQuickInputButton], fetched: true, status: true, }, ), ), ), - onDidClickItemButton: (quickpick, button, { item }) => { - if (button === QuickCommandButtons.RevealInSideBar) { + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === RevealInSideBarQuickInputButton) { void RepositoryActions.reveal(item.path, context.associatedView, { select: true, focus: false, @@ -1467,16 +1618,15 @@ export async function* pickRepositoriesStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: quickpick => { - if (quickpick.activeItems.length === 0) return; - - void RepositoryActions.reveal(quickpick.activeItems[0].item.path, context.associatedView, { + onDidPressKey: (_quickpick, _key, { item }) => { + void RepositoryActions.reveal(item.path, context.associatedView, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } @@ -1512,12 +1662,12 @@ export function* pickStashStep< ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] : [ ...map(stash.commits.values(), commit => - createCommitQuickPickItem( + createStashQuickPickItem( commit, picked != null && (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), { - buttons: [QuickCommandButtons.ShowDetailsView], + buttons: [ShowDetailsViewQuickInputButton], compact: true, icon: true, }, @@ -1525,22 +1675,80 @@ export function* pickStashStep< ), ], onDidClickItemButton: (_quickpick, button, { item }) => { - if (button === QuickCommandButtons.ShowDetailsView) { + if (button === ShowDetailsViewQuickInputButton) { void StashActions.showDetailsView(item, { pin: false, preserveFocus: true }); } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await StashActions.showDetailsView(quickpick.activeItems[0].item, { pin: false, preserveFocus: true }); + onDidPressKey: async (_quickpick, _key, { item }) => { + await StashActions.showDetailsView(item, { pin: false, preserveFocus: true }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } -export async function* pickTagsStep< +export function* pickStashesStep< + State extends PartialStepState & { repo: Repository }, + Context extends { repos: Repository[]; title: string }, +>( + state: State, + context: Context, + { + ignoreFocusOut, + stash, + picked, + placeholder, + titleContext, + }: { + ignoreFocusOut?: boolean; + stash: GitStash | undefined; + picked: string | string[] | undefined; + placeholder: string | ((context: Context, stash: GitStash | undefined) => string); + titleContext?: string; + }, +): StepResultGenerator { + const step = createPickStep>({ + title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), + multiselect: true, + placeholder: typeof placeholder === 'string' ? placeholder : placeholder(context, stash), + ignoreFocusOut: ignoreFocusOut, + matchOnDescription: true, + matchOnDetail: true, + items: + stash == null + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : [ + ...map(stash.commits.values(), commit => + createStashQuickPickItem( + commit, + picked != null && + (typeof picked === 'string' ? commit.ref === picked : picked.includes(commit.ref)), + { + buttons: [ShowDetailsViewQuickInputButton], + compact: true, + icon: true, + }, + ), + ), + ], + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === ShowDetailsViewQuickInputButton) { + void StashActions.showDetailsView(item, { pin: false, preserveFocus: true }); + } + }, + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: async (_quickpick, _key, { item }) => { + await StashActions.showDetailsView(item, { pin: false, preserveFocus: true }); + }, + }); + + const selection: StepSelection = yield step; + return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; +} + +export function* pickTagsStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; showTags?: boolean; title: string }, >( @@ -1557,24 +1765,25 @@ export async function* pickTagsStep< placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const tags = await getTags(state.repo, { - buttons: [QuickCommandButtons.RevealInSideBar], +): StepResultGenerator { + const items = getTags(state.repo, { + buttons: [RevealInSideBarQuickInputButton], filter: filter, picked: picked, - }); + }).then(tags => + tags.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : tags, + ); const step = createPickStep({ - multiselect: tags.length !== 0, + multiselect: true, title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: tags.length === 0 ? `No tags found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No tags found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - tags.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : tags, - onDidClickItemButton: (quickpick, button, { item }) => { - if (button === QuickCommandButtons.RevealInSideBar) { + items: items, + onDidClickItemButton: (_quickpick, button, { item }) => { + if (button === RevealInSideBarQuickInputButton) { void TagActions.reveal(item, { select: true, focus: false, @@ -1583,166 +1792,171 @@ export async function* pickTagsStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await TagActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await TagActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } -export async function* pickWorktreeStep< +export function* pickWorktreeStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; title: string; worktrees?: GitWorktree[] }, >( state: State, context: Context, { + excludeOpened, filter, includeStatus, picked, placeholder, titleContext, }: { + excludeOpened?: boolean; filter?: (b: GitWorktree) => boolean; includeStatus?: boolean; picked?: string | string[]; placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const worktrees = await getWorktrees(context.worktrees ?? state.repo, { - buttons: [QuickCommandButtons.OpenInNewWindow, QuickCommandButtons.RevealInSideBar], +): StepResultGenerator { + const items = getWorktrees(context.worktrees ?? state.repo, { + buttons: [OpenInNewWindowQuickInputButton, RevealInSideBarQuickInputButton], + excludeOpened: excludeOpened, filter: filter, includeStatus: includeStatus, picked: picked, - }); + }).then(worktrees => + worktrees.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : worktrees, + ); const step = createPickStep({ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: worktrees.length === 0 ? `No worktrees found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No worktrees found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - worktrees.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : worktrees, - onDidClickItemButton: (quickpick, button, { item }) => { + items: items, + onDidClickItemButton: (_quickpick, button, { item }) => { switch (button) { - case QuickCommandButtons.OpenInNewWindow: - openWorkspace(item.uri, { location: OpenWorkspaceLocation.NewWindow }); + case OpenInNewWindowQuickInputButton: + openWorkspace(item.uri, { location: 'newWindow' }); break; - case QuickCommandButtons.RevealInSideBar: + case RevealInSideBarQuickInputButton: void WorktreeActions.reveal(item, { select: true, focus: false, expand: true }); break; } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await WorktreeActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await WorktreeActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; } -export async function* pickWorktreesStep< +export function* pickWorktreesStep< State extends PartialStepState & { repo: Repository }, Context extends { repos: Repository[]; title: string; worktrees?: GitWorktree[] }, >( state: State, context: Context, { + excludeOpened, filter, includeStatus, picked, placeholder, titleContext, }: { + excludeOpened?: boolean; filter?: (b: GitWorktree) => boolean; includeStatus?: boolean; picked?: string | string[]; placeholder: string; titleContext?: string; }, -): AsyncStepResultGenerator { - const worktrees = await getWorktrees(context.worktrees ?? state.repo, { - buttons: [QuickCommandButtons.OpenInNewWindow, QuickCommandButtons.RevealInSideBar], +): StepResultGenerator { + const items = getWorktrees(context.worktrees ?? state.repo, { + buttons: [OpenInNewWindowQuickInputButton, RevealInSideBarQuickInputButton], + excludeOpened: excludeOpened, filter: filter, includeStatus: includeStatus, picked: picked, - }); + }).then(worktrees => + worktrees.length === 0 + ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] + : worktrees, + ); const step = createPickStep({ - multiselect: worktrees.length !== 0, + multiselect: true, title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context), - placeholder: worktrees.length === 0 ? `No worktrees found in ${state.repo.formattedName}` : placeholder, + placeholder: count => (!count ? `No worktrees found in ${state.repo.formattedName}` : placeholder), matchOnDetail: true, - items: - worktrees.length === 0 - ? [createDirectiveQuickPickItem(Directive.Back, true), createDirectiveQuickPickItem(Directive.Cancel)] - : worktrees, - onDidClickItemButton: (quickpick, button, { item }) => { + items: items, + onDidClickItemButton: (_quickpick, button, { item }) => { switch (button) { - case QuickCommandButtons.OpenInNewWindow: - openWorkspace(item.uri, { location: OpenWorkspaceLocation.NewWindow }); + case OpenInNewWindowQuickInputButton: + openWorkspace(item.uri, { location: 'newWindow' }); break; - case QuickCommandButtons.RevealInSideBar: + case RevealInSideBarQuickInputButton: void WorktreeActions.reveal(item, { select: true, focus: false, expand: true }); break; } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async quickpick => { - if (quickpick.activeItems.length === 0) return; - - await WorktreeActions.reveal(quickpick.activeItems[0].item, { + onDidPressKey: async (_quickpick, _key, { item }) => { + await WorktreeActions.reveal(item, { select: true, focus: false, expand: true, }); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResultBreak; } -export async function* showCommitOrStashStep< +export function* showCommitOrStashStep< State extends PartialStepState & { repo: Repository; reference: GitCommit | GitStashCommit }, Context extends { repos: Repository[]; title: string }, >( state: State, context: Context, -): AsyncStepResultGenerator { +): StepResultGenerator { const step: QuickPickStep = createPickStep({ title: appendReposToTitle( - GitReference.toString(state.reference, { + getReferenceLabel(state.reference, { capitalize: true, icon: false, }), state, context, ), - placeholder: GitReference.toString(state.reference, { capitalize: true, icon: false }), + placeholder: getReferenceLabel(state.reference, { capitalize: true, icon: false }), ignoreFocusOut: true, - items: await getShowCommitOrStashStepItems(state), - // additionalButtons: [QuickCommandButtons.ShowDetailsView, QuickCommandButtons.RevealInSideBar], - onDidClickItemButton: (quickpick, button, _item) => { + items: getShowCommitOrStashStepItems(state), + // additionalButtons: [ShowDetailsView, RevealInSideBar], + onDidClickItemButton: (_quickpick, button, _item) => { switch (button) { - case QuickCommandButtons.ShowDetailsView: - if (GitReference.isStash(state.reference)) { + case ShowDetailsViewQuickInputButton: + if (isStashReference(state.reference)) { void StashActions.showDetailsView(state.reference, { pin: false, preserveFocus: true }); } else { void CommitActions.showDetailsView(state.reference, { @@ -1751,8 +1965,8 @@ export async function* showCommitOrStashStep< }); } break; - case QuickCommandButtons.RevealInSideBar: - if (GitReference.isStash(state.reference)) { + case RevealInSideBarQuickInputButton: + if (isStashReference(state.reference)) { void StashActions.reveal(state.reference, { select: true, focus: false, @@ -1769,12 +1983,11 @@ export async function* showCommitOrStashStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - - await quickpick.activeItems[0].onDidPressKey(key); + onDidPressKey: async (_quickpick, key, item) => { + await item.onDidPressKey(key); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0] : StepResultBreak; } @@ -1800,14 +2013,22 @@ async function getShowCommitOrStashStepItems< reference: state.reference, }, }), - new GitCommandQuickPickItem('Delete Stash...', { + new GitCommandQuickPickItem('Rename Stash...', { command: 'stash', state: { - subcommand: 'drop', + subcommand: 'rename', repo: state.repo, reference: state.reference, }, }), + new GitCommandQuickPickItem('Drop Stash...', { + command: 'stash', + state: { + subcommand: 'drop', + repo: state.repo, + references: [state.reference], + }, + }), createQuickPickSeparator(), new CommitCopyMessageQuickPickItem(state.reference), @@ -1816,7 +2037,7 @@ async function getShowCommitOrStashStepItems< const remotes = await Container.instance.git.getRemotesWithProviders(state.repo.path, { sort: true }); if (remotes?.length) { items.push( - createQuickPickSeparator(GitRemote.getHighlanderProviderName(remotes) ?? 'Remote'), + createQuickPickSeparator(getHighlanderProviderName(remotes) ?? 'Remote'), new OpenRemoteResourceCommandQuickPickItem(remotes, { type: RemoteResourceType.Commit, sha: state.reference.sha, @@ -1833,8 +2054,7 @@ async function getShowCommitOrStashStepItems< const branch = await Container.instance.git.getBranch(state.repo.path); const [branches, published] = await Promise.all([ branch != null - ? Container.instance.git.getCommitBranches(state.repo.path, state.reference.ref, { - branch: branch.name, + ? Container.instance.git.getCommitBranches(state.repo.path, state.reference.ref, branch.name, { commitDate: isCommit(state.reference) ? state.reference.committer.date : undefined, }) : undefined, @@ -1877,7 +2097,7 @@ async function getShowCommitOrStashStepItems< command: 'reset', state: { repo: state.repo, - reference: GitReference.create(`${state.reference.ref}^`, state.reference.repoPath, { + reference: createReference(`${state.reference.ref}^`, state.reference.repoPath, { refType: 'revision', name: `${state.reference.name}^`, message: state.reference.message, @@ -1973,9 +2193,7 @@ async function getShowCommitOrStashStepItems< }), ); - items.splice( - 0, - 0, + items.unshift( new CommitFilesQuickPickItem(state.reference, { unpublished: unpublished, hint: 'Click to see all changed files', @@ -2002,14 +2220,14 @@ export function* showCommitOrStashFilesStep< const step: QuickPickStep = createPickStep({ title: appendReposToTitle( - GitReference.toString(state.reference, { + getReferenceLabel(state.reference, { capitalize: true, icon: false, }), state, context, ), - placeholder: GitReference.toString(state.reference, { capitalize: true, icon: false }), + placeholder: getReferenceLabel(state.reference, { capitalize: true, icon: false }), ignoreFocusOut: true, items: [ new CommitFilesQuickPickItem(state.reference, { @@ -2022,11 +2240,11 @@ export function* showCommitOrStashFilesStep< ) ?? []), ] as (CommitFilesQuickPickItem | CommitFileQuickPickItem)[], matchOnDescription: true, - // additionalButtons: [QuickCommandButtons.ShowDetailsView, QuickCommandButtons.RevealInSideBar], - onDidClickItemButton: (quickpick, button, _item) => { + // additionalButtons: [ShowDetailsView, RevealInSideBar], + onDidClickItemButton: (_quickpick, button, _item) => { switch (button) { - case QuickCommandButtons.ShowDetailsView: - if (GitReference.isStash(state.reference)) { + case ShowDetailsViewQuickInputButton: + if (isStashReference(state.reference)) { void StashActions.showDetailsView(state.reference, { pin: false, preserveFocus: true }); } else { void CommitActions.showDetailsView(state.reference, { @@ -2035,8 +2253,8 @@ export function* showCommitOrStashFilesStep< }); } break; - case QuickCommandButtons.RevealInSideBar: - if (GitReference.isStash(state.reference)) { + case RevealInSideBarQuickInputButton: + if (isStashReference(state.reference)) { void StashActions.reveal(state.reference, { select: true, focus: false, @@ -2053,27 +2271,26 @@ export function* showCommitOrStashFilesStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - - await quickpick.activeItems[0].onDidPressKey(key); + onDidPressKey: async (_quickpick, key, item) => { + await item.onDidPressKey(key); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0] : StepResultBreak; } -export async function* showCommitOrStashFileStep< +export function* showCommitOrStashFileStep< State extends PartialStepState & { repo: Repository; reference: GitCommit | GitStashCommit; fileName: string; }, Context extends { repos: Repository[]; title: string }, ->(state: State, context: Context): AsyncStepResultGenerator { +>(state: State, context: Context): StepResultGenerator { const step: QuickPickStep = createPickStep({ title: appendReposToTitle( - GitReference.toString(state.reference, { + getReferenceLabel(state.reference, { capitalize: true, icon: false, }), @@ -2083,17 +2300,17 @@ export async function* showCommitOrStashFileStep< ), placeholder: `${formatPath(state.fileName, { relativeTo: state.repo.path, - })} in ${GitReference.toString(state.reference, { + })} in ${getReferenceLabel(state.reference, { icon: false, })}`, ignoreFocusOut: true, - items: await getShowCommitOrStashFileStepItems(state), + items: getShowCommitOrStashFileStepItems(state), matchOnDescription: true, - // additionalButtons: [QuickCommandButtons.ShowDetailsView, QuickCommandButtons.RevealInSideBar], - onDidClickItemButton: (quickpick, button, _item) => { + // additionalButtons: [ShowDetailsView, RevealInSideBar], + onDidClickItemButton: (_quickpick, button, _item) => { switch (button) { - case QuickCommandButtons.ShowDetailsView: - if (GitReference.isStash(state.reference)) { + case ShowDetailsViewQuickInputButton: + if (isStashReference(state.reference)) { void StashActions.showDetailsView(state.reference, { pin: false, preserveFocus: true }); } else { void CommitActions.showDetailsView(state.reference, { @@ -2102,8 +2319,8 @@ export async function* showCommitOrStashFileStep< }); } break; - case QuickCommandButtons.RevealInSideBar: - if (GitReference.isStash(state.reference)) { + case RevealInSideBarQuickInputButton: + if (isStashReference(state.reference)) { void StashActions.reveal(state.reference, { select: true, focus: false, @@ -2120,12 +2337,11 @@ export async function* showCommitOrStashFileStep< } }, keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - - await quickpick.activeItems[0].onDidPressKey(key); + onDidPressKey: async (_quickpick, key, item) => { + await item.onDidPressKey(key); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0] : StepResultBreak; } @@ -2159,7 +2375,7 @@ async function getShowCommitOrStashFileStepItems< const remotes = await Container.instance.git.getRemotesWithProviders(state.repo.path, { sort: true }); if (remotes?.length) { items.push( - createQuickPickSeparator(GitRemote.getHighlanderProviderName(remotes) ?? 'Remote'), + createQuickPickSeparator(getHighlanderProviderName(remotes) ?? 'Remote'), new OpenRemoteResourceCommandQuickPickItem(remotes, { type: RemoteResourceType.Revision, fileName: state.fileName, @@ -2225,9 +2441,7 @@ async function getShowCommitOrStashFileStepItems< }), ); - items.splice( - 0, - 0, + items.unshift( new CommitFilesQuickPickItem(state.reference, { file: file, hint: 'Click to see all changed files' }), ); return items as CommandQuickPickItem[]; @@ -2241,16 +2455,15 @@ export function* showRepositoryStatusStep< const working = context.status.getFormattedDiffStatus({ expand: true, separator: ', ' }); const step: QuickPickStep = createPickStep({ title: appendReposToTitle(context.title, state, context), - placeholder: `${upstream ? `${upstream}, ${working}` : working}`, //'Changes to be committed', + placeholder: upstream ? `${upstream}, ${working}` : working, //'Changes to be committed', ignoreFocusOut: true, items: getShowRepositoryStatusStepItems(state, context), keys: ['right', 'alt+right', 'ctrl+right'], - onDidPressKey: async (quickpick, key) => { - if (quickpick.activeItems.length === 0) return; - - await quickpick.activeItems[0].onDidPressKey(key); + onDidPressKey: async (_quickpick, key, item) => { + await item.onDidPressKey(key); }, }); + const selection: StepSelection = yield step; return canPickStepContinue(step, state, selection) ? selection[0] : StepResultBreak; } @@ -2282,28 +2495,28 @@ function getShowRepositoryStatusStepItems< if (context.status.state.ahead === 0 && context.status.state.behind === 0) { items.push( createDirectiveQuickPickItem(Directive.Noop, true, { - label: `$(git-branch) ${context.status.branch} is up to date with $(git-branch) ${context.status.upstream}`, + label: `$(git-branch) ${context.status.branch} is up to date with $(git-branch) ${context.status.upstream?.name}`, detail: workingTreeStatus, }), ); } else if (context.status.state.ahead !== 0 && context.status.state.behind !== 0) { items.push( createDirectiveQuickPickItem(Directive.Noop, true, { - label: `$(git-branch) ${context.status.branch} has diverged from $(git-branch) ${context.status.upstream}`, + label: `$(git-branch) ${context.status.branch} has diverged from $(git-branch) ${context.status.upstream?.name}`, detail: workingTreeStatus, }), ); } else if (context.status.state.ahead !== 0) { items.push( createDirectiveQuickPickItem(Directive.Noop, true, { - label: `$(git-branch) ${context.status.branch} is ahead of $(git-branch) ${context.status.upstream}`, + label: `$(git-branch) ${context.status.branch} is ahead of $(git-branch) ${context.status.upstream?.name}`, detail: workingTreeStatus, }), ); } else if (context.status.state.behind !== 0) { items.push( createDirectiveQuickPickItem(Directive.Noop, true, { - label: `$(git-branch) ${context.status.branch} is behind $(git-branch) ${context.status.upstream}`, + label: `$(git-branch) ${context.status.branch} is behind $(git-branch) ${context.status.upstream?.name}`, detail: workingTreeStatus, }), ); @@ -2317,8 +2530,8 @@ function getShowRepositoryStatusStepItems< command: 'log', state: { repo: state.repo, - reference: GitReference.create( - GitRevision.createRange(context.status.ref, context.status.upstream), + reference: createReference( + createRevisionRange(context.status.ref, context.status.upstream?.name, '..'), state.repo.path, ), }, @@ -2335,8 +2548,8 @@ function getShowRepositoryStatusStepItems< command: 'log', state: { repo: state.repo, - reference: GitReference.create( - GitRevision.createRange(context.status.upstream, context.status.ref), + reference: createReference( + createRevisionRange(context.status.upstream?.name, context.status.ref, '..'), state.repo.path, ), }, @@ -2359,65 +2572,137 @@ function getShowRepositoryStatusStepItems< computed.stagedAddsAndChanges.concat(computed.unstagedAddsAndChanges), ), ); + + items.push( + new OpenOnlyChangedFilesCommandQuickPickItem( + computed.stagedAddsAndChanges.concat(computed.unstagedAddsAndChanges), + ), + ); } if (computed.staged > 0) { + items.push(new OpenChangedFilesCommandQuickPickItem(computed.stagedAddsAndChanges, 'Open Staged Files')); + items.push( - new OpenChangedFilesCommandQuickPickItem(computed.stagedAddsAndChanges, { - label: '$(files) Open Staged Files', - }), + new OpenOnlyChangedFilesCommandQuickPickItem(computed.stagedAddsAndChanges, 'Open Only Staged Files'), ); } if (computed.unstaged > 0) { + items.push(new OpenChangedFilesCommandQuickPickItem(computed.unstagedAddsAndChanges, 'Open Unstaged Files')); + items.push( - new OpenChangedFilesCommandQuickPickItem(computed.unstagedAddsAndChanges, { - label: '$(files) Open Unstaged Files', - }), + new OpenOnlyChangedFilesCommandQuickPickItem(computed.unstagedAddsAndChanges, 'Open Only Unstaged Files'), ); } if (context.status.files.length) { - items.push(new CommandQuickPickItem('$(x) Close Unchanged Files', Commands.CloseUnchangedFiles)); + items.push(new CommandQuickPickItem('Close Unchanged Files', new ThemeIcon('x'), Commands.CloseUnchangedFiles)); } return items; } export async function* ensureAccessStep< - State extends PartialStepState & { repo: Repository }, - Context extends { repos: Repository[]; title: string }, ->(state: State, context: Context, feature: PlusFeatures): AsyncStepResultGenerator { - const access = await Container.instance.git.access(feature, state.repo.path); - if (access.allowed) return undefined; + State extends PartialStepState & { repo?: Repository }, + Context extends { title: string }, +>(state: State, context: Context, feature: PlusFeatures): AsyncStepResultGenerator { + const access = await Container.instance.git.access(feature, state.repo?.path); + if (access.allowed) return access; const directives: DirectiveQuickPickItem[] = []; let placeholder: string; if (access.subscription.current.account?.verified === false) { - directives.push(createDirectiveQuickPickItem(Directive.RequiresVerification, true)); - placeholder = 'You must verify your email address before you can continue'; + directives.push( + createDirectiveQuickPickItem(Directive.RequiresVerification, true), + createQuickPickSeparator(), + createDirectiveQuickPickItem(Directive.Cancel), + ); + placeholder = 'You must verify your email before you can continue'; } else { - if (access.subscription.required == null) return undefined; + if (access.subscription.required == null) return access; + + let detail; + const promo = getApplicablePromo(access.subscription.current.state); + if (promo != null) { + // NOTE: Don't add a default case, so that if we add a new promo the build will break without handling it + switch (promo.key) { + case 'pro50': + detail = '$(star-full) Limited-Time Sale: Save 33% or more on your 1st seat of Pro'; + break; + case 'launchpad': + case 'launchpad-extended': + detail = `$(rocket) Launchpad Sale: Save 75% or more on GitLens Pro`; + break; + } + } - placeholder = 'You need GitLens Pro to access GitLens+ features on this repo'; + placeholder = 'Pro feature — requires a trial or paid plan for use on privately-hosted repos'; if (isSubscriptionPaidPlan(access.subscription.required) && access.subscription.current.account != null) { - directives.push(createDirectiveQuickPickItem(Directive.RequiresPaidSubscription, true)); + placeholder = 'Pro feature — requires a paid plan for use on privately-hosted repos'; + directives.push( + createDirectiveQuickPickItem(Directive.RequiresPaidSubscription, true, { detail: detail }), + createQuickPickSeparator(), + createDirectiveQuickPickItem(Directive.Cancel), + ); } else if ( access.subscription.current.account == null && !isSubscriptionPreviewTrialExpired(access.subscription.current) ) { - directives.push(createDirectiveQuickPickItem(Directive.StartPreviewTrial, true)); + directives.push( + createDirectiveQuickPickItem(Directive.StartPreview, true), + createQuickPickSeparator(), + createDirectiveQuickPickItem(Directive.Cancel), + ); } else { - directives.push(createDirectiveQuickPickItem(Directive.ExtendTrial)); + directives.push( + createDirectiveQuickPickItem(Directive.StartProTrial, true), + createDirectiveQuickPickItem(Directive.SignIn), + createQuickPickSeparator(), + createDirectiveQuickPickItem(Directive.Cancel), + ); } } + switch (feature) { + case PlusFeatures.Launchpad: + directives.splice( + 0, + 0, + createDirectiveQuickPickItem(Directive.Cancel, undefined, { + label: 'Launchpad prioritizes your pull requests to keep you focused and your team unblocked', + detail: 'Click to learn more about Launchpad', + iconPath: new ThemeIcon('rocket'), + onDidSelect: () => + void executeCommand(Commands.OpenWalkthrough, { + step: 'launchpad', + source: 'launchpad', + detail: 'info', + }), + }), + createQuickPickSeparator(), + ); + break; + case PlusFeatures.Worktrees: + directives.splice( + 0, + 0, + createDirectiveQuickPickItem(Directive.Noop, undefined, { + label: 'Worktrees minimize context switching by allowing simultaneous work on multiple branches', + iconPath: getIconPathUris(Container.instance, 'icon-repo.svg'), + }), + ); + break; + } + const step = createPickStep({ - title: appendReposToTitle(context.title, state, context), + title: context.title, placeholder: placeholder, - items: [...directives, createDirectiveQuickPickItem(Directive.Cancel)], + items: directives, + buttons: [], + isConfirmationStep: true, }); const selection: StepSelection = yield step; - return canPickStepContinue(step, state, selection) ? undefined : StepResultBreak; + return canPickStepContinue(step, state, selection) ? access : StepResultBreak; } diff --git a/src/commands/quickCommand.ts b/src/commands/quickCommand.ts index 432eab8acc440..6f5da346e8943 100644 --- a/src/commands/quickCommand.ts +++ b/src/commands/quickCommand.ts @@ -1,14 +1,15 @@ import type { InputBox, QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; -import { configuration } from '../configuration'; +import type { Keys } from '../constants'; +import type { Commands } from '../constants.commands'; import type { Container } from '../container'; -import type { Keys } from '../keyboard'; +import { createQuickPickSeparator } from '../quickpicks/items/common'; import type { DirectiveQuickPickItem } from '../quickpicks/items/directive'; import { createDirectiveQuickPickItem, Directive, isDirective } from '../quickpicks/items/directive'; - -export * from './quickCommand.buttons'; -export * from './quickCommand.steps'; +import { configuration } from '../system/vscode/configuration'; export interface CustomStep { + type: 'custom'; + ignoreFocusOut?: boolean; show(step: CustomStep): Promise>; @@ -17,104 +18,121 @@ export interface CustomStep { export function isCustomStep( step: QuickPickStep | QuickInputStep | CustomStep | typeof StepResultBreak, ): step is CustomStep { - return typeof step === 'object' && (step as CustomStep).show != null; + return typeof step === 'object' && 'type' in step && step.type === 'custom'; } -export interface QuickInputStep { +export interface QuickInputStep { + type: 'input'; + additionalButtons?: QuickInputButton[]; buttons?: QuickInputButton[]; + disallowBack?: boolean; ignoreFocusOut?: boolean; + isConfirmationStep?: boolean; keys?: StepNavigationKeys[]; placeholder?: string; prompt?: string; title?: string; - value?: string; + value?: T; onDidClickButton?(input: InputBox, button: QuickInputButton): boolean | void | Promise; onDidPressKey?(quickpick: InputBox, key: Keys): void | Promise; - validate?(value: string | undefined): [boolean, string | undefined] | Promise<[boolean, string | undefined]>; + validate?(value: T | undefined): [boolean, T | undefined] | Promise<[boolean, T | undefined]>; } export function isQuickInputStep( - step: QuickPickStep | QuickInputStep | typeof StepResultBreak, + step: QuickPickStep | QuickInputStep | CustomStep | typeof StepResultBreak, ): step is QuickInputStep { - return typeof step === 'object' && (step as QuickPickStep).items == null && (step as CustomStep).show == null; + return typeof step === 'object' && 'type' in step && step.type === 'input'; } export interface QuickPickStep { + type: 'pick'; + additionalButtons?: QuickInputButton[]; allowEmpty?: boolean; buttons?: QuickInputButton[]; + disallowBack?: boolean; ignoreFocusOut?: boolean; - items: (DirectiveQuickPickItem | T)[]; // | DirectiveQuickPickItem[]; + isConfirmationStep?: boolean; + items: (DirectiveQuickPickItem | T)[] | Promise<(DirectiveQuickPickItem | T)[]>; keys?: StepNavigationKeys[]; matchOnDescription?: boolean; matchOnDetail?: boolean; multiselect?: boolean; - placeholder?: string; + placeholder?: string | ((count: number) => string); selectedItems?: QuickPickItem[]; title?: string; value?: string; selectValueWhenShown?: boolean; - onDidAccept?(quickpick: QuickPick): boolean | Promise; - onDidChangeValue?(quickpick: QuickPick): boolean | Promise; - onDidClickButton?(quickpick: QuickPick, button: QuickInputButton): boolean | void | Promise; + frozen?: boolean; + + onDidActivate?(quickpick: QuickPick): void; + + onDidAccept?(quickpick: QuickPick): boolean | Promise; + onDidChangeValue?(quickpick: QuickPick): boolean | Promise; + onDidChangeSelection?(quickpick: QuickPick, selection: readonly T[]): void; + onDidClickButton?( + quickpick: QuickPick, + button: QuickInputButton, + ): + | boolean + | void + | Promise>; /** * @returns `true` if the current item should be selected */ onDidClickItemButton?( - quickpick: QuickPick, + quickpick: QuickPick, button: QuickInputButton, item: T, ): boolean | void | Promise; - onDidLoadMore?(quickpick: QuickPick): (DirectiveQuickPickItem | T)[] | Promise<(DirectiveQuickPickItem | T)[]>; - onDidPressKey?(quickpick: QuickPick, key: Keys): void | Promise; - onValidateValue?(quickpick: QuickPick, value: string, items: T[]): boolean | Promise; + onDidLoadMore?( + quickpick: QuickPick, + ): (DirectiveQuickPickItem | T)[] | Promise<(DirectiveQuickPickItem | T)[]>; + onDidPressKey?(quickpick: QuickPick, key: Keys, item: T): void | Promise; + onValidateValue?( + quickpick: QuickPick, + value: string, + items: T[], + ): boolean | Promise; validate?(selection: T[]): boolean; } export function isQuickPickStep( step: QuickPickStep | QuickInputStep | CustomStep | typeof StepResultBreak, ): step is QuickPickStep { - return typeof step === 'object' && (step as QuickPickStep).items != null; + return typeof step === 'object' && 'type' in step && step.type === 'pick'; } export type StepGenerator = - | Generator, any | undefined> - | AsyncGenerator, any | undefined>; + | Generator> + | AsyncGenerator>; export type StepItemType = T extends CustomStep ? U : T extends QuickPickStep - ? U[] - : T extends QuickInputStep - ? string - : never; + ? U[] + : T extends QuickInputStep + ? string + : never; export type StepNavigationKeys = Exclude; export const StepResultBreak = Symbol('BreakStep'); export type StepResult = typeof StepResultBreak | T; -export type StepResultGenerator = Generator< - QuickPickStep | QuickInputStep | CustomStep, - StepResult, - any | undefined ->; -export type AsyncStepResultGenerator = AsyncGenerator< - QuickPickStep | QuickInputStep | CustomStep, - StepResult, - any | undefined ->; +export type StepResultGenerator = Generator>; +export type AsyncStepResultGenerator = AsyncGenerator>; // Can't use this union type because of https://github.com/microsoft/TypeScript/issues/41428 // export type StepResultGenerator = // | Generator, any | undefined> // | AsyncGenerator, any | undefined>; export type StepSelection = T extends CustomStep - ? U | Directive + ? Exclude | Directive : T extends QuickPickStep - ? U[] | Directive - : T extends QuickInputStep - ? string | Directive - : never; + ? Exclude[] | Directive + : T extends QuickInputStep + ? string | Directive + : never; export type PartialStepState = Partial & { counter: number; confirm?: boolean; startingStep?: number }; export type StepState> = T & { counter: number; confirm?: boolean; startingStep?: number }; @@ -132,13 +150,13 @@ export abstract class QuickCommand implements QuickPickItem { public readonly key: string, public readonly label: string, public readonly title: string, - options: { + options?: { description?: string; detail?: string; - } = {}, + }, ) { - this.description = options.description; - this.detail = options.detail; + this.description = options?.description; + this.detail = options?.detail; } get canConfirm(): boolean { @@ -278,7 +296,7 @@ export async function canInputStepContinue( export function canPickStepContinue( step: T, state: PartialStepState, - selection: StepItemType | Directive, + selection: Directive | StepItemType, ): selection is StepItemType { if (!canStepContinue(step, state, selection)) return false; @@ -290,12 +308,13 @@ export function canPickStepContinue( return false; } -export function canStepContinue( - step: T, +export function canStepContinue( + _step: T, state: PartialStepState, result: Directive | StepItemType, ): result is StepItemType { if (result == null) return false; + if (isDirective(result)) { switch (result) { case Directive.Back: @@ -307,11 +326,6 @@ export function canStepContinue( case Directive.Cancel: endSteps(state); break; - // case Directive.Noop: - // case Directive.RequiresVerification: - // case Directive.RequiresFreeSubscription: - // case Directive.RequiresProSubscription: - // break; } return false; } @@ -324,32 +338,61 @@ export function createConfirmStep> = {}, + options?: Partial>, ): QuickPickStep { return createPickStep({ + isConfirmationStep: true, placeholder: `Confirm ${context.title}`, title: title, ignoreFocusOut: true, - items: [...confirmations, cancel ?? createDirectiveQuickPickItem(Directive.Cancel)], + items: [ + ...confirmations, + createQuickPickSeparator(), + cancel ?? createDirectiveQuickPickItem(Directive.Cancel), + ], selectedItems: [confirmations.find(c => c.picked) ?? confirmations[0]], ...options, }); } -export function createInputStep(step: QuickInputStep): QuickInputStep { +export function createInputStep(step: Optional, 'type'>): QuickInputStep { // Make sure any input steps won't close on focus loss - step.ignoreFocusOut = true; - return step; + return { type: 'input', ...step, ignoreFocusOut: true }; } -export function createPickStep(step: QuickPickStep): QuickPickStep { - return step; +export function createPickStep(step: Optional, 'type'>): QuickPickStep { + return { type: 'pick', ...step }; } -export function createCustomStep(step: CustomStep): CustomStep { - return step; +export function createCustomStep(step: Optional, 'type'>): CustomStep { + return { type: 'custom', ...step }; } export function endSteps(state: PartialStepState) { state.counter = -1; } + +export function freezeStep(step: QuickPickStep, quickpick: QuickPick): Disposable { + quickpick.enabled = false; + step.frozen = true; + return { + [Symbol.dispose]: () => { + step.frozen = false; + quickpick.enabled = true; + quickpick.show(); + }, + }; +} + +export interface CrossCommandReference { + command: Commands; + args?: T; +} + +export function isCrossCommandReference(value: any): value is CrossCommandReference { + return value.command != null; +} + +export function createCrossCommandReference(command: Commands, args: T): CrossCommandReference { + return { command: command, args: args }; +} diff --git a/src/commands/rebaseEditor.ts b/src/commands/rebaseEditor.ts index 2e1d46f7f7cfd..8a37ec5a8e2dd 100644 --- a/src/commands/rebaseEditor.ts +++ b/src/commands/rebaseEditor.ts @@ -1,6 +1,6 @@ -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; import { Command } from './base'; @command() diff --git a/src/commands/refreshHover.ts b/src/commands/refreshHover.ts index 1d9065be3238f..78fa870393778 100644 --- a/src/commands/refreshHover.ts +++ b/src/commands/refreshHover.ts @@ -1,6 +1,6 @@ -import { Commands, CoreCommands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { command, executeCoreCommand } from '../system/command'; +import { command, executeCoreCommand } from '../system/vscode/command'; import { Command } from './base'; @command() @@ -11,6 +11,6 @@ export class RefreshHoverCommand extends Command { async execute() { // TODO@eamodio figure out how to really refresh/update a hover - await executeCoreCommand(CoreCommands.EditorShowHover); + await executeCoreCommand('editor.action.showHover'); } } diff --git a/src/commands/remoteProviders.ts b/src/commands/remoteProviders.ts index f2d2be8902c8f..e9e0edee75c71 100644 --- a/src/commands/remoteProviders.ts +++ b/src/commands/remoteProviders.ts @@ -1,12 +1,13 @@ -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import type { GitCommit } from '../git/models/commit'; -import { GitRemote } from '../git/models/remote'; +import type { GitRemote } from '../git/models/remote'; +import { isRemote } from '../git/models/remote'; import type { Repository } from '../git/models/repository'; -import type { RichRemoteProvider } from '../git/remotes/richRemoteProvider'; -import { RepositoryPicker } from '../quickpicks/repositoryPicker'; -import { command } from '../system/command'; +import type { RemoteProvider } from '../git/remotes/remoteProvider'; +import { showRepositoryPicker } from '../quickpicks/repositoryPicker'; import { first } from '../system/iterable'; +import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import { Command, isCommandContextViewNodeHasRemote } from './base'; @@ -21,9 +22,9 @@ export class ConnectRemoteProviderCommand extends Command { static getMarkdownCommandArgs(remote: GitRemote): string; static getMarkdownCommandArgs(argsOrRemote: ConnectRemoteProviderCommandArgs | GitRemote): string { let args: ConnectRemoteProviderCommandArgs | GitCommit; - if (GitRemote.is(argsOrRemote)) { + if (isRemote(argsOrRemote)) { args = { - remote: argsOrRemote.id, + remote: argsOrRemote.name, repoPath: argsOrRemote.repoPath, }; } else { @@ -39,22 +40,22 @@ export class ConnectRemoteProviderCommand extends Command { protected override preExecute(context: CommandContext, args?: ConnectRemoteProviderCommandArgs) { if (isCommandContextViewNodeHasRemote(context)) { - args = { ...args, remote: context.node.remote.id, repoPath: context.node.remote.repoPath }; + args = { ...args, remote: context.node.remote.name, repoPath: context.node.remote.repoPath }; } return this.execute(args); } async execute(args?: ConnectRemoteProviderCommandArgs): Promise { - let remote: GitRemote | undefined; + let remote: GitRemote | undefined; let remotes: GitRemote[] | undefined; let repoPath; if (args?.repoPath == null) { - const repos = new Map>(); + const repos = new Map>(); for (const repo of this.container.git.openRepositories) { - const remote = await repo.getRichRemote(); - if (remote?.provider != null && !(await remote.provider.isConnected())) { + const remote = await repo.getBestRemoteWithIntegration({ includeDisconnected: true }); + if (remote?.provider != null) { repos.set(repo, remote); } } @@ -65,30 +66,34 @@ export class ConnectRemoteProviderCommand extends Command { [repo, remote] = first(repos)!; repoPath = repo.path; } else { - const pick = await RepositoryPicker.show( + const pick = await showRepositoryPicker( undefined, 'Choose which repository to connect to the remote provider', [...repos.keys()], ); - if (pick?.item == null) return undefined; + if (pick == null) return undefined; - repoPath = pick.repoPath; - remote = repos.get(pick.item)!; + repoPath = pick.path; + remote = repos.get(pick)!; } } else if (args?.remote == null) { repoPath = args.repoPath; - remote = await this.container.git.getBestRemoteWithRichProvider(repoPath, { includeDisconnected: true }); + remote = await this.container.git.getBestRemoteWithIntegration(repoPath, { includeDisconnected: true }); if (remote == null) return false; } else { repoPath = args.repoPath; remotes = await this.container.git.getRemotesWithProviders(repoPath); - remote = remotes.find(r => r.id === args.remote) as GitRemote | undefined; - if (!remote?.hasRichProvider()) return false; + remote = remotes.find(r => r.name === args.remote) as GitRemote | undefined; + if (!remote?.hasIntegration()) return false; } - const connected = await remote.provider.connect(); + const integration = await this.container.integrations.getByRemote(remote); + if (integration == null) return false; + + const connected = await integration.connect('remoteProvider'); + if ( connected && !(remotes ?? (await this.container.git.getRemotesWithProviders(repoPath))).some(r => r.default) @@ -110,9 +115,9 @@ export class DisconnectRemoteProviderCommand extends Command { static getMarkdownCommandArgs(remote: GitRemote): string; static getMarkdownCommandArgs(argsOrRemote: DisconnectRemoteProviderCommandArgs | GitRemote): string { let args: DisconnectRemoteProviderCommandArgs | GitCommit; - if (GitRemote.is(argsOrRemote)) { + if (isRemote(argsOrRemote)) { args = { - remote: argsOrRemote.id, + remote: argsOrRemote.name, repoPath: argsOrRemote.repoPath, }; } else { @@ -131,20 +136,20 @@ export class DisconnectRemoteProviderCommand extends Command { protected override preExecute(context: CommandContext, args?: ConnectRemoteProviderCommandArgs) { if (isCommandContextViewNodeHasRemote(context)) { - args = { ...args, remote: context.node.remote.id, repoPath: context.node.remote.repoPath }; + args = { ...args, remote: context.node.remote.name, repoPath: context.node.remote.repoPath }; } return this.execute(args); } async execute(args?: DisconnectRemoteProviderCommandArgs): Promise { - let remote: GitRemote | undefined; + let remote: GitRemote | undefined; let repoPath; if (args?.repoPath == null) { - const repos = new Map>(); + const repos = new Map>(); for (const repo of this.container.git.openRepositories) { - const remote = await repo.getRichRemote(true); + const remote = await repo.getBestRemoteWithIntegration({ includeDisconnected: false }); if (remote != null) { repos.set(repo, remote); } @@ -156,30 +161,29 @@ export class DisconnectRemoteProviderCommand extends Command { [repo, remote] = first(repos)!; repoPath = repo.path; } else { - const pick = await RepositoryPicker.show( + const pick = await showRepositoryPicker( undefined, 'Choose which repository to disconnect from the remote provider', [...repos.keys()], ); - if (pick?.item == null) return undefined; + if (pick == null) return undefined; - repoPath = pick.repoPath; - remote = repos.get(pick.item)!; + repoPath = pick.path; + remote = repos.get(pick)!; } } else if (args?.remote == null) { repoPath = args.repoPath; - remote = await this.container.git.getBestRemoteWithRichProvider(repoPath, { includeDisconnected: false }); + remote = await this.container.git.getBestRemoteWithIntegration(repoPath, { includeDisconnected: false }); if (remote == null) return undefined; } else { repoPath = args.repoPath; - remote = (await this.container.git.getRemotesWithProviders(repoPath)).find(r => r.id === args.remote) as - | GitRemote - | undefined; - if (!remote?.hasRichProvider()) return undefined; + remote = (await this.container.git.getRemotesWithProviders(repoPath)).find(r => r.name === args.remote); + if (!remote?.hasIntegration()) return undefined; } - return remote.provider.disconnect(); + const integration = await this.container.integrations.getByRemote(remote); + return integration?.disconnect(); } } diff --git a/src/commands/repositories.ts b/src/commands/repositories.ts index f98b0b22dd2c1..28c944caf894c 100644 --- a/src/commands/repositories.ts +++ b/src/commands/repositories.ts @@ -1,7 +1,7 @@ -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { executeGitCommand } from '../git/actions'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; import { Command } from './base'; @command() diff --git a/src/commands/resetViewsLayout.ts b/src/commands/resetViewsLayout.ts new file mode 100644 index 0000000000000..3c2043f52f29e --- /dev/null +++ b/src/commands/resetViewsLayout.ts @@ -0,0 +1,33 @@ +import { Commands } from '../constants.commands'; +import type { ViewIds } from '../constants.views'; +import { viewIdsByDefaultContainerId } from '../constants.views'; +import type { Container } from '../container'; +import { command, executeCoreCommand } from '../system/vscode/command'; +import { Command } from './base'; + +@command() +export class ResetViewsLayoutCommand extends Command { + constructor(private readonly container: Container) { + super(Commands.ResetViewsLayout); + } + + async execute() { + // Don't use this because it will forcibly show & expand every view + // for (const view of viewIds) { + // void (await executeCoreCommand(`gitlens.views.${view}.resetViewLocation`)); + // } + + for (const [containerId, viewIds] of viewIdsByDefaultContainerId) { + try { + void (await executeCoreCommand('vscode.moveViews', { + viewIds: viewIds.map(v => `gitlens.views.${v}`), + destinationId: containerId, + })); + } catch {} + + if (containerId.includes('gitlens')) { + void (await executeCoreCommand(`${containerId}.resetViewContainerLocation`)); + } + } + } +} diff --git a/src/commands/resets.ts b/src/commands/resets.ts index 190e2e2efa766..3dab98f72647f 100644 --- a/src/commands/resets.ts +++ b/src/commands/resets.ts @@ -1,40 +1,205 @@ -import { ConfigurationTarget } from 'vscode'; +import type { MessageItem } from 'vscode'; +import { ConfigurationTarget, window } from 'vscode'; import { resetAvatarCache } from '../avatars'; -import { configuration } from '../configuration'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { command } from '../system/command'; +import type { QuickPickItemOfT } from '../quickpicks/items/common'; +import { createQuickPickSeparator } from '../quickpicks/items/common'; +import { command } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; import { Command } from './base'; +const resetTypes = [ + 'ai', + 'avatars', + 'integrations', + 'plus', + 'repositoryAccess', + 'suppressedWarnings', + 'usageTracking', + 'workspace', +] as const; +type ResetType = 'all' | (typeof resetTypes)[number]; + @command() -export class ResetAvatarCacheCommand extends Command { +export class ResetCommand extends Command { constructor(private readonly container: Container) { - super(Commands.ResetAvatarCache); + super(Commands.Reset); } + async execute() { + type ResetQuickPickItem = QuickPickItemOfT; - execute() { - resetAvatarCache('all'); - } -} + const items: ResetQuickPickItem[] = [ + { + label: 'AI Keys...', + detail: 'Clears any locally stored AI keys', + item: 'ai', + }, + { + label: 'Avatars...', + detail: 'Clears the stored avatar cache', + item: 'avatars', + }, + { + label: 'Integrations (Authentication)...', + detail: 'Clears any locally stored authentication for integrations', + item: 'integrations', + }, + { + label: 'Repository Access...', + detail: 'Clears the stored repository access cache', + item: 'repositoryAccess', + }, + { + label: 'Suppressed Warnings...', + detail: 'Clears any suppressed warnings, e.g. messages with "Don\'t Show Again" options', + item: 'suppressedWarnings', + }, + { + label: 'Usage Tracking...', + detail: 'Clears any locally tracked usage, typically used for first time experience', + item: 'usageTracking', + }, + { + label: 'Workspace Storage...', + detail: 'Clears stored data associated with the current workspace', + item: 'workspace', + }, + createQuickPickSeparator(), + { + label: 'Everything...', + description: ' — \u00a0be very careful with this!', + detail: 'Clears ALL locally stored data; ALL GitLens state will be LOST', + item: 'all', + }, + ]; -@command() -export class ResetSuppressedWarningsCommand extends Command { - constructor(private readonly container: Container) { - super(Commands.ResetSuppressedWarnings); + if (this.container.debugging) { + items.splice( + 0, + 0, + { + label: 'Subscription Reset', + detail: 'Resets the stored subscription', + item: 'plus', + }, + createQuickPickSeparator(), + ); + } + + // create a quick pick with options to clear all the different resets that GitLens supports + const pick = await window.showQuickPick(items, { + title: 'Reset Stored Data', + placeHolder: 'Choose which data to reset, will be prompted to confirm', + }); + + if (pick?.item == null) return; + if (pick.item === 'plus' && !this.container.debugging) return; + + const confirm: MessageItem = { title: 'Reset' }; + const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + + let confirmationMessage: string | undefined; + switch (pick?.item) { + case 'all': + confirmationMessage = 'Are you sure you want to reset EVERYTHING?'; + confirm.title = 'Reset Everything'; + break; + case 'ai': + confirmationMessage = 'Are you sure you want to reset all of the stored AI keys?'; + confirm.title = 'Reset AI Keys'; + break; + case 'avatars': + confirmationMessage = 'Are you sure you want to reset the avatar cache?'; + confirm.title = 'Reset Avatars'; + break; + case 'integrations': + confirmationMessage = 'Are you sure you want to reset all of the stored integrations?'; + confirm.title = 'Reset Integrations'; + break; + case 'repositoryAccess': + confirmationMessage = 'Are you sure you want to reset the repository access cache?'; + confirm.title = 'Reset Repository Access'; + break; + case 'suppressedWarnings': + confirmationMessage = 'Are you sure you want to reset all of the suppressed warnings?'; + confirm.title = 'Reset Suppressed Warnings'; + break; + case 'usageTracking': + confirmationMessage = 'Are you sure you want to reset all of the usage tracking?'; + confirm.title = 'Reset Usage Tracking'; + break; + case 'workspace': + confirmationMessage = 'Are you sure you want to reset the stored data for the current workspace?'; + confirm.title = 'Reset Workspace Storage'; + break; + } + + if (confirmationMessage != null) { + const result = await window.showWarningMessage( + `This is IRREVERSIBLE!\n${confirmationMessage}`, + { modal: true }, + confirm, + cancel, + ); + if (result !== confirm) return; + } + + await this.reset(pick.item); } - async execute() { - await configuration.update('advanced.messages', undefined, ConfigurationTarget.Global); + private async reset(reset: ResetType) { + switch (reset) { + case 'all': + for (const r of resetTypes) { + await this.reset(r); + } + + await this.container.storage.reset(); + break; + + case 'ai': + await (await this.container.ai)?.reset(true); + break; + + case 'avatars': + resetAvatarCache('all'); + break; + + case 'integrations': + await this.container.integrations.reset(); + break; + + case 'plus': + await this.container.subscription.logout(true, undefined); + break; + + case 'repositoryAccess': + await this.container.git.clearAllRepoVisibilityCaches(); + break; + + case 'suppressedWarnings': + await configuration.update('advanced.messages', undefined, ConfigurationTarget.Global); + break; + + case 'usageTracking': + await this.container.usage.reset(); + break; + + case 'workspace': + await this.container.storage.resetWorkspace(); + break; + } } } @command() -export class ResetTrackedUsageCommand extends Command { +export class ResetAIKeyCommand extends Command { constructor(private readonly container: Container) { - super(Commands.ResetTrackedUsage); + super(Commands.ResetAIKey); } async execute() { - await this.container.usage.reset(); + await (await this.container.ai)?.reset(); } } diff --git a/src/commands/searchCommits.ts b/src/commands/searchCommits.ts index 093f3796009b6..97d9dd4553eec 100644 --- a/src/commands/searchCommits.ts +++ b/src/commands/searchCommits.ts @@ -1,9 +1,9 @@ -import { configuration } from '../configuration'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; +import type { SearchQuery } from '../constants.search'; import type { Container } from '../container'; import { executeGitCommand } from '../git/actions'; -import type { SearchQuery } from '../git/search'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; import { SearchResultsNode } from '../views/nodes/searchResultsNode'; import type { CommandContext } from './base'; import { Command, isCommandContextViewNodeHasRepository } from './base'; diff --git a/src/commands/setViewsLayout.ts b/src/commands/setViewsLayout.ts deleted file mode 100644 index 9206f09e40b0a..0000000000000 --- a/src/commands/setViewsLayout.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { window } from 'vscode'; -import { viewsConfigKeys } from '../configuration'; -import { Commands, CoreCommands } from '../constants'; -import type { Container } from '../container'; -import { command, executeCommand, executeCoreCommand } from '../system/command'; -import { Command } from './base'; - -export enum ViewsLayout { - GitLens = 'gitlens', - SourceControl = 'scm', -} - -export interface SetViewsLayoutCommandArgs { - layout: ViewsLayout; -} - -@command() -export class SetViewsLayoutCommand extends Command { - constructor(private readonly container: Container) { - super(Commands.SetViewsLayout); - } - - async execute(args?: SetViewsLayoutCommandArgs) { - let layout = args?.layout; - if (layout == null) { - const pick = await window.showQuickPick( - [ - { - label: 'Source Control Layout', - description: '(default)', - detail: 'Shows all the views together on the Source Control side bar', - layout: ViewsLayout.SourceControl, - }, - { - label: 'GitLens Layout', - description: '', - detail: 'Shows all the views together on the GitLens side bar', - layout: ViewsLayout.GitLens, - }, - ], - { - placeHolder: 'Choose a GitLens views layout', - }, - ); - if (pick == null) return; - - layout = pick.layout; - } - - void this.container.storage.store('views:layout', layout); - - const views = viewsConfigKeys.filter(v => v !== 'contributors'); - - switch (layout) { - case ViewsLayout.GitLens: - try { - // Because of https://github.com/microsoft/vscode/issues/105774, run the command twice which seems to fix things - let count = 0; - while (count++ < 2) { - void (await executeCoreCommand(CoreCommands.MoveViews, { - viewIds: views.map(v => `gitlens.views.${v}`), - destinationId: 'workbench.view.extension.gitlens', - })); - } - } catch {} - - break; - case ViewsLayout.SourceControl: - try { - // Because of https://github.com/microsoft/vscode/issues/105774, run the command twice which seems to fix things - let count = 0; - while (count++ < 2) { - void (await executeCoreCommand(CoreCommands.MoveViews, { - viewIds: views.map(v => `gitlens.views.${v}`), - destinationId: 'workbench.view.scm', - })); - } - } catch { - for (const view of views) { - void (await executeCommand(`gitlens.views.${view}.resetViewLocation`)); - } - } - - break; - } - } -} diff --git a/src/commands/showCommitsInView.ts b/src/commands/showCommitsInView.ts index ff87c71932a21..973d1622437de 100644 --- a/src/commands/showCommitsInView.ts +++ b/src/commands/showCommitsInView.ts @@ -1,17 +1,14 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { executeGitCommand } from '../git/actions'; -import { showDetailsView } from '../git/actions/commit'; import { GitUri } from '../git/gitUri'; -import { GitReference } from '../git/models/reference'; import { createSearchQueryForCommits } from '../git/search'; -import { Logger } from '../logger'; import { showFileNotUnderSourceControlWarningMessage, showGenericErrorMessage } from '../messages'; -import { command } from '../system/command'; import { filterMap } from '../system/iterable'; -import type { CommandContext } from './base'; -import { ActiveEditorCommand, getCommandUri, isCommandContextViewNodeHasCommit } from './base'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; +import { ActiveEditorCommand, getCommandUri } from './base'; export interface ShowCommitsInViewCommandArgs { refs?: string[]; @@ -24,29 +21,17 @@ export class ShowCommitsInViewCommand extends ActiveEditorCommand { static getMarkdownCommandArgs(args: ShowCommitsInViewCommandArgs): string; static getMarkdownCommandArgs(argsOrSha: ShowCommitsInViewCommandArgs | string, repoPath?: string): string { const args = typeof argsOrSha === 'string' ? { refs: [argsOrSha], repoPath: repoPath } : argsOrSha; - return super.getMarkdownCommandArgsCore(Commands.ShowCommitInView, args); + return super.getMarkdownCommandArgsCore(Commands.ShowCommitsInView, args); } constructor(private readonly container: Container) { - super([Commands.ShowCommitInView, Commands.ShowInDetailsView, Commands.ShowCommitsInView]); - } - - protected override preExecute(context: CommandContext, args?: ShowCommitsInViewCommandArgs) { - if (context.type === 'viewItem') { - args = { ...args }; - if (isCommandContextViewNodeHasCommit(context)) { - args.refs = [context.node.commit.sha]; - args.repoPath = context.node.commit.repoPath; - } - } - - return this.execute(context.editor, context.uri, args); + super(Commands.ShowCommitsInView); } async execute(editor?: TextEditor, uri?: Uri, args?: ShowCommitsInViewCommandArgs) { args = { ...args }; - if (args.refs === undefined) { + if (args.refs == null) { uri = getCommandUri(uri, editor); if (uri == null) return undefined; @@ -65,13 +50,13 @@ export class ShowCommitsInViewCommand extends ActiveEditorCommand { ) : await this.container.git.getBlameForRange(gitUri, editor.selection); if (blame === undefined) { - return showFileNotUnderSourceControlWarningMessage('Unable to find commits'); + return void showFileNotUnderSourceControlWarningMessage('Unable to find commits'); } args.refs = [...filterMap(blame.commits.values(), c => (c.isUncommitted ? undefined : c.ref))]; } catch (ex) { Logger.error(ex, 'ShowCommitsInViewCommand', 'getBlameForRange'); - return showGenericErrorMessage('Unable to find commits'); + return void showGenericErrorMessage('Unable to find commits'); } } else { if (gitUri.sha == null) return undefined; @@ -80,9 +65,9 @@ export class ShowCommitsInViewCommand extends ActiveEditorCommand { } } - if (args.refs.length === 1) { - return showDetailsView(GitReference.create(args.refs[0], args.repoPath!, { refType: 'revision' })); - } + // if (args.refs.length === 1) { + // return showDetailsView(createReference(args.refs[0], args.repoPath!, { refType: 'revision' })); + // } return executeGitCommand({ command: 'search', diff --git a/src/commands/showLastQuickPick.ts b/src/commands/showLastQuickPick.ts index b02bbfa9bfa9a..35921e3b48088 100644 --- a/src/commands/showLastQuickPick.ts +++ b/src/commands/showLastQuickPick.ts @@ -1,9 +1,9 @@ import { commands } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { command } from '../system/command'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; import { Command, getLastCommand } from './base'; @command() diff --git a/src/commands/showQuickBranchHistory.ts b/src/commands/showQuickBranchHistory.ts index 4eb1350257736..904b5849fe311 100644 --- a/src/commands/showQuickBranchHistory.ts +++ b/src/commands/showQuickBranchHistory.ts @@ -1,10 +1,11 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { executeGitCommand } from '../git/actions'; import { GitUri } from '../git/gitUri'; -import { GitReference } from '../git/models/reference'; -import { command } from '../system/command'; +import type { GitReference } from '../git/models/reference'; +import { createReference } from '../git/models/reference'; +import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import { ActiveEditorCachedCommand, getCommandUri } from './base'; @@ -41,13 +42,13 @@ export class ShowQuickBranchHistoryCommand extends ActiveEditorCachedCommand { ref = args.branch === 'HEAD' ? 'HEAD' - : GitReference.create(args.branch, repoPath, { + : createReference(args.branch, repoPath, { refType: 'branch', name: args.branch, remote: false, }); } else if (args?.tag != null) { - ref = GitReference.create(args.tag, repoPath, { refType: 'tag', name: args.tag }); + ref = createReference(args.tag, repoPath, { refType: 'tag', name: args.tag }); } } diff --git a/src/commands/showQuickCommit.ts b/src/commands/showQuickCommit.ts index 330f4367af569..5be69bbe1fe7d 100644 --- a/src/commands/showQuickCommit.ts +++ b/src/commands/showQuickCommit.ts @@ -1,19 +1,19 @@ import type { TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { executeGitCommand } from '../git/actions'; import { reveal } from '../git/actions/commit'; import { GitUri } from '../git/gitUri'; import type { GitCommit, GitStashCommit } from '../git/models/commit'; import type { GitLog } from '../git/models/log'; -import { Logger } from '../logger'; import { showCommitNotFoundWarningMessage, showFileNotUnderSourceControlWarningMessage, showGenericErrorMessage, showLineUncommittedWarningMessage, } from '../messages'; -import { command } from '../system/command'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import { ActiveEditorCachedCommand, getCommandUri, isCommandContextViewNodeHasCommit } from './base'; @@ -68,7 +68,7 @@ export class ShowQuickCommitCommand extends ActiveEditorCachedCommand { if (uri == null) return; gitUri = await GitUri.fromUri(uri); - repoPath = gitUri.repoPath; + repoPath = gitUri.repoPath!; } } else { if (args.sha == null) { @@ -128,7 +128,7 @@ export class ShowQuickCommitCommand extends ActiveEditorCachedCommand { } if (args.repoLog == null) { - args.commit = await this.container.git.getCommit(repoPath!, args.sha); + args.commit = await this.container.git.getCommit(repoPath, args.sha); } } diff --git a/src/commands/showQuickCommitFile.ts b/src/commands/showQuickCommitFile.ts index 9b8a7655de8d9..66c4a3c0ed899 100644 --- a/src/commands/showQuickCommitFile.ts +++ b/src/commands/showQuickCommitFile.ts @@ -1,28 +1,30 @@ import type { TextEditor } from 'vscode'; import { Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { executeGitCommand } from '../git/actions'; import { GitUri } from '../git/gitUri'; import type { GitCommit, GitStashCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; import type { GitLog } from '../git/models/log'; -import { Logger } from '../logger'; +import { createReference } from '../git/models/reference'; import { showCommitNotFoundWarningMessage, showFileNotUnderSourceControlWarningMessage, showGenericErrorMessage, showLineUncommittedWarningMessage, } from '../messages'; -import { command } from '../system/command'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import { ActiveEditorCachedCommand, getCommandUri, isCommandContextViewNodeHasCommit } from './base'; export interface ShowQuickCommitFileCommandArgs { - sha?: string; commit?: GitCommit | GitStashCommit; + line?: number; fileLog?: GitLog; revisionUri?: string; + sha?: string; } @command() @@ -32,25 +34,16 @@ export class ShowQuickCommitFileCommand extends ActiveEditorCachedCommand { } constructor(private readonly container: Container) { - super([ - Commands.ShowQuickCommitFile, - Commands.ShowQuickCommitRevision, - Commands.ShowQuickCommitRevisionInDiffLeft, - Commands.ShowQuickCommitRevisionInDiffRight, - ]); + super(Commands.ShowQuickCommitFile); } protected override async preExecute(context: CommandContext, args?: ShowQuickCommitFileCommandArgs) { - if (context.editor != null && context.command.startsWith(Commands.ShowQuickCommitRevision)) { - args = { ...args }; - - const gitUri = await GitUri.fromUri(context.editor.document.uri); - args.sha = gitUri.sha; + if (context.type === 'editorLine') { + args = { ...args, line: context.line }; } if (context.type === 'viewItem') { - args = { ...args }; - args.sha = context.node.uri.sha; + args = { ...args, sha: context.node.uri.sha }; if (isCommandContextViewNodeHasCommit(context)) { args.commit = context.node.commit; @@ -75,10 +68,8 @@ export class ShowQuickCommitFileCommand extends ActiveEditorCachedCommand { } if (args.sha == null) { - if (editor == null) return; - - const blameLine = editor.selection.active.line; - if (blameLine < 0) return; + const blameLine = args.line ?? editor?.selection.active.line; + if (blameLine == null) return; try { const blame = await this.container.git.getBlameForLine(gitUri, blameLine); @@ -142,12 +133,6 @@ export class ShowQuickCommitFileCommand extends ActiveEditorCachedCommand { } } - // const shortSha = GitRevision.shorten(args.sha); - - // if (args.commit instanceof GitBlameCommit) { - // args.commit = (await this.container.git.getCommit(args.commit.repoPath, args.commit.ref))!; - // } - await executeGitCommand({ command: 'show', state: { @@ -156,49 +141,42 @@ export class ShowQuickCommitFileCommand extends ActiveEditorCachedCommand { fileName: path, }, }); - - // if (args.goBackCommand === undefined) { - // const commandArgs: ShowQuickCommitCommandArgs = { - // commit: args.commit, - // sha: args.sha, - // }; - - // // Create a command to get back to the commit details - // args.goBackCommand = new CommandQuickPickItem( - // { - // label: `go back ${GlyphChars.ArrowBack}`, - // description: `to details of ${GlyphChars.Space}$(git-commit) ${shortSha}`, - // }, - // Commands.ShowQuickCommit, - // [args.commit.toGitUri(), commandArgs], - // ); - // } - - // // Create a command to get back to where we are right now - // const currentCommand = new CommandQuickPickItem( - // { - // label: `go back ${GlyphChars.ArrowBack}`, - // description: `to details of ${args.commit.getFormattedPath()} from ${ - // GlyphChars.Space - // }$(git-commit) ${shortSha}`, - // }, - // Commands.ShowQuickCommitFile, - // [args.commit.toGitUri(), args], - // ); - - // const pick = await CommitFileQuickPick.show(args.commit as GitCommit, uri, { - // goBackCommand: args.goBackCommand, - // currentCommand: currentCommand, - // fileLog: args.fileLog, - // }); - // if (pick === undefined) return undefined; - - // if (pick instanceof CommandQuickPickItem) return pick.execute(); - - // return undefined; } catch (ex) { Logger.error(ex, 'ShowQuickCommitFileDetailsCommand'); void showGenericErrorMessage('Unable to show commit file details'); } } } + +@command() +export class ShowQuickCommitRevisionCommand extends ActiveEditorCachedCommand { + constructor(private readonly container: Container) { + super([ + Commands.ShowQuickCommitRevision, + Commands.ShowQuickCommitRevisionInDiffLeft, + Commands.ShowQuickCommitRevisionInDiffRight, + ]); + } + + async execute(editor?: TextEditor, uri?: Uri) { + uri = getCommandUri(uri, editor); + if (uri == null) return; + + try { + const gitUri = await GitUri.fromUri(uri); + if (gitUri?.sha == null) return; + + await executeGitCommand({ + command: 'show', + state: { + repo: gitUri.repoPath, + reference: createReference(gitUri.sha, gitUri.repoPath!, { refType: 'revision' }), + fileName: gitUri.fsPath, + }, + }); + } catch (ex) { + Logger.error(ex, 'ShowQuickCommitRevisionCommand'); + void showGenericErrorMessage('Unable to show commit details'); + } + } +} diff --git a/src/commands/showQuickFileHistory.ts b/src/commands/showQuickFileHistory.ts index 9e487c8f4d959..19a0d52c92700 100644 --- a/src/commands/showQuickFileHistory.ts +++ b/src/commands/showQuickFileHistory.ts @@ -1,5 +1,5 @@ import type { Range, TextEditor, Uri } from 'vscode'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { executeGitCommand } from '../git/actions'; import { GitUri } from '../git/gitUri'; @@ -8,7 +8,7 @@ import type { GitLog } from '../git/models/log'; import type { GitReference } from '../git/models/reference'; import type { GitTag } from '../git/models/tag'; import type { CommandQuickPickItem } from '../quickpicks/items/common'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import { ActiveEditorCachedCommand, getCommandUri } from './base'; diff --git a/src/commands/showQuickRepoStatus.ts b/src/commands/showQuickRepoStatus.ts index 57f53088ff741..e66cbefc2f016 100644 --- a/src/commands/showQuickRepoStatus.ts +++ b/src/commands/showQuickRepoStatus.ts @@ -1,7 +1,7 @@ -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { executeGitCommand } from '../git/actions'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; import { Command } from './base'; export interface ShowQuickRepoStatusCommandArgs { diff --git a/src/commands/showQuickStashList.ts b/src/commands/showQuickStashList.ts index 6a96bc1938d4b..a028b2ccc6b47 100644 --- a/src/commands/showQuickStashList.ts +++ b/src/commands/showQuickStashList.ts @@ -1,7 +1,7 @@ -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { executeGitCommand } from '../git/actions'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; import { Command } from './base'; export interface ShowQuickStashListCommandArgs { diff --git a/src/commands/showView.ts b/src/commands/showView.ts index 53366c78ff8e8..30238b17ae3d2 100644 --- a/src/commands/showView.ts +++ b/src/commands/showView.ts @@ -1,6 +1,7 @@ -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { command } from '../system/command'; +import type { GraphWebviewShowingArgs } from '../plus/webviews/graph/registration'; +import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import { Command } from './base'; @@ -8,11 +9,16 @@ import { Command } from './base'; export class ShowViewCommand extends Command { constructor(private readonly container: Container) { super([ + Commands.ShowAccountView, Commands.ShowBranchesView, Commands.ShowCommitDetailsView, Commands.ShowCommitsView, Commands.ShowContributorsView, + Commands.ShowDraftsView, Commands.ShowFileHistoryView, + Commands.ShowGraphView, + Commands.ShowHomeView, + Commands.ShowLaunchpadView, Commands.ShowLineHistoryView, Commands.ShowRemotesView, Commands.ShowRepositoriesView, @@ -21,16 +27,19 @@ export class ShowViewCommand extends Command { Commands.ShowTagsView, Commands.ShowTimelineView, Commands.ShowWorktreesView, - Commands.ShowHomeView, + Commands.ShowWorkspacesView, ]); } - protected override preExecute(context: CommandContext) { - return this.execute(context.command as Commands); + protected override preExecute(context: CommandContext, ...args: unknown[]) { + return this.execute(context, ...args); } - async execute(command: Commands) { + async execute(context: CommandContext, ...args: unknown[]) { + const command = context.command as Commands; switch (command) { + case Commands.ShowAccountView: + return this.container.accountView.show(); case Commands.ShowBranchesView: return this.container.branchesView.show(); case Commands.ShowCommitDetailsView: @@ -39,10 +48,16 @@ export class ShowViewCommand extends Command { return this.container.commitsView.show(); case Commands.ShowContributorsView: return this.container.contributorsView.show(); + case Commands.ShowDraftsView: + return this.container.draftsView.show(); case Commands.ShowFileHistoryView: return this.container.fileHistoryView.show(); + case Commands.ShowGraphView: + return this.container.graphView.show(undefined, ...(args as GraphWebviewShowingArgs)); case Commands.ShowHomeView: return this.container.homeView.show(); + case Commands.ShowLaunchpadView: + return this.container.launchpadView.show(); case Commands.ShowLineHistoryView: return this.container.lineHistoryView.show(); case Commands.ShowRemotesView: @@ -59,6 +74,8 @@ export class ShowViewCommand extends Command { return this.container.timelineView.show(); case Commands.ShowWorktreesView: return this.container.worktreesView.show(); + case Commands.ShowWorkspacesView: + return this.container.workspacesView.show(); } return Promise.resolve(undefined); diff --git a/src/commands/stashApply.ts b/src/commands/stashApply.ts index a681a42524d45..b890c6b185014 100644 --- a/src/commands/stashApply.ts +++ b/src/commands/stashApply.ts @@ -1,10 +1,10 @@ -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { apply, pop } from '../git/actions/stash'; import type { GitStashCommit } from '../git/models/commit'; import type { GitStashReference } from '../git/models/reference'; import type { CommandQuickPickItem } from '../quickpicks/items/common'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import { Command, isCommandContextViewNodeHasCommit, isCommandContextViewNodeHasRepository } from './base'; diff --git a/src/commands/stashSave.ts b/src/commands/stashSave.ts index 9bd0bfdfefce6..33f5568165539 100644 --- a/src/commands/stashSave.ts +++ b/src/commands/stashSave.ts @@ -1,11 +1,12 @@ import type { Uri } from 'vscode'; import type { ScmResource } from '../@types/vscode.git.resources'; -import { ScmResourceGroupType } from '../@types/vscode.git.resources.enums'; -import { Commands } from '../constants'; +import { ScmResourceGroupType, ScmStatus } from '../@types/vscode.git.resources.enums'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; +import { Features } from '../features'; import { push } from '../git/actions/stash'; import { GitUri } from '../git/gitUri'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; import type { CommandContext } from './base'; import { Command, @@ -18,7 +19,10 @@ export interface StashSaveCommandArgs { message?: string; repoPath?: string; uris?: Uri[]; + includeUntracked?: boolean; keepStaged?: boolean; + onlyStaged?: boolean; + onlyStagedUris?: Uri[]; } @command() @@ -38,41 +42,106 @@ export class StashSaveCommand extends Command { } else if (isCommandContextViewNodeHasRepoPath(context)) { args = { ...args }; args.repoPath = context.node.repoPath; + } else if (context.type === 'scm') { + if (context.scm.rootUri != null) { + const repo = this.container.git.getRepository(context.scm.rootUri); + if (repo != null) { + args = { ...args }; + args.repoPath = repo.path; + } + } } else if (context.type === 'scm-states') { args = { ...args }; - args.uris = context.scmResourceStates.map(s => s.resourceUri); - args.repoPath = (await this.container.git.getOrOpenRepository(args.uris[0]))?.path; - - const status = await this.container.git.getStatusForRepo(args.repoPath); - if (status?.computeWorkingTreeStatus().staged) { - if ( - !context.scmResourceStates.some( - s => (s as ScmResource).resourceGroupType === ScmResourceGroupType.Index, - ) - ) { - args.keepStaged = true; + + let hasOnlyStaged = undefined; + let hasStaged = false; + let hasUntracked = false; + + const uris: Uri[] = []; + + for (const resource of context.scmResourceStates as ScmResource[]) { + uris.push(resource.resourceUri); + if (resource.type === ScmStatus.UNTRACKED) { + hasUntracked = true; + } + + if (resource.resourceGroupType === ScmResourceGroupType.Index) { + hasStaged = true; + if (hasOnlyStaged == null) { + hasOnlyStaged = true; + } + } else { + hasOnlyStaged = false; } } + + const repo = await this.container.git.getOrOpenRepository(uris[0]); + + args.repoPath = repo?.path; + args.onlyStaged = repo != null && hasOnlyStaged ? await repo.supports(Features.StashOnlyStaged) : false; + if (args.keepStaged == null && !hasStaged) { + args.keepStaged = true; + } + args.includeUntracked = hasUntracked; + + args.uris = uris; } else if (context.type === 'scm-groups') { args = { ...args }; - args.uris = context.scmResourceGroups.reduce( - (a, b) => a.concat(b.resourceStates.map(s => s.resourceUri)), - [], - ); - args.repoPath = (await this.container.git.getOrOpenRepository(args.uris[0]))?.path; - - const status = await this.container.git.getStatusForRepo(args.repoPath); - if (status?.computeWorkingTreeStatus().staged) { - if (!context.scmResourceGroups.some(g => g.id === 'index')) { - args.keepStaged = true; + + let hasOnlyStaged = undefined; + let hasStaged = false; + let hasUntracked = false; + + const uris: Uri[] = []; + const stagedUris: Uri[] = []; + + for (const group of context.scmResourceGroups) { + for (const resource of group.resourceStates as ScmResource[]) { + uris.push(resource.resourceUri); + if (resource.type === ScmStatus.UNTRACKED) { + hasUntracked = true; + } + } + + if (group.id === 'index') { + hasStaged = true; + if (hasOnlyStaged == null) { + hasOnlyStaged = true; + } + stagedUris.push(...group.resourceStates.map(s => s.resourceUri)); + } else { + hasOnlyStaged = false; } } + + const repo = await this.container.git.getOrOpenRepository(uris[0]); + + args.repoPath = repo?.path; + args.onlyStaged = repo != null && hasOnlyStaged ? await repo.supports(Features.StashOnlyStaged) : false; + if (args.keepStaged == null && !hasStaged) { + args.keepStaged = true; + } + args.includeUntracked = hasUntracked; + + if (args.onlyStaged) { + args.onlyStagedUris = stagedUris; + } else { + args.uris = uris; + } } return this.execute(args); } execute(args?: StashSaveCommandArgs) { - return push(args?.repoPath, args?.uris, args?.message, args?.keepStaged); + return push( + args?.repoPath, + args?.uris, + args?.message, + args?.includeUntracked, + args?.keepStaged, + args?.onlyStaged, + args?.onlyStagedUris, + ); } } diff --git a/src/commands/switchAIModel.ts b/src/commands/switchAIModel.ts new file mode 100644 index 0000000000000..9a38ec5ba541d --- /dev/null +++ b/src/commands/switchAIModel.ts @@ -0,0 +1,15 @@ +import { Commands } from '../constants.commands'; +import type { Container } from '../container'; +import { command } from '../system/vscode/command'; +import { Command } from './base'; + +@command() +export class SwitchAIModelCommand extends Command { + constructor(private readonly container: Container) { + super(Commands.SwitchAIModel); + } + + async execute() { + await (await this.container.ai)?.switchModel(); + } +} diff --git a/src/commands/switchMode.ts b/src/commands/switchMode.ts index 865d43d10d2b8..1ddbdc762c6f5 100644 --- a/src/commands/switchMode.ts +++ b/src/commands/switchMode.ts @@ -1,11 +1,11 @@ import { ConfigurationTarget } from 'vscode'; -import { configuration } from '../configuration'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { getLogScope } from '../logScope'; -import { ModePicker } from '../quickpicks/modePicker'; -import { command } from '../system/command'; +import { showModePicker } from '../quickpicks/modePicker'; import { log } from '../system/decorators/log'; +import { getLogScope, setLogScopeExit } from '../system/logger.scope'; +import { command } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; import { Command } from './base'; @command() @@ -18,12 +18,10 @@ export class SwitchModeCommand extends Command { async execute() { const scope = getLogScope(); - const pick = await ModePicker.show(); + const pick = await showModePicker(); if (pick === undefined) return; - if (scope != null) { - scope.exitDetails = ` \u2014 mode=${pick.key ?? ''}`; - } + setLogScopeExit(scope, ` \u2022 mode=${pick.key ?? ''}`); const active = configuration.get('mode.active'); if (active === pick.key) return; diff --git a/src/commands/toggleCodeLens.ts b/src/commands/toggleCodeLens.ts index fdffd117e2a7c..c9d89a83fe46b 100644 --- a/src/commands/toggleCodeLens.ts +++ b/src/commands/toggleCodeLens.ts @@ -1,6 +1,6 @@ -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { command } from '../system/command'; +import { command } from '../system/vscode/command'; import { Command } from './base'; @command() @@ -10,6 +10,6 @@ export class ToggleCodeLensCommand extends Command { } execute() { - return this.container.codeLens.toggleCodeLens(); + this.container.codeLens.toggleCodeLens(); } } diff --git a/src/commands/toggleFileAnnotations.ts b/src/commands/toggleFileAnnotations.ts index 0c583b8de6fe0..92327fba4bc56 100644 --- a/src/commands/toggleFileAnnotations.ts +++ b/src/commands/toggleFileAnnotations.ts @@ -1,15 +1,12 @@ import type { TextEditor, TextEditorEdit, Uri } from 'vscode'; -import { window } from 'vscode'; import type { AnnotationContext } from '../annotations/annotationProvider'; import type { ChangesAnnotationContext } from '../annotations/gutterChangesAnnotationProvider'; -import { UriComparer } from '../comparers'; -import { FileAnnotationType } from '../configuration'; -import { Commands } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { Logger } from '../logger'; import { showGenericErrorMessage } from '../messages'; -import { command } from '../system/command'; -import { isTextEditor } from '../system/utils'; +import { Logger } from '../system/logger'; +import { command } from '../system/vscode/command'; +import { getEditorIfVisible, isTrackableTextEditor } from '../system/vscode/utils'; import { ActiveEditorCommand, EditorCommand } from './base'; @command() @@ -18,16 +15,9 @@ export class ClearFileAnnotationsCommand extends EditorCommand { super([Commands.ClearFileAnnotations, Commands.ComputingFileAnnotations]); } - async execute(editor: TextEditor, edit: TextEditorEdit, uri?: Uri): Promise { - // Handle the case where we are focused on a non-editor editor (output, debug console) - if (editor != null && !isTextEditor(editor)) { - if (uri != null && !UriComparer.equals(uri, editor.document.uri)) { - const e = window.visibleTextEditors.find(e => UriComparer.equals(uri, e.document.uri)); - if (e != null) { - editor = e; - } - } - } + async execute(editor: TextEditor | undefined, _edit: TextEditorEdit, uri?: Uri): Promise { + editor = getValidEditor(editor, uri); + if (editor == null) return; try { await this.container.fileAnnotations.clear(editor); @@ -39,19 +29,19 @@ export class ClearFileAnnotationsCommand extends EditorCommand { } export interface ToggleFileBlameAnnotationCommandArgs { - type: FileAnnotationType.Blame; + type: 'blame'; context?: AnnotationContext; on?: boolean; } export interface ToggleFileChangesAnnotationCommandArgs { - type: FileAnnotationType.Changes; + type: 'changes'; context?: ChangesAnnotationContext; on?: boolean; } export interface ToggleFileHeatmapAnnotationCommandArgs { - type: FileAnnotationType.Heatmap; + type: 'heatmap'; context?: AnnotationContext; on?: boolean; } @@ -70,7 +60,7 @@ export class ToggleFileBlameCommand extends ActiveEditorCommand { execute(editor: TextEditor, uri?: Uri, args?: ToggleFileBlameAnnotationCommandArgs): Promise { return toggleFileAnnotations(this.container, editor, uri, { ...args, - type: FileAnnotationType.Blame, + type: 'blame', }); } } @@ -84,7 +74,7 @@ export class ToggleFileChangesCommand extends ActiveEditorCommand { execute(editor: TextEditor, uri?: Uri, args?: ToggleFileChangesAnnotationCommandArgs): Promise { return toggleFileAnnotations(this.container, editor, uri, { ...args, - type: FileAnnotationType.Changes, + type: 'changes', }); } } @@ -102,29 +92,21 @@ export class ToggleFileHeatmapCommand extends ActiveEditorCommand { execute(editor: TextEditor, uri?: Uri, args?: ToggleFileHeatmapAnnotationCommandArgs): Promise { return toggleFileAnnotations(this.container, editor, uri, { ...args, - type: FileAnnotationType.Heatmap, + type: 'heatmap', }); } } async function toggleFileAnnotations( container: Container, - editor: TextEditor, + editor: TextEditor | undefined, uri: Uri | undefined, args: TArgs, ): Promise { - // Handle the case where we are focused on a non-editor editor (output, debug console) - if (editor != null && !isTextEditor(editor)) { - if (uri != null && !UriComparer.equals(uri, editor.document.uri)) { - const e = window.visibleTextEditors.find(e => UriComparer.equals(uri, e.document.uri)); - if (e != null) { - editor = e; - } - } - } + editor = getValidEditor(editor, uri); try { - args = { type: FileAnnotationType.Blame, ...(args as any) }; + args = { type: 'blame', ...(args as any) }; void (await container.fileAnnotations.toggle( editor, @@ -140,3 +122,19 @@ async function toggleFileAnnotations | null; - outputLevel: OutputLevel; - partners: Record< + readonly modes: Record | null; + readonly outputLevel: OutputLevel; + readonly partners: Record< string, { - enabled: boolean; - [key: string]: any; + readonly enabled: boolean; + readonly [key: string]: any; } > | null; - plusFeatures: { - enabled: boolean; + readonly plusFeatures: { + readonly enabled: boolean; }; - proxy: { - url: string | null; - strictSSL: boolean; + readonly proxy: { + readonly url: string | null; + readonly strictSSL: boolean; } | null; - rebaseEditor: { - ordering: 'asc' | 'desc'; - showDetailsView: 'open' | 'selection' | false; - }; - remotes: RemotesConfig[] | null; - showWelcomeOnInstall: boolean; - showWhatsNewAfterUpgrades: boolean; - sortBranchesBy: BranchSorting; - sortContributorsBy: ContributorSorting; - sortTagsBy: TagSorting; - statusBar: { - alignment: 'left' | 'right'; - command: StatusBarCommand; - dateFormat: DateTimeFormat | string | null; - enabled: boolean; - format: string; - reduceFlicker: boolean; - pullRequests: { - enabled: boolean; + readonly rebaseEditor: { + readonly ordering: 'asc' | 'desc'; + readonly showDetailsView: 'open' | 'selection' | false; + }; + readonly remotes: RemotesConfig[] | null; + readonly showWelcomeOnInstall: boolean; + readonly showWhatsNewAfterUpgrades: boolean; + readonly sortBranchesBy: BranchSorting; + readonly sortContributorsBy: ContributorSorting; + readonly sortTagsBy: TagSorting; + readonly sortRepositoriesBy: RepositoriesSorting; + readonly statusBar: { + readonly alignment: 'left' | 'right'; + readonly command: StatusBarCommand; + readonly dateFormat: DateTimeFormat | (string & object) | null; + /*readonly*/ enabled: boolean; + readonly format: string; + readonly reduceFlicker: boolean; + readonly pullRequests: { + readonly enabled: boolean; }; - tooltipFormat: string; - }; - strings: { - codeLens: { - unsavedChanges: { - recentChangeAndAuthors: string; - recentChangeOnly: string; - authorsOnly: string; + readonly tooltipFormat: string; + }; + readonly strings: { + readonly codeLens: { + readonly unsavedChanges: { + readonly recentChangeAndAuthors: string; + readonly recentChangeOnly: string; + readonly authorsOnly: string; }; }; }; - telemetry: { - enabled: boolean; + readonly telemetry: { + readonly enabled: boolean; }; - terminal: { - overrideGitEditor: boolean; + readonly terminal: { + readonly overrideGitEditor: boolean; }; - terminalLinks: { - enabled: boolean; - showDetailsView: boolean; + readonly terminalLinks: { + readonly enabled: boolean; + readonly showDetailsView: boolean; }; - views: ViewsConfig; - virtualRepositories: { - enabled: boolean; + readonly views: ViewsConfig; + readonly virtualRepositories: { + readonly enabled: boolean; }; - visualHistory: { - queryLimit: number; + readonly visualHistory: { + readonly allowMultiple: boolean; + readonly queryLimit: number; }; - worktrees: { - defaultLocation: string | null; - openAfterCreate: 'always' | 'alwaysNewWindow' | 'onlyWhenEmpty' | 'never' | 'prompt'; - promptForLocation: boolean; + readonly worktrees: { + readonly defaultLocation: string | null; + readonly openAfterCreate: 'always' | 'alwaysNewWindow' | 'onlyWhenEmpty' | 'never' | 'prompt'; + readonly promptForLocation: boolean; }; - advanced: AdvancedConfig; -} - -export const enum AnnotationsToggleMode { - File = 'file', - Window = 'window', + readonly advanced: AdvancedConfig; } -export const enum AutolinkType { - Issue = 'Issue', - PullRequest = 'PullRequest', -} +export type AnnotationsToggleMode = 'file' | 'window'; +export type AutolinkType = 'issue' | 'pullrequest'; export interface AutolinkReference { - prefix: string; - url: string; - title?: string; - alphanumeric?: boolean; - ignoreCase?: boolean; + readonly prefix: string; + readonly url: string; + readonly title?: string; + readonly alphanumeric?: boolean; + readonly ignoreCase?: boolean; - type?: AutolinkType; - description?: string; + readonly type?: AutolinkType; + readonly description?: string; + readonly descriptor?: ResourceDescriptor; } -export const enum BlameHighlightLocations { - Gutter = 'gutter', - Line = 'line', - Scrollbar = 'overview', -} - -export const enum BranchSorting { - DateDesc = 'date:desc', - DateAsc = 'date:asc', - NameAsc = 'name:asc', - NameDesc = 'name:desc', -} - -export const enum ChangesLocations { - Gutter = 'gutter', - Line = 'line', - Scrollbar = 'overview', -} +export type BlameHighlightLocations = 'gutter' | 'line' | 'overview'; +export type BranchSorting = 'date:desc' | 'date:asc' | 'name:asc' | 'name:desc'; +export type ChangesLocations = 'gutter' | 'line' | 'overview'; export const enum CodeLensCommand { CopyRemoteCommitUrl = 'gitlens.copyRemoteCommitUrl', @@ -241,101 +288,46 @@ export const enum CodeLensCommand { ToggleFileHeatmap = 'gitlens.toggleFileHeatmap', } -export const enum CodeLensScopes { - Document = 'document', - Containers = 'containers', - Blocks = 'blocks', -} - -export const enum ContributorSorting { - CountDesc = 'count:desc', - CountAsc = 'count:asc', - DateDesc = 'date:desc', - DateAsc = 'date:asc', - NameAsc = 'name:asc', - NameDesc = 'name:desc', -} - -export const enum CustomRemoteType { - AzureDevOps = 'AzureDevOps', - Bitbucket = 'Bitbucket', - BitbucketServer = 'BitbucketServer', - Custom = 'Custom', - Gerrit = 'Gerrit', - GoogleSource = 'GoogleSource', - Gitea = 'Gitea', - GitHub = 'GitHub', - GitLab = 'GitLab', -} - -export const enum DateSource { - Authored = 'authored', - Committed = 'committed', -} - -export const enum DateStyle { - Absolute = 'absolute', - Relative = 'relative', -} - -export const enum FileAnnotationType { - Blame = 'blame', - Changes = 'changes', - Heatmap = 'heatmap', -} - -export const enum GitCommandSorting { - Name = 'name', - Usage = 'usage', -} - -export const enum GraphScrollMarkerTypes { - Selection = 'selection', - Head = 'head', - LocalBranches = 'localBranches', - RemoteBranches = 'remoteBranches', - Highlights = 'highlights', - Stashes = 'stashes', - Tags = 'tags', -} - -export const enum GraphMinimapTypes { - Selection = 'selection', - Head = 'head', - LocalBranches = 'localBranches', - RemoteBranches = 'remoteBranches', - Highlights = 'highlights', - Stashes = 'stashes', - Tags = 'tags', -} - -export const enum GravatarDefaultStyle { - Faces = 'wavatar', - Geometric = 'identicon', - Monster = 'monsterid', - MysteryPerson = 'mp', - Retro = 'retro', - Robot = 'robohash', -} - -export const enum HeatmapLocations { - Gutter = 'gutter', - Line = 'line', - Scrollbar = 'overview', -} - -export const enum KeyMap { - Alternate = 'alternate', - Chorded = 'chorded', - None = 'none', -} - -export const enum OutputLevel { - Silent = 'silent', - Errors = 'errors', - Verbose = 'verbose', - Debug = 'debug', -} +export type CodeLensScopes = 'document' | 'containers' | 'blocks'; +export type ContributorSorting = 'count:desc' | 'count:asc' | 'date:desc' | 'date:asc' | 'name:asc' | 'name:desc'; +export type RepositoriesSorting = 'discovered' | 'lastFetched:desc' | 'lastFetched:asc' | 'name:asc' | 'name:desc'; +export type CustomRemoteType = + | 'AzureDevOps' + | 'Bitbucket' + | 'BitbucketServer' + | 'Custom' + | 'Gerrit' + | 'GoogleSource' + | 'Gitea' + | 'GitHub' + | 'GitLab'; + +export type DateSource = 'authored' | 'committed'; +export type DateStyle = 'absolute' | 'relative'; +export type FileAnnotationType = 'blame' | 'changes' | 'heatmap'; +export type GitCommandSorting = 'name' | 'usage'; +export type GraphBranchesVisibility = 'all' | 'smart' | 'current'; +export type GraphScrollMarkersAdditionalTypes = + | 'localBranches' + | 'remoteBranches' + | 'stashes' + | 'tags' + | 'pullRequests'; +export type GraphMinimapMarkersAdditionalTypes = + | 'localBranches' + | 'remoteBranches' + | 'stashes' + | 'tags' + | 'pullRequests'; +export type GravatarDefaultStyle = 'wavatar' | 'identicon' | 'monsterid' | 'mp' | 'retro' | 'robohash'; +export type HeatmapLocations = 'gutter' | 'line' | 'overview'; +export type KeyMap = 'alternate' | 'chorded' | 'none'; + +type DeprecatedOutputLevel = + | /** @deprecated use `off` */ 'silent' + | /** @deprecated use `error` */ 'errors' + | /** @deprecated use `info` */ 'verbose'; +export type OutputLevel = LogLevel | DeprecatedOutputLevel; export const enum StatusBarCommand { CopyRemoteCommitUrl = 'gitlens.copyRemoteCommitUrl', @@ -357,471 +349,590 @@ export const enum StatusBarCommand { ToggleFileHeatmap = 'gitlens.toggleFileHeatmap', } -export const enum TagSorting { - DateDesc = 'date:desc', - DateAsc = 'date:asc', - NameAsc = 'name:asc', - NameDesc = 'name:desc', -} - -export const enum ViewBranchesLayout { - List = 'list', - Tree = 'tree', -} - -export const enum ViewFilesLayout { - Auto = 'auto', - List = 'list', - Tree = 'tree', -} +export type TagSorting = 'date:desc' | 'date:asc' | 'name:asc' | 'name:desc'; -export const enum ViewShowBranchComparison { - Branch = 'branch', - Working = 'working', -} +export type ViewBranchesLayout = 'list' | 'tree'; +export type ViewFilesLayout = 'auto' | 'list' | 'tree'; +export type ViewShowBranchComparison = 'branch' | 'working'; export interface AdvancedConfig { - abbreviatedShaLength: number; - abbreviateShaOnCopy: boolean; - blame: { - customArguments: string[] | null; - delayAfterEdit: number; - sizeThresholdAfterEdit: number; - }; - caching: { - enabled: boolean; - }; - commitOrdering: 'date' | 'author-date' | 'topo' | null; - externalDiffTool: string | null; - externalDirectoryDiffTool: string | null; - fileHistoryFollowsRenames: boolean; - fileHistoryShowAllBranches: boolean; - maxListItems: number; - maxSearchItems: number; - messages: { [key in SuppressedMessages]: boolean }; - quickPick: { - closeOnFocusOut: boolean; - }; - repositorySearchDepth: number | null; - similarityThreshold: number | null; + readonly abbreviatedShaLength: number; + readonly abbreviateShaOnCopy: boolean; + readonly blame: { + readonly customArguments: string[] | null; + readonly delayAfterEdit: number; + readonly sizeThresholdAfterEdit: number; + }; + readonly caching: { + readonly enabled: boolean; + }; + readonly commitOrdering: 'date' | 'author-date' | 'topo' | null; + readonly externalDiffTool: string | null; + readonly externalDirectoryDiffTool: string | null; + readonly fileHistoryFollowsRenames: boolean; + readonly fileHistoryShowAllBranches: boolean; + readonly fileHistoryShowMergeCommits: boolean; + readonly maxListItems: number; + readonly maxSearchItems: number; + readonly messages: { [key in SuppressedMessages]: boolean }; + readonly quickPick: { + readonly closeOnFocusOut: boolean; + }; + readonly repositorySearchDepth: number | null; + readonly similarityThreshold: number | null; } export interface GraphConfig { - avatars: boolean; - commitOrdering: 'date' | 'author-date' | 'topo'; - dateFormat: DateTimeFormat | string | null; - dateStyle: DateStyle | null; - defaultItemLimit: number; - dimMergeCommits: boolean; - experimental: { - minimap: { - enabled: boolean; - additionalTypes: GraphMinimapTypes[]; - }; - }; - highlightRowsOnRefHover: boolean; - scrollRowPadding: number; - showDetailsView: 'open' | 'selection' | false; - showGhostRefsOnRowHover: boolean; - scrollMarkers: { - enabled: boolean; - additionalTypes: GraphScrollMarkerTypes[]; - }; - pullRequests: { - enabled: boolean; - }; - showRemoteNames: boolean; - showUpstreamStatus: boolean; - pageItemLimit: number; - searchItemLimit: number; - statusBar: { - enabled: boolean; + readonly allowMultiple: boolean; + readonly avatars: boolean; + readonly branchesVisibility: GraphBranchesVisibility; + readonly commitOrdering: 'date' | 'author-date' | 'topo'; + readonly dateFormat: DateTimeFormat | string | null; + readonly dateStyle: DateStyle | null; + readonly defaultItemLimit: number; + readonly dimMergeCommits: boolean; + readonly highlightRowsOnRefHover: boolean; + readonly layout: 'editor' | 'panel'; + readonly minimap: { + readonly enabled: boolean; + readonly dataType: 'commits' | 'lines'; + readonly additionalTypes: GraphMinimapMarkersAdditionalTypes[]; + }; + readonly onlyFollowFirstParent: boolean; + readonly pageItemLimit: number; + readonly pullRequests: { + readonly enabled: boolean; + }; + readonly scrollMarkers: { + readonly enabled: boolean; + readonly additionalTypes: GraphScrollMarkersAdditionalTypes[]; + }; + readonly scrollRowPadding: number; + readonly searchItemLimit: number; + readonly showDetailsView: 'open' | 'selection' | false; + readonly showGhostRefsOnRowHover: boolean; + readonly showRemoteNames: boolean; + readonly showUpstreamStatus: boolean; + readonly sidebar: { + readonly enabled: boolean; + }; + readonly statusBar: { + readonly enabled: boolean; }; } export interface CodeLensConfig { - authors: { - enabled: boolean; - command: CodeLensCommand | false; + readonly authors: { + readonly enabled: boolean; + readonly command: CodeLensCommand | false; }; - dateFormat: DateTimeFormat | string | null; - enabled: boolean; - includeSingleLineSymbols: boolean; - recentChange: { - enabled: boolean; - command: CodeLensCommand | false; + readonly dateFormat: DateTimeFormat | string | null; + /*readonly*/ enabled: boolean; + readonly includeSingleLineSymbols: boolean; + readonly recentChange: { + readonly enabled: boolean; + readonly command: CodeLensCommand | false; }; - scopes: CodeLensScopes[]; - scopesByLanguage: CodeLensLanguageScope[] | null; - symbolScopes: string[]; + readonly scopes: CodeLensScopes[]; + readonly scopesByLanguage: CodeLensLanguageScope[] | null; + readonly symbolScopes: string[]; } export interface CodeLensLanguageScope { - language: string | undefined; - scopes?: CodeLensScopes[]; - symbolScopes?: string[]; + readonly language: string | undefined; + readonly scopes?: CodeLensScopes[]; + readonly symbolScopes?: string[]; } export interface MenuConfig { - editor: + readonly editor: | false | { - blame: boolean; - clipboard: boolean; - compare: boolean; - history: boolean; - remote: boolean; + readonly blame: boolean; + readonly clipboard: boolean; + readonly compare: boolean; + readonly history: boolean; + readonly remote: boolean; }; - editorGroup: + readonly editorGroup: | false | { - blame: boolean; - compare: boolean; + readonly blame: boolean; + readonly compare: boolean; }; - editorTab: + readonly editorGutter: | false | { - clipboard: boolean; - compare: boolean; - history: boolean; - remote: boolean; + readonly compare: boolean; + readonly remote: boolean; + readonly share: boolean; }; - explorer: + readonly editorTab: | false | { - clipboard: boolean; - compare: boolean; - history: boolean; - remote: boolean; + readonly clipboard: boolean; + readonly compare: boolean; + readonly history: boolean; + readonly remote: boolean; }; - scm: + readonly explorer: | false | { - graph: boolean; + readonly clipboard: boolean; + readonly compare: boolean; + readonly history: boolean; + readonly remote: boolean; }; - scmTitleInline: + readonly ghpr: | false | { - graph: boolean; + readonly worktree: boolean; }; - scmTitle: + readonly scm: | false | { - authors: boolean; - graph: boolean; + readonly graph: boolean; }; - scmGroupInline: + readonly scmRepositoryInline: false | { readonly graph: boolean; readonly stash: boolean }; + readonly scmRepository: | false | { - stash: boolean; + readonly authors: boolean; + readonly generateCommitMessage: boolean; + readonly patch: boolean; + readonly graph: boolean; }; - scmGroup: + readonly scmGroupInline: | false | { - compare: boolean; - openClose: boolean; - stash: boolean; + readonly stash: boolean; }; - scmItemInline: + readonly scmGroup: | false | { - stash: boolean; + readonly compare: boolean; + readonly openClose: boolean; + readonly patch: boolean; + readonly stash: boolean; }; - scmItem: + readonly scmItemInline: | false | { - clipboard: boolean; - compare: boolean; - history: boolean; - remote: boolean; - stash: boolean; + readonly stash: boolean; + }; + readonly scmItem: + | false + | { + readonly clipboard: boolean; + readonly compare: boolean; + readonly history: boolean; + readonly remote: boolean; + readonly share: boolean; + readonly stash: boolean; }; } export interface ModeConfig { - name: string; - statusBarItemName?: string; - description?: string; - annotations?: 'blame' | 'changes' | 'heatmap'; - codeLens?: boolean; - currentLine?: boolean; - hovers?: boolean; - statusBar?: boolean; + readonly name: string; + readonly statusBarItemName?: string; + readonly description?: string; + readonly annotations?: 'blame' | 'changes' | 'heatmap'; + readonly codeLens?: boolean; + readonly currentLine?: boolean; + readonly hovers?: boolean; + readonly statusBar?: boolean; } export type RemotesConfig = | { - domain: string; - regex: null; - name?: string; - protocol?: string; - type: CustomRemoteType; - urls?: RemotesUrlsConfig; - ignoreSSLErrors?: boolean | 'force'; + readonly domain: string; + readonly regex: null; + readonly name?: string; + readonly protocol?: string; + readonly type: CustomRemoteType; + readonly urls?: RemotesUrlsConfig; + readonly ignoreSSLErrors?: boolean | 'force'; } | { - domain: null; - regex: string; - name?: string; - protocol?: string; - type: CustomRemoteType; - urls?: RemotesUrlsConfig; - ignoreSSLErrors?: boolean | 'force'; + readonly domain: null; + readonly regex: string; + readonly name?: string; + readonly protocol?: string; + readonly type: CustomRemoteType; + readonly urls?: RemotesUrlsConfig; + readonly ignoreSSLErrors?: boolean | 'force'; }; export interface RemotesUrlsConfig { - repository: string; - branches: string; - branch: string; - commit: string; - comparison?: string; - file: string; - fileInBranch: string; - fileInCommit: string; - fileLine: string; - fileRange: string; + readonly repository: string; + readonly branches: string; + readonly branch: string; + readonly commit: string; + readonly comparison?: string; + readonly file: string; + readonly fileInBranch: string; + readonly fileInCommit: string; + readonly fileLine: string; + readonly fileRange: string; } // NOTE: Must be kept in sync with `gitlens.advanced.messages` setting in the package.json -export const enum SuppressedMessages { - CommitHasNoPreviousCommitWarning = 'suppressCommitHasNoPreviousCommitWarning', - CommitNotFoundWarning = 'suppressCommitNotFoundWarning', - CreatePullRequestPrompt = 'suppressCreatePullRequestPrompt', - SuppressDebugLoggingWarning = 'suppressDebugLoggingWarning', - FileNotUnderSourceControlWarning = 'suppressFileNotUnderSourceControlWarning', - GitDisabledWarning = 'suppressGitDisabledWarning', - GitMissingWarning = 'suppressGitMissingWarning', - GitVersionWarning = 'suppressGitVersionWarning', - LineUncommittedWarning = 'suppressLineUncommittedWarning', - NoRepositoryWarning = 'suppressNoRepositoryWarning', - RebaseSwitchToTextWarning = 'suppressRebaseSwitchToTextWarning', - IntegrationDisconnectedTooManyFailedRequestsWarning = 'suppressIntegrationDisconnectedTooManyFailedRequestsWarning', - IntegrationRequestFailed500Warning = 'suppressIntegrationRequestFailed500Warning', - IntegrationRequestTimedOutWarning = 'suppressIntegrationRequestTimedOutWarning', -} +export type SuppressedMessages = + | 'suppressCommitHasNoPreviousCommitWarning' + | 'suppressCommitNotFoundWarning' + | 'suppressCreatePullRequestPrompt' + | 'suppressDebugLoggingWarning' + | 'suppressFileNotUnderSourceControlWarning' + | 'suppressGitDisabledWarning' + | 'suppressGitMissingWarning' + | 'suppressGitVersionWarning' + | 'suppressLineUncommittedWarning' + | 'suppressNoRepositoryWarning' + | 'suppressRebaseSwitchToTextWarning' + | 'suppressGkDisconnectedTooManyFailedRequestsWarningMessage' + | 'suppressGkRequestFailed500Warning' + | 'suppressGkRequestTimedOutWarning' + | 'suppressIntegrationDisconnectedTooManyFailedRequestsWarning' + | 'suppressIntegrationRequestFailed500Warning' + | 'suppressIntegrationRequestTimedOutWarning' + | 'suppressBlameInvalidIgnoreRevsFileWarning' + | 'suppressBlameInvalidIgnoreRevsFileBadRevisionWarning'; export interface ViewsCommonConfig { - defaultItemLimit: number; - formats: { - commits: { - label: string; - description: string; - tooltip: string; - tooltipWithStatus: string; + readonly collapseWorktreesWhenPossible: boolean; + readonly defaultItemLimit: number; + readonly formats: { + readonly commits: { + readonly label: string; + readonly description: string; + readonly tooltip: string; + readonly tooltipWithStatus: string; }; - files: { - label: string; - description: string; + readonly files: { + readonly label: string; + readonly description: string; }; - stashes: { - label: string; - description: string; - }; - }; - pageItemLimit: number; - showRelativeDateMarkers: boolean; - - experimental: { - multiSelect: { - enabled: boolean | null | undefined; + readonly stashes: { + readonly label: string; + readonly description: string; + readonly tooltip: string; }; }; + readonly openChangesInMultiDiffEditor: boolean; + readonly pageItemLimit: number; + readonly showCurrentBranchOnTop: boolean; + readonly showRelativeDateMarkers: boolean; } export const viewsCommonConfigKeys: (keyof ViewsCommonConfig)[] = [ + 'collapseWorktreesWhenPossible', 'defaultItemLimit', 'formats', 'pageItemLimit', + 'showCurrentBranchOnTop', 'showRelativeDateMarkers', ]; interface ViewsConfigs { - branches: BranchesViewConfig; - commits: CommitsViewConfig; - commitDetails: CommitDetailsViewConfig; - contributors: ContributorsViewConfig; - fileHistory: FileHistoryViewConfig; - lineHistory: LineHistoryViewConfig; - remotes: RemotesViewConfig; - repositories: RepositoriesViewConfig; - searchAndCompare: SearchAndCompareViewConfig; - stashes: StashesViewConfig; - tags: TagsViewConfig; - worktrees: WorktreesViewConfig; + readonly branches: BranchesViewConfig; + readonly commits: CommitsViewConfig; + readonly commitDetails: CommitDetailsViewConfig; + readonly contributors: ContributorsViewConfig; + readonly drafts: DraftsViewConfig; + readonly fileHistory: FileHistoryViewConfig; + readonly launchpad: LaunchpadViewConfig; + readonly lineHistory: LineHistoryViewConfig; + readonly patchDetails: PatchDetailsViewConfig; + readonly pullRequest: PullRequestViewConfig; + readonly remotes: RemotesViewConfig; + readonly repositories: RepositoriesViewConfig; + readonly searchAndCompare: SearchAndCompareViewConfig; + readonly stashes: StashesViewConfig; + readonly tags: TagsViewConfig; + readonly worktrees: WorktreesViewConfig; + readonly workspaces: WorkspacesViewConfig; } export type ViewsConfigKeys = keyof ViewsConfigs; export const viewsConfigKeys: ViewsConfigKeys[] = [ + 'branches', 'commits', - 'repositories', + 'commitDetails', + 'contributors', + 'drafts', 'fileHistory', 'lineHistory', - 'branches', + 'patchDetails', + 'pullRequest', 'remotes', + 'repositories', + 'searchAndCompare', 'stashes', 'tags', - 'contributors', - 'searchAndCompare', 'worktrees', + 'workspaces', ]; export type ViewsConfig = ViewsCommonConfig & ViewsConfigs; export interface BranchesViewConfig { - avatars: boolean; - branches: { - layout: ViewBranchesLayout; + readonly avatars: boolean; + readonly branches: { + readonly layout: ViewBranchesLayout; }; - files: ViewsFilesConfig; - pullRequests: { - enabled: boolean; - showForBranches: boolean; - showForCommits: boolean; + readonly files: ViewsFilesConfig; + readonly pullRequests: { + readonly enabled: boolean; + readonly showForBranches: boolean; + readonly showForCommits: boolean; }; - reveal: boolean; - showBranchComparison: false | ViewShowBranchComparison.Branch; + readonly reveal: boolean; + readonly showBranchComparison: false | Extract; } export interface CommitsViewConfig { - avatars: boolean; - branches: undefined; - files: ViewsFilesConfig; - pullRequests: { - enabled: boolean; - showForBranches: boolean; - showForCommits: boolean; + readonly avatars: boolean; + readonly branches: undefined; + readonly files: ViewsFilesConfig; + readonly pullRequests: { + readonly enabled: boolean; + readonly showForBranches: boolean; + readonly showForCommits: boolean; }; - reveal: boolean; - showBranchComparison: false | ViewShowBranchComparison; + readonly reveal: boolean; + readonly showBranchComparison: false | ViewShowBranchComparison; } export interface CommitDetailsViewConfig { - avatars: boolean; - files: ViewsFilesConfig; - autolinks: { - enabled: boolean; - enhanced: boolean; + readonly avatars: boolean; + readonly files: ViewsFilesConfig; + readonly autolinks: { + readonly enabled: boolean; + readonly enhanced: boolean; }; - pullRequests: { - enabled: boolean; + readonly pullRequests: { + readonly enabled: boolean; }; } +export interface PatchDetailsViewConfig { + readonly avatars: boolean; + readonly files: ViewsFilesConfig; +} + export interface ContributorsViewConfig { - avatars: boolean; - files: ViewsFilesConfig; - pullRequests: { - enabled: boolean; - showForCommits: boolean; + readonly avatars: boolean; + readonly files: ViewsFilesConfig; + readonly pullRequests: { + readonly enabled: boolean; + readonly showForCommits: boolean; }; - reveal: boolean; - showAllBranches: boolean; - showStatistics: boolean; + readonly reveal: boolean; + readonly showAllBranches: boolean; + readonly showStatistics: boolean; +} + +export interface DraftsViewConfig { + readonly avatars: boolean; + readonly branches: undefined; + readonly files: ViewsFilesConfig; + readonly pullRequests: undefined; + readonly reveal: undefined; } export interface FileHistoryViewConfig { - avatars: boolean; - files: ViewsFilesConfig; + readonly avatars: boolean; + readonly files: ViewsFilesConfig; + readonly pullRequests: { + readonly enabled: boolean; + readonly showForCommits: boolean; + }; +} + +export interface LaunchpadViewConfig { + readonly enabled: boolean; + + readonly avatars: boolean; + readonly files: ViewsFilesConfig; + readonly pullRequests: { + readonly enabled: boolean; + readonly showForCommits: boolean; + }; } export interface LineHistoryViewConfig { - avatars: boolean; + readonly avatars: boolean; +} + +export interface PullRequestViewConfig { + readonly avatars: boolean; + readonly branches: undefined; + readonly files: ViewsFilesConfig; + readonly pullRequests: undefined; + readonly reveal: undefined; + readonly showBranchComparison: undefined; } export interface RemotesViewConfig { - avatars: boolean; - branches: { - layout: ViewBranchesLayout; + readonly avatars: boolean; + readonly branches: { + readonly layout: ViewBranchesLayout; }; - files: ViewsFilesConfig; - pullRequests: { - enabled: boolean; - showForBranches: boolean; - showForCommits: boolean; + readonly files: ViewsFilesConfig; + readonly pullRequests: { + readonly enabled: boolean; + readonly showForBranches: boolean; + readonly showForCommits: boolean; }; - reveal: boolean; + readonly reveal: boolean; } export interface RepositoriesViewConfig { - autoRefresh: boolean; - autoReveal: boolean; - avatars: boolean; - branches: { - layout: ViewBranchesLayout; - showBranchComparison: false | ViewShowBranchComparison.Branch; - }; - compact: boolean; - files: ViewsFilesConfig; - includeWorkingTree: boolean; - pullRequests: { - enabled: boolean; - showForBranches: boolean; - showForCommits: boolean; - }; - showBranchComparison: false | ViewShowBranchComparison; - showBranches: boolean; - showCommits: boolean; - showContributors: boolean; - showIncomingActivity: boolean; - showRemotes: boolean; - showStashes: boolean; - showTags: boolean; - showUpstreamStatus: boolean; - showWorktrees: boolean; + readonly autoRefresh: boolean; + readonly autoReveal: boolean; + readonly avatars: boolean; + readonly branches: { + readonly layout: ViewBranchesLayout; + readonly showBranchComparison: false | Extract; + }; + readonly compact: boolean; + readonly files: ViewsFilesConfig; + readonly includeWorkingTree: boolean; + readonly pullRequests: { + readonly enabled: boolean; + readonly showForBranches: boolean; + readonly showForCommits: boolean; + }; + readonly showBranchComparison: false | ViewShowBranchComparison; + readonly showBranches: boolean; + readonly showCommits: boolean; + readonly showContributors: boolean; + readonly showIncomingActivity: boolean; + readonly showRemotes: boolean; + readonly showStashes: boolean; + readonly showTags: boolean; + readonly showUpstreamStatus: boolean; + readonly showWorktrees: boolean; } export interface SearchAndCompareViewConfig { - avatars: boolean; - files: ViewsFilesConfig; - pullRequests: { - enabled: boolean; - showForCommits: boolean; + readonly avatars: boolean; + readonly files: ViewsFilesConfig; + readonly pullRequests: { + readonly enabled: boolean; + readonly showForCommits: boolean; }; } export interface StashesViewConfig { - files: ViewsFilesConfig; - reveal: boolean; + readonly files: ViewsFilesConfig; + readonly reveal: boolean; } export interface TagsViewConfig { - avatars: boolean; - branches: { - layout: ViewBranchesLayout; + readonly avatars: boolean; + readonly branches: { + readonly layout: ViewBranchesLayout; }; - files: ViewsFilesConfig; - reveal: boolean; + readonly files: ViewsFilesConfig; + readonly reveal: boolean; } export interface WorktreesViewConfig { - avatars: boolean; - files: ViewsFilesConfig; - pullRequests: { - enabled: boolean; - showForBranches: boolean; - showForCommits: boolean; - }; - reveal: boolean; - showBranchComparison: false | ViewShowBranchComparison.Branch; + readonly avatars: boolean; + readonly files: ViewsFilesConfig; + readonly pullRequests: { + readonly enabled: boolean; + readonly showForBranches: boolean; + readonly showForCommits: boolean; + }; + readonly reveal: boolean; + readonly showBranchComparison: false | Extract; +} + +export interface WorkspacesViewConfig { + readonly avatars: boolean; + readonly branches: { + readonly layout: ViewBranchesLayout; + readonly showBranchComparison: false | Extract; + }; + readonly compact: boolean; + readonly files: ViewsFilesConfig; + readonly includeWorkingTree: boolean; + readonly pullRequests: { + readonly enabled: boolean; + readonly showForBranches: boolean; + readonly showForCommits: boolean; + }; + readonly showBranchComparison: false | ViewShowBranchComparison; + readonly showBranches: boolean; + readonly showCommits: boolean; + readonly showContributors: boolean; + readonly showIncomingActivity: boolean; + readonly showRemotes: boolean; + readonly showStashes: boolean; + readonly showTags: boolean; + readonly showUpstreamStatus: boolean; + readonly showWorktrees: boolean; } export interface ViewsFilesConfig { - compact: boolean; - layout: ViewFilesLayout; - threshold: number; + readonly compact: boolean; + readonly icon: 'status' | 'type'; + readonly layout: ViewFilesLayout; + readonly threshold: number; } -export function fromOutputLevel(level: LogLevel | OutputLevel): LogLevel { +export function fromOutputLevel(level: OutputLevel): LogLevel { switch (level) { - case OutputLevel.Silent: - return LogLevel.Off; - case OutputLevel.Errors: - return LogLevel.Error; - case OutputLevel.Verbose: - return LogLevel.Info; - case OutputLevel.Debug: - return LogLevel.Debug; + case /** @deprecated use `off` */ 'silent': + return 'off'; + case /** @deprecated use `error` */ 'errors': + return 'error'; + case /** @deprecated use `info` */ 'verbose': + return 'info'; default: return level; } } + +export type CoreConfig = { + readonly editor: { + readonly letterSpacing: number; + }; + readonly files: { + readonly encoding: string; + readonly exclude: Record; + }; + readonly git: { + readonly autoRepositoryDetection: boolean | 'subFolders' | 'openEditors'; + readonly enabled: boolean; + readonly fetchOnPull: boolean; + readonly path: string | string[] | null; + readonly pullTags: boolean; + readonly repositoryScanIgnoredFolders: string[]; + readonly repositoryScanMaxDepth: number; + readonly useForcePushIfIncludes: boolean; + readonly useForcePushWithLease: boolean; + }; + readonly http: { + readonly proxy: string; + readonly proxySupport: 'fallback' | 'off' | 'on' | 'override'; + readonly proxyStrictSSL: boolean; + }; + readonly multiDiffEditor: { + readonly experimental: { + readonly enabled: boolean; + }; + }; + readonly search: { + readonly exclude: Record; + }; + readonly workbench: { + readonly editorAssociations: Record | { viewType: string; filenamePattern: string }[]; + readonly tree: { + readonly renderIndentGuides: 'always' | 'none' | 'onHover'; + readonly indent: number; + }; + }; +}; diff --git a/src/constants.ai.ts b/src/constants.ai.ts new file mode 100644 index 0000000000000..65ca4c71d899a --- /dev/null +++ b/src/constants.ai.ts @@ -0,0 +1,21 @@ +import type { AnthropicModels } from './ai/anthropicProvider'; +import type { GeminiModels } from './ai/geminiProvider'; +import type { OpenAIModels } from './ai/openaiProvider'; +import type { VSCodeAIModels } from './ai/vscodeProvider'; + +export type AIProviders = 'anthropic' | 'gemini' | 'openai' | 'vscode'; +export type AIModels = Provider extends 'openai' + ? OpenAIModels + : Provider extends 'anthropic' + ? AnthropicModels + : Provider extends 'gemini' + ? GeminiModels + : Provider extends 'vscode' + ? VSCodeAIModels + : AnthropicModels | GeminiModels | OpenAIModels; + +export type SupportedAIModels = + | `anthropic:${AIModels<'anthropic'>}` + | `google:${AIModels<'gemini'>}` + | `openai:${AIModels<'openai'>}` + | 'vscode'; diff --git a/src/constants.colors.ts b/src/constants.colors.ts new file mode 100644 index 0000000000000..56a93d86dc88c --- /dev/null +++ b/src/constants.colors.ts @@ -0,0 +1,52 @@ +import type { extensionPrefix } from './constants'; + +export type Colors = + | `${typeof extensionPrefix}.closedAutolinkedIssueIconColor` + | `${typeof extensionPrefix}.closedPullRequestIconColor` + | `${typeof extensionPrefix}.decorations.addedForegroundColor` + | `${typeof extensionPrefix}.decorations.branchAheadForegroundColor` + | `${typeof extensionPrefix}.decorations.branchBehindForegroundColor` + | `${typeof extensionPrefix}.decorations.branchDivergedForegroundColor` + | `${typeof extensionPrefix}.decorations.branchMissingUpstreamForegroundColor` + | `${typeof extensionPrefix}.decorations.branchUpToDateForegroundColor` + | `${typeof extensionPrefix}.decorations.branchUnpublishedForegroundColor` + | `${typeof extensionPrefix}.decorations.copiedForegroundColor` + | `${typeof extensionPrefix}.decorations.deletedForegroundColor` + | `${typeof extensionPrefix}.decorations.ignoredForegroundColor` + | `${typeof extensionPrefix}.decorations.modifiedForegroundColor` + | `${typeof extensionPrefix}.decorations.statusMergingOrRebasingConflictForegroundColor` + | `${typeof extensionPrefix}.decorations.statusMergingOrRebasingForegroundColor` + | `${typeof extensionPrefix}.decorations.renamedForegroundColor` + | `${typeof extensionPrefix}.decorations.untrackedForegroundColor` + | `${typeof extensionPrefix}.decorations.workspaceCurrentForegroundColor` + | `${typeof extensionPrefix}.decorations.workspaceRepoMissingForegroundColor` + | `${typeof extensionPrefix}.decorations.workspaceRepoOpenForegroundColor` + | `${typeof extensionPrefix}.decorations.worktreeHasUncommittedChangesForegroundColor` + | `${typeof extensionPrefix}.decorations.worktreeMissingForegroundColor` + | `${typeof extensionPrefix}.gutterBackgroundColor` + | `${typeof extensionPrefix}.gutterForegroundColor` + | `${typeof extensionPrefix}.gutterUncommittedForegroundColor` + | `${typeof extensionPrefix}.launchpadIndicatorMergeableColor` + | `${typeof extensionPrefix}.launchpadIndicatorMergeableHoverColor` + | `${typeof extensionPrefix}.launchpadIndicatorBlockedColor` + | `${typeof extensionPrefix}.launchpadIndicatorBlockedHoverColor` + | `${typeof extensionPrefix}.launchpadIndicatorAttentionColor` + | `${typeof extensionPrefix}.launchpadIndicatorAttentionHoverColor` + | `${typeof extensionPrefix}.lineHighlightBackgroundColor` + | `${typeof extensionPrefix}.lineHighlightOverviewRulerColor` + | `${typeof extensionPrefix}.mergedPullRequestIconColor` + | `${typeof extensionPrefix}.openAutolinkedIssueIconColor` + | `${typeof extensionPrefix}.openPullRequestIconColor` + | `${typeof extensionPrefix}.trailingLineBackgroundColor` + | `${typeof extensionPrefix}.trailingLineForegroundColor` + | `${typeof extensionPrefix}.unpublishedChangesIconColor` + | `${typeof extensionPrefix}.unpublishedCommitIconColor` + | `${typeof extensionPrefix}.unpulledChangesIconColor`; + +export type CoreColors = + | 'editorOverviewRuler.addedForeground' + | 'editorOverviewRuler.deletedForeground' + | 'editorOverviewRuler.modifiedForeground' + | 'list.foreground' + | 'list.warningForeground' + | 'statusBarItem.warningBackground'; diff --git a/src/constants.commands.ts b/src/constants.commands.ts new file mode 100644 index 0000000000000..414dafe54af1d --- /dev/null +++ b/src/constants.commands.ts @@ -0,0 +1,444 @@ +import type { CoreViewContainerIds, TreeViewIds, TreeViewTypes, ViewContainerIds, ViewIds } from './constants.views'; + +export const enum Commands { + ActionPrefix = 'gitlens.action.', + + AddAuthors = 'gitlens.addAuthors', + BrowseRepoAtRevision = 'gitlens.browseRepoAtRevision', + BrowseRepoAtRevisionInNewWindow = 'gitlens.browseRepoAtRevisionInNewWindow', + BrowseRepoBeforeRevision = 'gitlens.browseRepoBeforeRevision', + BrowseRepoBeforeRevisionInNewWindow = 'gitlens.browseRepoBeforeRevisionInNewWindow', + ClearFileAnnotations = 'gitlens.clearFileAnnotations', + CloseUnchangedFiles = 'gitlens.closeUnchangedFiles', + CompareWith = 'gitlens.compareWith', + CompareHeadWith = 'gitlens.compareHeadWith', + CompareWorkingWith = 'gitlens.compareWorkingWith', + ComputingFileAnnotations = 'gitlens.computingFileAnnotations', + ConnectRemoteProvider = 'gitlens.connectRemoteProvider', + CopyCurrentBranch = 'gitlens.copyCurrentBranch', + CopyDeepLinkToBranch = 'gitlens.copyDeepLinkToBranch', + CopyDeepLinkToCommit = 'gitlens.copyDeepLinkToCommit', + CopyDeepLinkToComparison = 'gitlens.copyDeepLinkToComparison', + CopyDeepLinkToFile = 'gitlens.copyDeepLinkToFile', + CopyDeepLinkToFileAtRevision = 'gitlens.copyDeepLinkToFileAtRevision', + CopyDeepLinkToLines = 'gitlens.copyDeepLinkToLines', + CopyDeepLinkToRepo = 'gitlens.copyDeepLinkToRepo', + CopyDeepLinkToTag = 'gitlens.copyDeepLinkToTag', + CopyDeepLinkToWorkspace = 'gitlens.copyDeepLinkToWorkspace', + CopyMessageToClipboard = 'gitlens.copyMessageToClipboard', + CopyRemoteBranchesUrl = 'gitlens.copyRemoteBranchesUrl', + CopyRemoteBranchUrl = 'gitlens.copyRemoteBranchUrl', + CopyRemoteCommitUrl = 'gitlens.copyRemoteCommitUrl', + CopyRemoteComparisonUrl = 'gitlens.copyRemoteComparisonUrl', + CopyRemoteFileUrl = 'gitlens.copyRemoteFileUrlToClipboard', + CopyRemoteFileUrlWithoutRange = 'gitlens.copyRemoteFileUrlWithoutRange', + CopyRemoteFileUrlFrom = 'gitlens.copyRemoteFileUrlFrom', + CopyRemotePullRequestUrl = 'gitlens.copyRemotePullRequestUrl', + CopyRemoteRepositoryUrl = 'gitlens.copyRemoteRepositoryUrl', + CopyShaToClipboard = 'gitlens.copyShaToClipboard', + CopyRelativePathToClipboard = 'gitlens.copyRelativePathToClipboard', + ApplyPatchFromClipboard = 'gitlens.applyPatchFromClipboard', + PastePatchFromClipboard = 'gitlens.pastePatchFromClipboard', + CopyPatchToClipboard = 'gitlens.copyPatchToClipboard', + CopyWorkingChangesToWorktree = 'gitlens.copyWorkingChangesToWorktree', + CreatePatch = 'gitlens.createPatch', + CreateCloudPatch = 'gitlens.createCloudPatch', + CreatePullRequestOnRemote = 'gitlens.createPullRequestOnRemote', + DiffDirectory = 'gitlens.diffDirectory', + DiffDirectoryWithHead = 'gitlens.diffDirectoryWithHead', + DiffFolderWithRevision = 'gitlens.diffFolderWithRevision', + DiffFolderWithRevisionFrom = 'gitlens.diffFolderWithRevisionFrom', + DiffWith = 'gitlens.diffWith', + DiffWithNext = 'gitlens.diffWithNext', + DiffWithNextInDiffLeft = 'gitlens.diffWithNextInDiffLeft', + DiffWithNextInDiffRight = 'gitlens.diffWithNextInDiffRight', + DiffWithPrevious = 'gitlens.diffWithPrevious', + DiffWithPreviousInDiffLeft = 'gitlens.diffWithPreviousInDiffLeft', + DiffWithPreviousInDiffRight = 'gitlens.diffWithPreviousInDiffRight', + DiffLineWithPrevious = 'gitlens.diffLineWithPrevious', + DiffWithRevision = 'gitlens.diffWithRevision', + DiffWithRevisionFrom = 'gitlens.diffWithRevisionFrom', + DiffWithWorking = 'gitlens.diffWithWorking', + DiffWithWorkingInDiffLeft = 'gitlens.diffWithWorkingInDiffLeft', + DiffWithWorkingInDiffRight = 'gitlens.diffWithWorkingInDiffRight', + DiffLineWithWorking = 'gitlens.diffLineWithWorking', + DisconnectRemoteProvider = 'gitlens.disconnectRemoteProvider', + DisableDebugLogging = 'gitlens.disableDebugLogging', + EnableDebugLogging = 'gitlens.enableDebugLogging', + DisableRebaseEditor = 'gitlens.disableRebaseEditor', + EnableRebaseEditor = 'gitlens.enableRebaseEditor', + ExternalDiff = 'gitlens.externalDiff', + ExternalDiffAll = 'gitlens.externalDiffAll', + FetchRepositories = 'gitlens.fetchRepositories', + GenerateCommitMessage = 'gitlens.generateCommitMessage', + GetStarted = 'gitlens.getStarted', + GKSwitchOrganization = 'gitlens.gk.switchOrganization', + InviteToLiveShare = 'gitlens.inviteToLiveShare', + OpenBlamePriorToChange = 'gitlens.openBlamePriorToChange', + OpenBranchesOnRemote = 'gitlens.openBranchesOnRemote', + OpenBranchOnRemote = 'gitlens.openBranchOnRemote', + OpenCurrentBranchOnRemote = 'gitlens.openCurrentBranchOnRemote', + OpenChangedFiles = 'gitlens.openChangedFiles', + OpenCommitOnRemote = 'gitlens.openCommitOnRemote', + OpenComparisonOnRemote = 'gitlens.openComparisonOnRemote', + OpenFileHistory = 'gitlens.openFileHistory', + OpenFileFromRemote = 'gitlens.openFileFromRemote', + OpenFileOnRemote = 'gitlens.openFileOnRemote', + OpenFileOnRemoteFrom = 'gitlens.openFileOnRemoteFrom', + OpenFileAtRevision = 'gitlens.openFileRevision', + OpenFileAtRevisionFrom = 'gitlens.openFileRevisionFrom', + OpenFolderHistory = 'gitlens.openFolderHistory', + OpenOnRemote = 'gitlens.openOnRemote', + OpenCloudPatch = 'gitlens.openCloudPatch', + OpenPatch = 'gitlens.openPatch', + OpenPullRequestOnRemote = 'gitlens.openPullRequestOnRemote', + OpenAssociatedPullRequestOnRemote = 'gitlens.openAssociatedPullRequestOnRemote', + OpenRepoOnRemote = 'gitlens.openRepoOnRemote', + OpenRevisionFile = 'gitlens.openRevisionFile', + OpenRevisionFileInDiffLeft = 'gitlens.openRevisionFileInDiffLeft', + OpenRevisionFileInDiffRight = 'gitlens.openRevisionFileInDiffRight', + OpenWalkthrough = 'gitlens.openWalkthrough', + OpenWorkingFile = 'gitlens.openWorkingFile', + OpenWorkingFileInDiffLeft = 'gitlens.openWorkingFileInDiffLeft', + OpenWorkingFileInDiffRight = 'gitlens.openWorkingFileInDiffRight', + PullRepositories = 'gitlens.pullRepositories', + PushRepositories = 'gitlens.pushRepositories', + GitCommands = 'gitlens.gitCommands', + GitCommandsBranch = 'gitlens.gitCommands.branch', + GitCommandsBranchCreate = 'gitlens.gitCommands.branch.create', + GitCommandsBranchDelete = 'gitlens.gitCommands.branch.delete', + GitCommandsBranchPrune = 'gitlens.gitCommands.branch.prune', + GitCommandsBranchRename = 'gitlens.gitCommands.branch.rename', + GitCommandsCheckout = 'gitlens.gitCommands.checkout', + GitCommandsCherryPick = 'gitlens.gitCommands.cherryPick', + GitCommandsHistory = 'gitlens.gitCommands.history', + GitCommandsMerge = 'gitlens.gitCommands.merge', + GitCommandsRebase = 'gitlens.gitCommands.rebase', + GitCommandsRemote = 'gitlens.gitCommands.remote', + GitCommandsRemoteAdd = 'gitlens.gitCommands.remote.add', + GitCommandsRemotePrune = 'gitlens.gitCommands.remote.prune', + GitCommandsRemoteRemove = 'gitlens.gitCommands.remote.remove', + GitCommandsReset = 'gitlens.gitCommands.reset', + GitCommandsRevert = 'gitlens.gitCommands.revert', + GitCommandsShow = 'gitlens.gitCommands.show', + GitCommandsStash = 'gitlens.gitCommands.stash', + GitCommandsStashDrop = 'gitlens.gitCommands.stash.drop', + GitCommandsStashList = 'gitlens.gitCommands.stash.list', + GitCommandsStashPop = 'gitlens.gitCommands.stash.pop', + GitCommandsStashPush = 'gitlens.gitCommands.stash.push', + GitCommandsStashRename = 'gitlens.gitCommands.stash.rename', + GitCommandsStatus = 'gitlens.gitCommands.status', + GitCommandsSwitch = 'gitlens.gitCommands.switch', + GitCommandsTag = 'gitlens.gitCommands.tag', + GitCommandsTagCreate = 'gitlens.gitCommands.tag.create', + GitCommandsTagDelete = 'gitlens.gitCommands.tag.delete', + GitCommandsWorktree = 'gitlens.gitCommands.worktree', + GitCommandsWorktreeCreate = 'gitlens.gitCommands.worktree.create', + GitCommandsWorktreeDelete = 'gitlens.gitCommands.worktree.delete', + GitCommandsWorktreeOpen = 'gitlens.gitCommands.worktree.open', + OpenOrCreateWorktreeForGHPR = 'gitlens.ghpr.views.openOrCreateWorktree', + PlusConnectCloudIntegrations = 'gitlens.plus.cloudIntegrations.connect', + PlusHide = 'gitlens.plus.hide', + PlusLogin = 'gitlens.plus.login', + PlusLogout = 'gitlens.plus.logout', + PlusManage = 'gitlens.plus.manage', + PlusManageCloudIntegrations = 'gitlens.plus.cloudIntegrations.manage', + PlusReactivateProTrial = 'gitlens.plus.reactivateProTrial', + PlusResendVerification = 'gitlens.plus.resendVerification', + PlusRestore = 'gitlens.plus.restore', + PlusShowPlans = 'gitlens.plus.showPlans', + PlusSignUp = 'gitlens.plus.signUp', + PlusStartPreviewTrial = 'gitlens.plus.startPreviewTrial', + PlusUpgrade = 'gitlens.plus.upgrade', + PlusValidate = 'gitlens.plus.validate', + QuickOpenFileHistory = 'gitlens.quickOpenFileHistory', + RefreshLaunchpad = 'gitlens.launchpad.refresh', + RefreshGraph = 'gitlens.graph.refresh', + RefreshHover = 'gitlens.refreshHover', + Reset = 'gitlens.reset', + ResetAIKey = 'gitlens.resetAIKey', + ResetViewsLayout = 'gitlens.resetViewsLayout', + RevealCommitInView = 'gitlens.revealCommitInView', + ShareAsCloudPatch = 'gitlens.shareAsCloudPatch', + SearchCommits = 'gitlens.showCommitSearch', + SearchCommitsInView = 'gitlens.views.searchAndCompare.searchCommits', + ShowBranchesView = 'gitlens.showBranchesView', + ShowCommitDetailsView = 'gitlens.showCommitDetailsView', + ShowCommitInView = 'gitlens.showCommitInView', + ShowCommitsInView = 'gitlens.showCommitsInView', + ShowCommitsView = 'gitlens.showCommitsView', + ShowContributorsView = 'gitlens.showContributorsView', + ShowDraftsView = 'gitlens.showDraftsView', + ShowFileHistoryView = 'gitlens.showFileHistoryView', + ShowFocusPage = 'gitlens.showFocusPage', + ShowGraph = 'gitlens.showGraph', + ShowGraphPage = 'gitlens.showGraphPage', + ShowGraphView = 'gitlens.showGraphView', + ShowHomeView = 'gitlens.showHomeView', + ShowAccountView = 'gitlens.showAccountView', + ShowInCommitGraph = 'gitlens.showInCommitGraph', + ShowInCommitGraphView = 'gitlens.showInCommitGraphView', + ShowInDetailsView = 'gitlens.showInDetailsView', + ShowInTimeline = 'gitlens.showInTimeline', + ShowLastQuickPick = 'gitlens.showLastQuickPick', + ShowLaunchpad = 'gitlens.showLaunchpad', + ShowLaunchpadView = 'gitlens.showLaunchpadView', + ShowLineCommitInView = 'gitlens.showLineCommitInView', + ShowLineHistoryView = 'gitlens.showLineHistoryView', + OpenOnlyChangedFiles = 'gitlens.openOnlyChangedFiles', + ShowPatchDetailsPage = 'gitlens.showPatchDetailsPage', + ShowQuickBranchHistory = 'gitlens.showQuickBranchHistory', + ShowQuickCommit = 'gitlens.showQuickCommitDetails', + ShowQuickCommitFile = 'gitlens.showQuickCommitFileDetails', + ShowQuickCurrentBranchHistory = 'gitlens.showQuickRepoHistory', + ShowQuickFileHistory = 'gitlens.showQuickFileHistory', + ShowQuickRepoStatus = 'gitlens.showQuickRepoStatus', + ShowQuickCommitRevision = 'gitlens.showQuickRevisionDetails', + ShowQuickCommitRevisionInDiffLeft = 'gitlens.showQuickRevisionDetailsInDiffLeft', + ShowQuickCommitRevisionInDiffRight = 'gitlens.showQuickRevisionDetailsInDiffRight', + ShowQuickStashList = 'gitlens.showQuickStashList', + ShowRemotesView = 'gitlens.showRemotesView', + ShowRepositoriesView = 'gitlens.showRepositoriesView', + ShowSearchAndCompareView = 'gitlens.showSearchAndCompareView', + ShowSettingsPage = 'gitlens.showSettingsPage', + ShowSettingsPageAndJumpToFileAnnotations = 'gitlens.showSettingsPage!file-annotations', + ShowSettingsPageAndJumpToBranchesView = 'gitlens.showSettingsPage!branches-view', + ShowSettingsPageAndJumpToCommitsView = 'gitlens.showSettingsPage!commits-view', + ShowSettingsPageAndJumpToContributorsView = 'gitlens.showSettingsPage!contributors-view', + ShowSettingsPageAndJumpToFileHistoryView = 'gitlens.showSettingsPage!file-history-view', + ShowSettingsPageAndJumpToLineHistoryView = 'gitlens.showSettingsPage!line-history-view', + ShowSettingsPageAndJumpToRemotesView = 'gitlens.showSettingsPage!remotes-view', + ShowSettingsPageAndJumpToRepositoriesView = 'gitlens.showSettingsPage!repositories-view', + ShowSettingsPageAndJumpToSearchAndCompareView = 'gitlens.showSettingsPage!search-compare-view', + ShowSettingsPageAndJumpToStashesView = 'gitlens.showSettingsPage!stashes-view', + ShowSettingsPageAndJumpToTagsView = 'gitlens.showSettingsPage!tags-view', + ShowSettingsPageAndJumpToWorkTreesView = 'gitlens.showSettingsPage!worktrees-view', + ShowSettingsPageAndJumpToViews = 'gitlens.showSettingsPage!views', + ShowSettingsPageAndJumpToCommitGraph = 'gitlens.showSettingsPage!commit-graph', + ShowSettingsPageAndJumpToAutolinks = 'gitlens.showSettingsPage!autolinks', + ShowStashesView = 'gitlens.showStashesView', + ShowTagsView = 'gitlens.showTagsView', + ShowTimelinePage = 'gitlens.showTimelinePage', + ShowTimelineView = 'gitlens.showTimelineView', + ShowWelcomePage = 'gitlens.showWelcomePage', + ShowWorktreesView = 'gitlens.showWorktreesView', + ShowWorkspacesView = 'gitlens.showWorkspacesView', + StashApply = 'gitlens.stashApply', + StashSave = 'gitlens.stashSave', + StashSaveFiles = 'gitlens.stashSaveFiles', + SwitchAIModel = 'gitlens.switchAIModel', + SwitchMode = 'gitlens.switchMode', + ToggleCodeLens = 'gitlens.toggleCodeLens', + ToggleFileBlame = 'gitlens.toggleFileBlame', + ToggleFileBlameInDiffLeft = 'gitlens.toggleFileBlameInDiffLeft', + ToggleFileBlameInDiffRight = 'gitlens.toggleFileBlameInDiffRight', + ToggleFileChanges = 'gitlens.toggleFileChanges', + ToggleFileChangesOnly = 'gitlens.toggleFileChangesOnly', + ToggleFileHeatmap = 'gitlens.toggleFileHeatmap', + ToggleFileHeatmapInDiffLeft = 'gitlens.toggleFileHeatmapInDiffLeft', + ToggleFileHeatmapInDiffRight = 'gitlens.toggleFileHeatmapInDiffRight', + ToggleLaunchpadIndicator = 'gitlens.launchpad.indicator.toggle', + ToggleGraph = 'gitlens.toggleGraph', + ToggleMaximizedGraph = 'gitlens.toggleMaximizedGraph', + ToggleLineBlame = 'gitlens.toggleLineBlame', + ToggleReviewMode = 'gitlens.toggleReviewMode', + ToggleZenMode = 'gitlens.toggleZenMode', + ViewsCopy = 'gitlens.views.copy', + ViewsCopyAsMarkdown = 'gitlens.views.copyAsMarkdown', + ViewsCopyUrl = 'gitlens.views.copyUrl', + ViewsOpenDirectoryDiff = 'gitlens.views.openDirectoryDiff', + ViewsOpenDirectoryDiffWithWorking = 'gitlens.views.openDirectoryDiffWithWorking', + ViewsOpenUrl = 'gitlens.views.openUrl', + + Deprecated_DiffHeadWith = 'gitlens.diffHeadWith', + Deprecated_DiffWorkingWith = 'gitlens.diffWorkingWith', + Deprecated_OpenBranchesInRemote = 'gitlens.openBranchesInRemote', + Deprecated_OpenBranchInRemote = 'gitlens.openBranchInRemote', + Deprecated_OpenCommitInRemote = 'gitlens.openCommitInRemote', + Deprecated_OpenFileInRemote = 'gitlens.openFileInRemote', + Deprecated_OpenInRemote = 'gitlens.openInRemote', + Deprecated_OpenRepoInRemote = 'gitlens.openRepoInRemote', + Deprecated_ShowFileHistoryInView = 'gitlens.showFileHistoryInView', +} + +export type CoreCommands = + | 'cursorMove' + | 'editor.action.showHover' + | 'editor.action.showReferences' + | 'editor.action.webvieweditor.showFind' + | 'editorScroll' + | 'list.collapseAllToFocus' + | 'openInIntegratedTerminal' + | 'openInTerminal' + | 'revealFileInOS' + | 'revealInExplorer' + | 'revealLine' + | 'setContext' + | 'vscode.open' + | 'vscode.openFolder' + | 'vscode.openWith' + | 'vscode.changes' + | 'vscode.diff' + | 'vscode.executeCodeLensProvider' + | 'vscode.executeDocumentSymbolProvider' + | 'vscode.moveViews' + | 'vscode.previewHtml' + | 'workbench.action.closeActiveEditor' + | 'workbench.action.closeAllEditors' + | 'workbench.action.closePanel' + | 'workbench.action.nextEditor' + | 'workbench.action.openWalkthrough' + | 'workbench.action.toggleMaximizedPanel' + | 'workbench.extensions.installExtension' + | 'workbench.extensions.uninstallExtension' + | 'workbench.files.action.focusFilesExplorer' + | 'workbench.view.explorer' + | 'workbench.view.scm' + | `${ViewContainerIds | CoreViewContainerIds}.resetViewContainerLocation` + | `${ViewIds}.${'focus' | 'removeView' | 'resetViewLocation' | 'toggleVisibility'}`; + +export type CoreGitCommands = + | 'git.fetch' + | 'git.publish' + | 'git.pull' + | 'git.pullRebase' + | 'git.push' + | 'git.pushForce' + | 'git.undoCommit'; + +export type TreeViewCommands = `gitlens.views.${ + | `branches.${ + | 'copy' + | 'refresh' + | `setLayoutTo${'List' | 'Tree'}` + | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` + | `setShowAvatars${'On' | 'Off'}` + | `setShowBranchComparison${'On' | 'Off'}` + | `setShowBranchPullRequest${'On' | 'Off'}`}` + | `commits.${ + | 'copy' + | 'refresh' + | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` + | `setCommitsFilter${'Authors' | 'Off'}` + | `setShowAvatars${'On' | 'Off'}` + | `setShowBranchComparison${'On' | 'Off'}` + | `setShowBranchPullRequest${'On' | 'Off'}` + | `setShowMergeCommits${'On' | 'Off'}`}` + | `contributors.${ + | 'copy' + | 'refresh' + | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` + | `setShowAllBranches${'On' | 'Off'}` + | `setShowAvatars${'On' | 'Off'}` + | `setShowMergeCommits${'On' | 'Off'}` + | `setShowStatistics${'On' | 'Off'}`}` + | `drafts.${'copy' | 'refresh' | 'info' | 'create' | 'delete' | `setShowAvatars${'On' | 'Off'}`}` + | `fileHistory.${ + | 'copy' + | 'refresh' + | 'changeBase' + | `setCursorFollowing${'On' | 'Off'}` + | `setEditorFollowing${'On' | 'Off'}` + | `setRenameFollowing${'On' | 'Off'}` + | `setShowAllBranches${'On' | 'Off'}` + | `setShowMergeCommits${'On' | 'Off'}` + | `setShowAvatars${'On' | 'Off'}`}` + | `launchpad.${ + | 'copy' + | 'info' + | 'refresh' + | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` + | `setShowAvatars${'On' | 'Off'}`}` + | `lineHistory.${ + | 'copy' + | 'refresh' + | 'changeBase' + | `setEditorFollowing${'On' | 'Off'}` + | `setShowAvatars${'On' | 'Off'}`}` + | `pullRequest.${ + | 'close' + | 'copy' + | 'refresh' + | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` + | `setShowAvatars${'On' | 'Off'}`}` + | `remotes.${ + | 'copy' + | 'refresh' + | `setLayoutTo${'List' | 'Tree'}` + | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` + | `setShowAvatars${'On' | 'Off'}` + | `setShowBranchPullRequest${'On' | 'Off'}`}` + | `repositories.${ + | 'copy' + | 'refresh' + | `setBranchesLayoutTo${'List' | 'Tree'}` + | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` + | `setAutoRefreshTo${'On' | 'Off'}` + | `setShowAvatars${'On' | 'Off'}` + | `setShowBranchComparison${'On' | 'Off'}` + | `setBranchesShowBranchComparison${'On' | 'Off'}` + | `setShowBranches${'On' | 'Off'}` + | `setShowCommits${'On' | 'Off'}` + | `setShowContributors${'On' | 'Off'}` + | `setShowRemotes${'On' | 'Off'}` + | `setShowStashes${'On' | 'Off'}` + | `setShowTags${'On' | 'Off'}` + | `setShowWorktrees${'On' | 'Off'}` + | `setShowUpstreamStatus${'On' | 'Off'}` + | `setShowSectionOff`}` + | `searchAndCompare.${ + | 'copy' + | 'refresh' + | 'clear' + | 'pin' + | 'unpin' + | 'swapComparison' + | 'selectForCompare' + | 'compareWithSelected' + | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` + | `setKeepResultsTo${'On' | 'Off'}` + | `setShowAvatars${'On' | 'Off'}` + | `setFilesFilterOn${'Left' | 'Right'}` + | 'setFilesFilterOff'}` + | `stashes.${'copy' | 'refresh' | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}`}` + | `tags.${ + | 'copy' + | 'refresh' + | `setLayoutTo${'List' | 'Tree'}` + | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` + | `setShowAvatars${'On' | 'Off'}`}` + | `workspaces.${ + | 'info' + | 'copy' + | 'refresh' + | 'addRepos' + | 'addReposFromLinked' + | 'changeAutoAddSetting' + | 'convert' + | 'create' + | 'createLocal' + | 'delete' + | 'locateAllRepos' + | 'openLocal' + | 'openLocalNewWindow' + | `repo.${'locate' | 'open' | 'openInNewWindow' | 'addToWindow' | 'remove'}`}` + | `worktrees.${ + | 'copy' + | 'refresh' + | `setFilesLayoutTo${'Auto' | 'List' | 'Tree'}` + | `setShowAvatars${'On' | 'Off'}` + | `setShowBranchComparison${'On' | 'Off'}` + | `setShowBranchPullRequest${'On' | 'Off'}`}`}`; + +type ExtractSuffix = U extends `${Prefix}${infer V}` ? V : never; +type FilterCommands = U extends `${Prefix}${infer V}` ? `${Prefix}${V}` : never; + +export type TreeViewCommandsByViewId = FilterCommands; +export type TreeViewCommandsByViewType = FilterCommands< + `gitlens.views.${T}.`, + TreeViewCommands +>; +export type TreeViewCommandSuffixesByViewType = ExtractSuffix< + `gitlens.views.${T}.`, + FilterCommands<`gitlens.views.${T}.`, TreeViewCommands> +>; diff --git a/src/constants.context.ts b/src/constants.context.ts new file mode 100644 index 0000000000000..9d41f64a531a0 --- /dev/null +++ b/src/constants.context.ts @@ -0,0 +1,51 @@ +import type { AnnotationStatus } from './annotations/annotationProvider'; +import type { Keys, PromoKeys } from './constants'; +import type { CustomEditorTypes, WebviewTypes, WebviewViewTypes } from './constants.views'; +import type { SubscriptionPlanId, SubscriptionState } from './plus/gk/account/subscription'; + +export type ContextKeys = { + 'gitlens:debugging': boolean; + 'gitlens:disabled': boolean; + 'gitlens:disabledToggleCodeLens': boolean; + 'gitlens:enabled': boolean; + 'gitlens:gk:hasOrganizations': boolean; + 'gitlens:gk:organization:ai:enabled': boolean; + 'gitlens:gk:organization:drafts:byob': boolean; + 'gitlens:gk:organization:drafts:enabled': boolean; + 'gitlens:hasVirtualFolders': boolean; + 'gitlens:launchpad:connect': boolean; + 'gitlens:plus': SubscriptionPlanId; + 'gitlens:plus:disallowedRepos': string[]; + 'gitlens:plus:enabled': boolean; + 'gitlens:plus:required': boolean; + 'gitlens:plus:state': SubscriptionState; + 'gitlens:prerelease': boolean; + 'gitlens:promo': PromoKeys; + 'gitlens:readonly': boolean; + 'gitlens:repos:withRemotes': string[]; + 'gitlens:repos:withHostingIntegrations': string[]; + 'gitlens:repos:withHostingIntegrationsConnected': string[]; + 'gitlens:schemes:trackable': string[]; + 'gitlens:tabs:annotated': string[]; + 'gitlens:tabs:annotated:computing': string[]; + 'gitlens:tabs:blameable': string[]; + 'gitlens:tabs:tracked': string[]; + 'gitlens:untrusted': boolean; + 'gitlens:views:canCompare': boolean; + 'gitlens:views:canCompare:file': boolean; + 'gitlens:views:commits:filtered': boolean; + 'gitlens:views:commits:hideMergeCommits': boolean; + 'gitlens:views:contributors:hideMergeCommits': boolean; + 'gitlens:views:fileHistory:canPin': boolean; + 'gitlens:views:fileHistory:cursorFollowing': boolean; + 'gitlens:views:fileHistory:editorFollowing': boolean; + 'gitlens:views:lineHistory:editorFollowing': boolean; + 'gitlens:views:patchDetails:mode': 'create' | 'view'; + 'gitlens:views:pullRequest:visible': boolean; + 'gitlens:views:repositories:autoRefresh': boolean; + 'gitlens:vsls': boolean | 'host' | 'guest'; + 'gitlens:window:annotated': AnnotationStatus; +} & Record<`gitlens:action:${string}`, number> & + Record<`gitlens:key:${Keys}`, boolean> & + Record<`gitlens:webview:${WebviewTypes | CustomEditorTypes}:visible`, boolean> & + Record<`gitlens:webviewView:${WebviewViewTypes}:visible`, boolean>; diff --git a/src/constants.search.ts b/src/constants.search.ts new file mode 100644 index 0000000000000..39eda394ef7f3 --- /dev/null +++ b/src/constants.search.ts @@ -0,0 +1,48 @@ +type SearchOperatorsShortForm = '' | '=:' | '@:' | '#:' | '?:' | '~:' | 'is:'; +export type SearchOperatorsLongForm = 'message:' | 'author:' | 'commit:' | 'file:' | 'change:' | 'type:'; +export type SearchOperators = SearchOperatorsShortForm | SearchOperatorsLongForm; + +export const searchOperators = new Set([ + '', + '=:', + 'message:', + '@:', + 'author:', + '#:', + 'commit:', + '?:', + 'file:', + '~:', + 'change:', + 'is:', + 'type:', +]); + +export const searchOperatorsToLongFormMap = new Map([ + ['', 'message:'], + ['=:', 'message:'], + ['message:', 'message:'], + ['@:', 'author:'], + ['author:', 'author:'], + ['#:', 'commit:'], + ['commit:', 'commit:'], + ['?:', 'file:'], + ['file:', 'file:'], + ['~:', 'change:'], + ['change:', 'change:'], + ['is:', 'type:'], + ['type:', 'type:'], +]); + +export const searchOperationRegex = + /(?:(?=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:|is:|type:)\s?(?".+?"|\S+}?))|(?\S+)(?!(?:=|message|@|author|#|commit|\?|file|~|change|is|type):)/g; + +export const searchOperationHelpRegex = + /(?:^|(\b|\s)*)((=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:|is:|type:)(?:"[^"]*"?|\w*))(?:$|(\b|\s))/g; + +export interface SearchQuery { + query: string; + matchAll?: boolean; + matchCase?: boolean; + matchRegex?: boolean; +} diff --git a/src/constants.storage.ts b/src/constants.storage.ts new file mode 100644 index 0000000000000..318a44bbc362c --- /dev/null +++ b/src/constants.storage.ts @@ -0,0 +1,300 @@ +import type { GraphBranchesVisibility, ViewShowBranchComparison } from './config'; +import type { AIProviders } from './constants.ai'; +import type { Environment } from './container'; +import type { Subscription } from './plus/gk/account/subscription'; +import type { Integration } from './plus/integrations/integration'; +import type { IntegrationId } from './plus/integrations/providers/models'; +import type { TrackedUsage, TrackedUsageKeys } from './telemetry/usageTracker'; +import type { DeepLinkServiceState } from './uris/deepLinks/deepLink'; + +export type SecretKeys = + | IntegrationAuthenticationKeys + | `gitlens.${AIProviders}.key` + | `gitlens.plus.auth:${Environment}`; + +export type IntegrationAuthenticationKeys = + | `gitlens.integration.auth:${IntegrationId}|${string}` + | `gitlens.integration.auth.cloud:${IntegrationId}|${string}`; + +export const enum SyncedStorageKeys { + Version = 'gitlens:synced:version', + PreReleaseVersion = 'gitlens:synced:preVersion', + HomeViewWelcomeVisible = 'gitlens:views:welcome:visible', +} + +export type DeprecatedGlobalStorage = { + /** @deprecated use `confirm:ai:tos:${AIProviders}` */ + 'confirm:sendToOpenAI': boolean; + /** @deprecated */ + 'home:actions:completed': ('dismissed:welcome' | 'opened:scm')[]; + /** @deprecated */ + 'home:steps:completed': string[]; + /** @deprecated */ + 'home:sections:dismissed': string[]; + /** @deprecated */ + 'home:status:pinned': boolean; + /** @deprecated */ + 'home:banners:dismissed': string[]; + /** @deprecated */ + 'plus:discountNotificationShown': boolean; + /** @deprecated */ + 'plus:migratedAuthentication': boolean; + /** @deprecated */ + 'plus:renewalDiscountNotificationShown': boolean; + /** @deprecated */ + 'views:layout': 'gitlens' | 'scm'; + /** @deprecated */ + 'views:commitDetails:dismissed': 'sidebar'[]; +} & { + /** @deprecated */ + [key in `disallow:connection:${string}`]: any; +}; + +export type GlobalStorage = { + avatars: [string, StoredAvatar][]; + repoVisibility: [string, StoredRepoVisibilityInfo][]; + 'deepLinks:pending': StoredDeepLinkContext; + pendingWelcomeOnFocus: boolean; + pendingWhatsNewOnFocus: boolean; + // Don't change this key name ('premium`) as its the stored subscription + 'premium:subscription': Stored; + 'synced:version': string; + // Keep the pre-release version separate from the released version + 'synced:preVersion': string; + usages: Record; + version: string; + // Keep the pre-release version separate from the released version + preVersion: string; + 'views:welcome:visible': boolean; + 'confirm:draft:storage': boolean; + 'home:sections:collapsed': string[]; + 'launchpad:groups:collapsed': StoredLaunchpadGroup[]; + 'launchpad:indicator:hasLoaded': boolean; + 'launchpad:indicator:hasInteracted': string; +} & { [key in `confirm:ai:tos:${AIProviders}`]: boolean } & { + [key in `provider:authentication:skip:${string}`]: boolean; +} & { [key in `gk:${string}:checkin`]: Stored } & { + [key in `gk:${string}:organizations`]: Stored; +} & { [key in `jira:${string}:organizations`]: Stored } & { + [key in `jira:${string}:projects`]: Stored; +}; + +export type DeprecatedWorkspaceStorage = { + /** @deprecated use `confirm:ai:tos:${AIProviders}` */ + 'confirm:sendToOpenAI': boolean; + /** @deprecated */ + 'graph:banners:dismissed': Record; + /** @deprecated */ + 'views:searchAndCompare:keepResults': boolean; +}; + +export type WorkspaceStorage = { + assumeRepositoriesOnStartup?: boolean; + 'branch:comparisons': StoredBranchComparisons; + 'gitComandPalette:usage': StoredRecentUsage; + gitPath: string; + 'graph:columns': Record; + 'graph:filtersByRepo': Record; + 'remote:default': string; + 'starred:branches': StoredStarred; + 'starred:repositories': StoredStarred; + 'views:repositories:autoRefresh': boolean; + 'views:searchAndCompare:pinned': StoredSearchAndCompareItems; + 'views:commitDetails:autolinksExpanded': boolean; + 'views:commitDetails:pullRequestExpanded': boolean; +} & { [key in `confirm:ai:tos:${AIProviders}`]: boolean } & { + [key in `connected:${Integration['key']}`]: boolean; +}; + +export interface Stored { + v: SchemaVersion; + data: T; + timestamp?: number; +} + +export interface StoredGKCheckInResponse { + user: StoredGKUser; + licenses: { + paidLicenses: Record; + effectiveLicenses: Record; + }; +} + +export interface StoredGKUser { + id: string; + name: string; + email: string; + status: 'activated' | 'pending'; + createdDate: string; + firstGitLensCheckIn?: string; +} + +export interface StoredGKLicense { + latestStatus: 'active' | 'canceled' | 'cancelled' | 'expired' | 'in_trial' | 'non_renewing' | 'trial'; + latestStartDate: string; + latestEndDate: string; + organizationId: string | undefined; + reactivationCount?: number; +} + +export type StoredGKLicenseType = + | 'gitlens-pro' + | 'gitlens-teams' + | 'gitlens-hosted-enterprise' + | 'gitlens-self-hosted-enterprise' + | 'gitlens-standalone-enterprise' + | 'bundle-pro' + | 'bundle-teams' + | 'bundle-hosted-enterprise' + | 'bundle-self-hosted-enterprise' + | 'bundle-standalone-enterprise' + | 'gitkraken_v1-pro' + | 'gitkraken_v1-teams' + | 'gitkraken_v1-hosted-enterprise' + | 'gitkraken_v1-self-hosted-enterprise' + | 'gitkraken_v1-standalone-enterprise' + | 'gitkraken-v1-pro' + | 'gitkraken-v1-teams' + | 'gitkraken-v1-hosted-enterprise' + | 'gitkraken-v1-self-hosted-enterprise' + | 'gitkraken-v1-standalone-enterprise'; + +export interface StoredOrganization { + id: string; + name: string; + role: 'owner' | 'admin' | 'billing' | 'user'; +} + +export interface StoredJiraOrganization { + key: string; + id: string; + name: string; + url: string; + avatarUrl: string; +} + +export interface StoredJiraProject { + key: string; + id: string; + name: string; + resourceId: string; +} + +export interface StoredAvatar { + uri: string; + timestamp: number; +} + +export type StoredRepositoryVisibility = 'private' | 'public' | 'local'; + +export interface StoredRepoVisibilityInfo { + visibility: StoredRepositoryVisibility; + timestamp: number; + remotesHash?: string; +} + +export interface StoredBranchComparison { + ref: string; + label?: string; + notation: '..' | '...' | undefined; + type: Exclude | undefined; + checkedFiles?: string[]; +} + +export type StoredBranchComparisons = Record; + +export interface StoredDeepLinkContext { + url?: string | undefined; + repoPath?: string | undefined; + targetSha?: string | undefined; + secondaryTargetSha?: string | undefined; + useProgress?: boolean | undefined; + state?: DeepLinkServiceState | undefined; +} + +export interface StoredGraphColumn { + isHidden?: boolean; + mode?: string; + width?: number; +} + +export type StoredGraphExcludeTypes = 'remotes' | 'stashes' | 'tags'; + +export interface StoredGraphFilters { + branchesVisibility?: GraphBranchesVisibility; + includeOnlyRefs?: Record; + excludeRefs?: Record; + excludeTypes?: Record; +} + +export type StoredGraphRefType = 'head' | 'remote' | 'tag'; + +export interface StoredGraphExcludedRef { + id: string; + type: StoredGraphRefType; + name: string; + owner?: string; +} + +export interface StoredGraphIncludeOnlyRef { + id: string; + type: StoredGraphRefType; + name: string; + owner?: string; +} + +export interface StoredNamedRef { + label?: string; + ref: string; +} + +export interface StoredComparison { + type: 'comparison'; + timestamp: number; + path: string; + ref1: StoredNamedRef; + ref2: StoredNamedRef; + notation?: '..' | '...'; + + checkedFiles?: string[]; +} + +export interface StoredSearch { + type: 'search'; + timestamp: number; + path: string; + labels: { + label: string; + queryLabel: + | string + | { + label: string; + resultsType?: { singular: string; plural: string }; + }; + }; + search: StoredSearchQuery; +} + +export interface StoredSearchQuery { + pattern: string; + matchAll?: boolean; + matchCase?: boolean; + matchRegex?: boolean; +} + +export type StoredSearchAndCompareItem = StoredComparison | StoredSearch; +export type StoredSearchAndCompareItems = Record; +export type StoredStarred = Record; +export type StoredRecentUsage = Record; + +export type StoredLaunchpadGroup = + | 'current-branch' + | 'pinned' + | 'mergeable' + | 'blocked' + | 'follow-up' + | 'needs-review' + | 'waiting-for-review' + | 'draft' + | 'other' + | 'snoozed'; diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts new file mode 100644 index 0000000000000..ad67e5fab105d --- /dev/null +++ b/src/constants.telemetry.ts @@ -0,0 +1,470 @@ +import type { AIModels, AIProviders } from './constants.ai'; +import type { Commands } from './constants.commands'; +import type { SubscriptionState } from './plus/gk/account/subscription'; +import type { SupportedCloudIntegrationIds } from './plus/integrations/authentication/models'; +import type { IntegrationId } from './plus/integrations/providers/models'; +import type { TelemetryEventData } from './telemetry/telemetry'; +import type { TrackedUsageKeys } from './telemetry/usageTracker'; + +export type Sources = + | 'account' + | 'code-suggest' + | 'cloud-patches' + | 'commandPalette' + | 'deeplink' + | 'git-commands' + | 'graph' + | 'home' + | 'inspect' + | 'inspect-overview' + | 'integrations' + | 'launchpad' + | 'launchpad-indicator' + | 'launchpad-view' + | 'notification' + | 'patchDetails' + | 'prompt' + | 'remoteProvider' + | 'settings' + | 'timeline' + | 'trial-indicator' + | 'scm-input' + | 'subscription' + | 'walkthrough' + | 'welcome' + | 'worktrees'; + +export type LoginContext = 'start_trial'; + +export type ConnectIntegrationContext = 'launchpad'; + +export type Context = LoginContext | ConnectIntegrationContext; + +export interface Source { + source: Sources; + detail?: string | TelemetryEventData; +} + +export const sourceToContext: { [source in Sources]?: Context } = { + launchpad: 'launchpad', +}; + +export type TelemetryGlobalContext = { + 'cloudIntegrations.connected.count': number; + 'cloudIntegrations.connected.ids': string; + debugging: boolean; + enabled: boolean; + prerelease: boolean; + install: boolean; + upgrade: boolean; + upgradedFrom: string | undefined; + 'folders.count': number; + 'folders.schemes': string; + 'providers.count': number; + 'providers.ids': string; + 'repositories.count': number; + 'repositories.hasRemotes': boolean; + 'repositories.hasRichRemotes': boolean; + 'repositories.hasConnectedRemotes': boolean; + 'repositories.withRemotes': number; + 'repositories.withHostingIntegrations': number; + 'repositories.withHostingIntegrationsConnected': number; + 'repositories.remoteProviders': string; + 'repositories.schemes': string; + 'repositories.visibility': 'private' | 'public' | 'local' | 'mixed'; + 'workspace.isTrusted': boolean; +} & SubscriptionEventData; + +export type TelemetryEvents = { + /** Sent when account validation fails */ + 'account/validation/failed': { + 'account.id': string; + exception: string; + code: string | undefined; + statusCode: string | undefined; + }; + + /** Sent when GitLens is activated */ + activate: { + 'activation.elapsed': number; + 'activation.mode': string | undefined; + } & Record<`config.${string}`, string | number | boolean | null>; + + /** Sent when explaining changes from wip, commits, stashes, patches,etc. */ + 'ai/explain': { + type: 'change'; + changeType: 'wip' | 'stash' | 'commit' | `draft-${'patch' | 'stash' | 'suggested_pr_change'}`; + } & AIEventBase; + + /** Sent when generating summaries from commits, stashes, patches, etc. */ + 'ai/generate': (AIGenerateCommitEvent | AIGenerateDraftEvent) & AIEventBase; + + /** Sent when connecting to one or more cloud-based integrations*/ + 'cloudIntegrations/connecting': { + 'integration.ids': string | undefined; + }; + + /** Sent when connected to one or more cloud-based integrations from gkdev*/ + 'cloudIntegrations/connected': { + 'integration.ids': string | undefined; + 'integration.connected.ids': string | undefined; + }; + + /** Sent when getting connected providers from the api fails*/ + 'cloudIntegrations/getConnections/failed': { + code: number | undefined; + }; + + /** Sent when getting a provider token from the api fails*/ + 'cloudIntegrations/getConnection/failed': { + code: number | undefined; + 'integration.id': string | undefined; + }; + + /** Sent when refreshing a provider token from the api fails*/ + 'cloudIntegrations/refreshConnection/failed': { + code: number | undefined; + 'integration.id': string | undefined; + }; + + /** Sent when a cloud-based hosting provider is connected */ + 'cloudIntegrations/hosting/connected': { + 'hostingProvider.provider': IntegrationId; + 'hostingProvider.key': string; + }; + /** Sent when a cloud-based hosting provider is disconnected */ + 'cloudIntegrations/hosting/disconnected': { + 'hostingProvider.provider': IntegrationId; + 'hostingProvider.key': string; + }; + /** Sent when a cloud-based issue provider is connected */ + 'cloudIntegrations/issue/connected': { + 'issueProvider.provider': IntegrationId; + 'issueProvider.key': string; + }; + /** Sent when a cloud-based issue provider is disconnected */ + 'cloudIntegrations/issue/disconnected': { + 'issueProvider.provider': IntegrationId; + 'issueProvider.key': string; + }; + /** Sent when a user chooses to manage the cloud integrations */ + 'cloudIntegrations/settingsOpened': { + 'integration.id': SupportedCloudIntegrationIds | undefined; + }; + + /** Sent when a code suggestion is archived */ + codeSuggestionArchived: { + provider: string | undefined; + 'repository.visibility': 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + repoPrivacy: 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + draftId: string; + /** Named for compatibility with other GK surfaces */ + reason: 'committed' | 'rejected' | 'accepted'; + }; + /** Sent when a code suggestion is created */ + codeSuggestionCreated: { + provider: string | undefined; + 'repository.visibility': 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + repoPrivacy: 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + draftId: string; + /** Named for compatibility with other GK surfaces */ + draftPrivacy: 'public' | 'private' | 'invite_only' | 'provider_access'; + /** Named for compatibility with other GK surfaces */ + filesChanged: number; + /** Named for compatibility with other GK surfaces */ + source: 'reviewMode'; + }; + /** Sent when a code suggestion is opened */ + codeSuggestionViewed: { + provider: string | undefined; + 'repository.visibility': 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + repoPrivacy: 'private' | 'public' | 'local' | undefined; + /** Named for compatibility with other GK surfaces */ + draftId: string; + /** Named for compatibility with other GK surfaces */ + draftPrivacy: 'public' | 'private' | 'invite_only' | 'provider_access'; + /** Named for compatibility with other GK surfaces */ + source?: string; + }; + + /** Sent when a GitLens command is executed */ + command: + | { + command: Commands.GitCommands; + context?: { mode?: string; submode?: string }; + } + | { + command: string; + context?: undefined; + webview?: string; + }; + /** Sent when a VS Code command is executed by a GitLens provided action */ + 'command/core': { command: string }; + + /** Sent when the user takes an action on a launchpad item */ + 'launchpad/title/action': LaunchpadEventData & { + action: 'feedback' | 'open-on-gkdev' | 'refresh' | 'settings' | 'connect'; + }; + + /** Sent when the user takes an action on a launchpad item */ + 'launchpad/action': LaunchpadEventData & { + action: + | 'open' + | 'code-suggest' + | 'merge' + | 'soft-open' + | 'switch' + | 'open-worktree' + | 'switch-and-code-suggest' + | 'show-overview' + | 'open-changes' + | 'open-in-graph' + | 'pin' + | 'unpin' + | 'snooze' + | 'unsnooze' + | 'open-suggestion' + | 'open-suggestion-browser'; + } & Partial>; + /** Sent when the user changes launchpad configuration settings */ + 'launchpad/configurationChanged': { + 'config.launchpad.staleThreshold': number | null; + 'config.launchpad.ignoredOrganizations': number; + 'config.launchpad.ignoredRepositories': number; + 'config.launchpad.indicator.enabled': boolean; + 'config.launchpad.indicator.icon': 'default' | 'group'; + 'config.launchpad.indicator.label': false | 'item' | 'counts'; + 'config.launchpad.indicator.useColors': boolean; + 'config.launchpad.indicator.groups': string; + 'config.launchpad.indicator.polling.enabled': boolean; + 'config.launchpad.indicator.polling.interval': number; + }; + /** Sent when the user expands/collapses a launchpad group */ + 'launchpad/groupToggled': LaunchpadEventData & { + group: LaunchpadGroups; + collapsed: boolean; + }; + /** Sent when the user opens launchpad; use `instance` to correlate a launchpad "session" */ + 'launchpad/open': LaunchpadEventDataBase; + /** Sent when the launchpad is opened; use `instance` to correlate a launchpad "session" */ + 'launchpad/opened': LaunchpadEventData & { + connected: boolean; + }; + /** Sent when the launchpad has "reloaded" (while open, e.g. user refreshed or back button) and is disconnected; use `instance` to correlate a launchpad "session" */ + 'launchpad/steps/connect': LaunchpadEventData & { + connected: boolean; + }; + /** Sent when the launchpad has "reloaded" (while open, e.g. user refreshed or back button) and is connected; use `instance` to correlate a launchpad "session" */ + 'launchpad/steps/main': LaunchpadEventData & { + connected: boolean; + }; + /** Sent when the user opens the details of a launchpad item (e.g. click on an item); use `instance` to correlate a launchpad "session" */ + 'launchpad/steps/details': LaunchpadEventData & { + action: 'select'; + } & Partial>; + /** Sent when the user hides the launchpad indicator */ + 'launchpad/indicator/hidden': void; + /** Sent when the launchpad indicator loads (with data) for the first time ever for this device */ + 'launchpad/indicator/firstLoad': void; + /** Sent when a launchpad operation is taking longer than a set timeout to complete */ + 'launchpad/operation/slow': { + timeout: number; + operation: 'getMyPullRequests' | 'getCodeSuggestions' | 'getEnrichedItems' | 'getCodeSuggestionCounts'; + duration: number; + }; + + /** Sent when a PR review was started in the inspect overview */ + openReviewMode: { + provider: string; + 'repository.visibility': 'private' | 'public' | 'local' | undefined; + /** Provided for compatibility with other GK surfaces */ + repoPrivacy: 'private' | 'public' | 'local' | undefined; + filesChanged: number; + /** Provided for compatibility with other GK surfaces */ + source: Sources; + }; + + /** Sent when the "context" of the workspace changes (e.g. repo added, integration connected, etc) */ + 'providers/context': void; + + /** Sent when we've loaded all the git providers and their repositories */ + 'providers/registrationComplete': { + 'config.git.autoRepositoryDetection': boolean | 'subFolders' | 'openEditors' | undefined; + }; + + /** Sent when a local (Git remote-based) hosting provider is connected */ + 'remoteProviders/connected': { + 'hostingProvider.provider': IntegrationId; + 'hostingProvider.key': string; + + /** @deprecated */ + 'remoteProviders.key': string; + }; + /** Sent when a local (Git remote-based) hosting provider is disconnected */ + 'remoteProviders/disconnected': { + 'hostingProvider.provider': IntegrationId; + 'hostingProvider.key': string; + + /** @deprecated */ + 'remoteProviders.key': string; + }; + + /** Sent when the workspace's repositories change */ + 'repositories/changed': { + 'repositories.added': number; + 'repositories.removed': number; + }; + /** Sent when the workspace's repository visibility is first requested */ + 'repositories/visibility': { + 'repositories.visibility': 'private' | 'public' | 'local' | 'mixed'; + }; + + /** Sent when a repository is opened */ + 'repository/opened': { + 'repository.id': string; + 'repository.scheme': string; + 'repository.closed': boolean; + 'repository.folder.scheme': string | undefined; + 'repository.provider.id': string; + 'repository.remoteProviders': string; + }; + /** Sent when a repository's visibility is first requested */ + 'repository/visibility': { + 'repository.visibility': 'private' | 'public' | 'local' | undefined; + 'repository.id': string | undefined; + 'repository.scheme': string | undefined; + 'repository.closed': boolean | undefined; + 'repository.folder.scheme': string | undefined; + 'repository.provider.id': string | undefined; + }; + + /** Sent when the subscription is loaded */ + subscription: SubscriptionEventData; + 'subscription/action': + | { + action: + | 'sign-up' + | 'sign-in' + | 'sign-out' + | 'manage' + | 'reactivate' + | 'resend-verification' + | 'pricing' + | 'start-preview-trial' + | 'upgrade'; + } + | { + action: 'visibility'; + visible: boolean; + }; + /** Sent when the subscription changes */ + 'subscription/changed': SubscriptionEventData; + + /** Sent when a "tracked feature" is interacted with, today that is only when webview/webviewView/custom editor is shown */ + 'usage/track': { + 'usage.key': TrackedUsageKeys; + 'usage.count': number; + }; + + /** Sent when the walkthrough is opened */ + walkthrough: { + step?: + | 'get-started' + | 'core-features' + | 'pro-features' + | 'pro-trial' + | 'pro-upgrade' + | 'pro-reactivate' + | 'pro-paid' + | 'visualize' + | 'launchpad' + | 'code-collab' + | 'integrations' + | 'more'; + }; +}; + +type AIEventBase = { + 'model.id': AIModels; + 'model.provider.id': AIProviders; + 'model.provider.name': string; + 'retry.count': number; + duration?: number; + 'input.length'?: number; + 'output.length'?: number; + 'failed.reason'?: 'user-declined' | 'user-cancelled' | 'error'; + 'failed.error'?: string; +}; + +export type AIGenerateCommitEvent = { + type: 'commitMessage'; +}; + +export type AIGenerateDraftEvent = { + type: 'draftMessage'; + draftType: 'patch' | 'stash' | 'suggested_pr_change'; +}; + +export type LaunchpadTelemetryContext = LaunchpadEventData; + +type LaunchpadEventDataBase = { + instance: number; + 'initialState.group': string | undefined; + 'initialState.selectTopItem': boolean; +}; + +type LaunchpadEventData = LaunchpadEventDataBase & + ( + | Partial<{ 'items.error': string }> + | Partial< + { + 'items.count': number; + 'items.timings.prs': number; + 'items.timings.codeSuggestionCounts': number; + 'items.timings.enrichedItems': number; + 'groups.count': number; + } & Record<`groups.${LaunchpadGroups}.count`, number> & + Record<`groups.${LaunchpadGroups}.collapsed`, boolean | undefined> + > + ); + +type LaunchpadGroups = + | 'current-branch' + | 'pinned' + | 'mergeable' + | 'blocked' + | 'follow-up' + | 'needs-review' + | 'waiting-for-review' + | 'draft' + | 'other' + | 'snoozed'; + +type SubscriptionEventData = { + 'subscription.state'?: SubscriptionState; + 'subscription.status'?: + | 'verification' + | 'free' + | 'preview' + | 'preview-expired' + | 'trial' + | 'trial-expired' + | 'trial-reactivation-eligible' + | 'paid' + | 'unknown'; +} & Partial< + Record<`account.${string}`, string | number | boolean | undefined> & + Record<`subscription.${string}`, string | number | boolean | undefined> & + Record<`subscription.previewTrial.${string}`, string | number | boolean | undefined> & + Record<`previous.account.${string}`, string | number | boolean | undefined> & + Record<`previous.subscription.${string}`, string | number | boolean | undefined> & + Record<`previous.subscription.previewTrial.${string}`, string | number | boolean | undefined> +>; + +/** Used to provide a "source context" to gk.dev for both tracking and customization purposes */ +export type TrackingContext = 'graph' | 'launchpad' | 'visual_file_history' | 'worktrees'; diff --git a/src/constants.ts b/src/constants.ts index 2fa13a8e70613..8a9d890a05876 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,17 +1,10 @@ +export const extensionPrefix = 'gitlens'; export const quickPickTitleMaxChars = 80; -export const slowCallWarningThreshold = 500; -export const ImageMimetypes: Record = { - '.png': 'image/png', - '.gif': 'image/gif', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.jpe': 'image/jpeg', - '.webp': 'image/webp', - '.tif': 'image/tiff', - '.tiff': 'image/tiff', - '.bmp': 'image/bmp', -}; +export const experimentalBadge = 'ᴇxᴘᴇʀÉĒᴍᴇɴᴛᴀʟ'; +export const previewBadge = 'ᴘʀᴇᴠÉĒᴇᴡ'; +export const proBadge = 'ᴘʀᴏ'; +export const proBadgeSuperscript = 'á´žá´ŋá´ŧ'; export const enum CharCode { /** @@ -52,309 +45,10 @@ export const enum CharCode { z = 122, } -export const enum Colors { - GutterBackgroundColor = 'gitlens.gutterBackgroundColor', - GutterForegroundColor = 'gitlens.gutterForegroundColor', - GutterUncommittedForegroundColor = 'gitlens.gutterUncommittedForegroundColor', - TrailingLineBackgroundColor = 'gitlens.trailingLineBackgroundColor', - TrailingLineForegroundColor = 'gitlens.trailingLineForegroundColor', - LineHighlightBackgroundColor = 'gitlens.lineHighlightBackgroundColor', - LineHighlightOverviewRulerColor = 'gitlens.lineHighlightOverviewRulerColor', - ClosedAutolinkedIssueIconColor = 'gitlens.closedAutolinkedIssueIconColor', - ClosedPullRequestIconColor = 'gitlens.closedPullRequestIconColor', - OpenAutolinkedIssueIconColor = 'gitlens.openAutolinkedIssueIconColor', - OpenPullRequestIconColor = 'gitlens.openPullRequestIconColor', - MergedPullRequestIconColor = 'gitlens.mergedPullRequestIconColor', - UnpublishedChangesIconColor = 'gitlens.unpublishedChangesIconColor', - UnpublishedCommitIconColor = 'gitlens.unpublishedCommitIconColor', - UnpulledChangesIconColor = 'gitlens.unpulledChangesIconColor', -} - -export const enum Commands { - ActionPrefix = 'gitlens.action.', - - AddAuthors = 'gitlens.addAuthors', - BrowseRepoAtRevision = 'gitlens.browseRepoAtRevision', - BrowseRepoAtRevisionInNewWindow = 'gitlens.browseRepoAtRevisionInNewWindow', - BrowseRepoBeforeRevision = 'gitlens.browseRepoBeforeRevision', - BrowseRepoBeforeRevisionInNewWindow = 'gitlens.browseRepoBeforeRevisionInNewWindow', - ClearFileAnnotations = 'gitlens.clearFileAnnotations', - CloseUnchangedFiles = 'gitlens.closeUnchangedFiles', - CloseWelcomeView = 'gitlens.closeWelcomeView', - CompareWith = 'gitlens.compareWith', - CompareHeadWith = 'gitlens.compareHeadWith', - CompareWorkingWith = 'gitlens.compareWorkingWith', - ComputingFileAnnotations = 'gitlens.computingFileAnnotations', - ConnectRemoteProvider = 'gitlens.connectRemoteProvider', - CopyAutolinkUrl = 'gitlens.copyAutolinkUrl', - CopyCurrentBranch = 'gitlens.copyCurrentBranch', - CopyDeepLinkToBranch = 'gitlens.copyDeepLinkToBranch', - CopyDeepLinkToCommit = 'gitlens.copyDeepLinkToCommit', - CopyDeepLinkToRepo = 'gitlens.copyDeepLinkToRepo', - CopyDeepLinkToTag = 'gitlens.copyDeepLinkToTag', - CopyMessageToClipboard = 'gitlens.copyMessageToClipboard', - CopyRemoteBranchesUrl = 'gitlens.copyRemoteBranchesUrl', - CopyRemoteBranchUrl = 'gitlens.copyRemoteBranchUrl', - CopyRemoteCommitUrl = 'gitlens.copyRemoteCommitUrl', - CopyRemoteComparisonUrl = 'gitlens.copyRemoteComparisonUrl', - CopyRemoteFileUrl = 'gitlens.copyRemoteFileUrlToClipboard', - CopyRemoteFileUrlWithoutRange = 'gitlens.copyRemoteFileUrlWithoutRange', - CopyRemoteFileUrlFrom = 'gitlens.copyRemoteFileUrlFrom', - CopyRemoteIssueUrl = 'gitlens.copyRemoteIssueUrl', - CopyRemotePullRequestUrl = 'gitlens.copyRemotePullRequestUrl', - CopyRemoteRepositoryUrl = 'gitlens.copyRemoteRepositoryUrl', - CopyShaToClipboard = 'gitlens.copyShaToClipboard', - CreatePullRequestOnRemote = 'gitlens.createPullRequestOnRemote', - DiffDirectory = 'gitlens.diffDirectory', - DiffDirectoryWithHead = 'gitlens.diffDirectoryWithHead', - DiffWith = 'gitlens.diffWith', - DiffWithNext = 'gitlens.diffWithNext', - DiffWithNextInDiffLeft = 'gitlens.diffWithNextInDiffLeft', - DiffWithNextInDiffRight = 'gitlens.diffWithNextInDiffRight', - DiffWithPrevious = 'gitlens.diffWithPrevious', - DiffWithPreviousInDiffLeft = 'gitlens.diffWithPreviousInDiffLeft', - DiffWithPreviousInDiffRight = 'gitlens.diffWithPreviousInDiffRight', - DiffLineWithPrevious = 'gitlens.diffLineWithPrevious', - DiffWithRevision = 'gitlens.diffWithRevision', - DiffWithRevisionFrom = 'gitlens.diffWithRevisionFrom', - DiffWithWorking = 'gitlens.diffWithWorking', - DiffWithWorkingInDiffLeft = 'gitlens.diffWithWorkingInDiffLeft', - DiffWithWorkingInDiffRight = 'gitlens.diffWithWorkingInDiffRight', - DiffLineWithWorking = 'gitlens.diffLineWithWorking', - DisconnectRemoteProvider = 'gitlens.disconnectRemoteProvider', - DisableDebugLogging = 'gitlens.disableDebugLogging', - EnableDebugLogging = 'gitlens.enableDebugLogging', - DisableRebaseEditor = 'gitlens.disableRebaseEditor', - EnableRebaseEditor = 'gitlens.enableRebaseEditor', - ExternalDiff = 'gitlens.externalDiff', - ExternalDiffAll = 'gitlens.externalDiffAll', - FetchRepositories = 'gitlens.fetchRepositories', - GetStarted = 'gitlens.getStarted', - InviteToLiveShare = 'gitlens.inviteToLiveShare', - OpenAutolinkUrl = 'gitlens.openAutolinkUrl', - OpenBlamePriorToChange = 'gitlens.openBlamePriorToChange', - OpenBranchesOnRemote = 'gitlens.openBranchesOnRemote', - OpenBranchOnRemote = 'gitlens.openBranchOnRemote', - OpenCurrentBranchOnRemote = 'gitlens.openCurrentBranchOnRemote', - OpenChangedFiles = 'gitlens.openChangedFiles', - OpenCommitOnRemote = 'gitlens.openCommitOnRemote', - OpenComparisonOnRemote = 'gitlens.openComparisonOnRemote', - OpenFileHistory = 'gitlens.openFileHistory', - OpenFileFromRemote = 'gitlens.openFileFromRemote', - OpenFileOnRemote = 'gitlens.openFileOnRemote', - OpenFileOnRemoteFrom = 'gitlens.openFileOnRemoteFrom', - OpenFileAtRevision = 'gitlens.openFileRevision', - OpenFileAtRevisionFrom = 'gitlens.openFileRevisionFrom', - OpenFolderHistory = 'gitlens.openFolderHistory', - OpenOnRemote = 'gitlens.openOnRemote', - OpenIssueOnRemote = 'gitlens.openIssueOnRemote', - OpenPullRequestOnRemote = 'gitlens.openPullRequestOnRemote', - OpenAssociatedPullRequestOnRemote = 'gitlens.openAssociatedPullRequestOnRemote', - OpenRepoOnRemote = 'gitlens.openRepoOnRemote', - OpenRevisionFile = 'gitlens.openRevisionFile', - OpenRevisionFileInDiffLeft = 'gitlens.openRevisionFileInDiffLeft', - OpenRevisionFileInDiffRight = 'gitlens.openRevisionFileInDiffRight', - OpenWalkthrough = 'gitlens.openWalkthrough', - OpenWorkingFile = 'gitlens.openWorkingFile', - OpenWorkingFileInDiffLeft = 'gitlens.openWorkingFileInDiffLeft', - OpenWorkingFileInDiffRight = 'gitlens.openWorkingFileInDiffRight', - PullRepositories = 'gitlens.pullRepositories', - PushRepositories = 'gitlens.pushRepositories', - GitCommands = 'gitlens.gitCommands', - GitCommandsBranch = 'gitlens.gitCommands.branch', - GitCommandsCherryPick = 'gitlens.gitCommands.cherryPick', - GitCommandsMerge = 'gitlens.gitCommands.merge', - GitCommandsRebase = 'gitlens.gitCommands.rebase', - GitCommandsReset = 'gitlens.gitCommands.reset', - GitCommandsRevert = 'gitlens.gitCommands.revert', - GitCommandsSwitch = 'gitlens.gitCommands.switch', - GitCommandsTag = 'gitlens.gitCommands.tag', - GitCommandsWorktree = 'gitlens.gitCommands.worktree', - OpenOrCreateWorktreeForGHPR = 'gitlens.ghpr.views.openOrCreateWorktree', - PlusHide = 'gitlens.plus.hide', - PlusLearn = 'gitlens.plus.learn', - PlusLoginOrSignUp = 'gitlens.plus.loginOrSignUp', - PlusLogout = 'gitlens.plus.logout', - PlusManage = 'gitlens.plus.manage', - PlusPurchase = 'gitlens.plus.purchase', - PlusResendVerification = 'gitlens.plus.resendVerification', - PlusRestore = 'gitlens.plus.restore', - PlusShowPlans = 'gitlens.plus.showPlans', - PlusStartPreviewTrial = 'gitlens.plus.startPreviewTrial', - PlusValidate = 'gitlens.plus.validate', - QuickOpenFileHistory = 'gitlens.quickOpenFileHistory', - RefreshGraph = 'gitlens.graph.refresh', - RefreshFocus = 'gitlens.focus.refresh', - RefreshHover = 'gitlens.refreshHover', - RefreshTimelinePage = 'gitlens.refreshTimelinePage', - ResetAvatarCache = 'gitlens.resetAvatarCache', - ResetSuppressedWarnings = 'gitlens.resetSuppressedWarnings', - ResetTrackedUsage = 'gitlens.resetTrackedUsage', - RevealCommitInView = 'gitlens.revealCommitInView', - SearchCommits = 'gitlens.showCommitSearch', - SearchCommitsInView = 'gitlens.views.searchAndCompare.searchCommits', - SetViewsLayout = 'gitlens.setViewsLayout', - ShowBranchesView = 'gitlens.showBranchesView', - ShowCommitInView = 'gitlens.showCommitInView', - ShowCommitsInView = 'gitlens.showCommitsInView', - ShowCommitsView = 'gitlens.showCommitsView', - ShowContributorsView = 'gitlens.showContributorsView', - ShowHomeView = 'gitlens.showHomeView', - ShowFileHistoryView = 'gitlens.showFileHistoryView', - ShowInCommitGraph = 'gitlens.showInCommitGraph', - ShowInDetailsView = 'gitlens.showInDetailsView', - ShowLastQuickPick = 'gitlens.showLastQuickPick', - ShowLineHistoryView = 'gitlens.showLineHistoryView', - ShowQuickBranchHistory = 'gitlens.showQuickBranchHistory', - ShowQuickCommit = 'gitlens.showQuickCommitDetails', - ShowQuickCommitFile = 'gitlens.showQuickCommitFileDetails', - ShowQuickCurrentBranchHistory = 'gitlens.showQuickRepoHistory', - ShowQuickFileHistory = 'gitlens.showQuickFileHistory', - ShowQuickRepoStatus = 'gitlens.showQuickRepoStatus', - ShowQuickCommitRevision = 'gitlens.showQuickRevisionDetails', - ShowQuickCommitRevisionInDiffLeft = 'gitlens.showQuickRevisionDetailsInDiffLeft', - ShowQuickCommitRevisionInDiffRight = 'gitlens.showQuickRevisionDetailsInDiffRight', - ShowQuickStashList = 'gitlens.showQuickStashList', - ShowRemotesView = 'gitlens.showRemotesView', - ShowRepositoriesView = 'gitlens.showRepositoriesView', - ShowSearchAndCompareView = 'gitlens.showSearchAndCompareView', - ShowSettingsPage = 'gitlens.showSettingsPage', - ShowSettingsPageAndJumpToBranchesView = 'gitlens.showSettingsPage#branches-view', - ShowSettingsPageAndJumpToCommitsView = 'gitlens.showSettingsPage#commits-view', - ShowSettingsPageAndJumpToContributorsView = 'gitlens.showSettingsPage#contributors-view', - ShowSettingsPageAndJumpToFileHistoryView = 'gitlens.showSettingsPage#file-history-view', - ShowSettingsPageAndJumpToLineHistoryView = 'gitlens.showSettingsPage#line-history-view', - ShowSettingsPageAndJumpToRemotesView = 'gitlens.showSettingsPage#remotes-view', - ShowSettingsPageAndJumpToRepositoriesView = 'gitlens.showSettingsPage#repositories-view', - ShowSettingsPageAndJumpToSearchAndCompareView = 'gitlens.showSettingsPage#search-compare-view', - ShowSettingsPageAndJumpToStashesView = 'gitlens.showSettingsPage#stashes-view', - ShowSettingsPageAndJumpToTagsView = 'gitlens.showSettingsPage#tags-view', - ShowSettingsPageAndJumpToWorkTreesView = 'gitlens.showSettingsPage#worktrees-view', - ShowSettingsPageAndJumpToViews = 'gitlens.showSettingsPage#views', - ShowSettingsPageAndJumpToCommitGraph = 'gitlens.showSettingsPage#commit-graph', - ShowSettingsPageAndJumpToAutolinks = 'gitlens.showSettingsPage#autolinks', - ShowStashesView = 'gitlens.showStashesView', - ShowTagsView = 'gitlens.showTagsView', - ShowWorktreesView = 'gitlens.showWorktreesView', - ShowCommitDetailsView = 'gitlens.showCommitDetailsView', - ShowTimelinePage = 'gitlens.showTimelinePage', - ShowTimelineView = 'gitlens.showTimelineView', - ShowGraphPage = 'gitlens.showGraphPage', - ShowWelcomePage = 'gitlens.showWelcomePage', - ShowFocusPage = 'gitlens.showFocusPage', - StashApply = 'gitlens.stashApply', - StashSave = 'gitlens.stashSave', - StashSaveFiles = 'gitlens.stashSaveFiles', - SwitchMode = 'gitlens.switchMode', - ToggleCodeLens = 'gitlens.toggleCodeLens', - ToggleFileBlame = 'gitlens.toggleFileBlame', - ToggleFileBlameInDiffLeft = 'gitlens.toggleFileBlameInDiffLeft', - ToggleFileBlameInDiffRight = 'gitlens.toggleFileBlameInDiffRight', - ToggleFileChanges = 'gitlens.toggleFileChanges', - ToggleFileChangesOnly = 'gitlens.toggleFileChangesOnly', - ToggleFileHeatmap = 'gitlens.toggleFileHeatmap', - ToggleFileHeatmapInDiffLeft = 'gitlens.toggleFileHeatmapInDiffLeft', - ToggleFileHeatmapInDiffRight = 'gitlens.toggleFileHeatmapInDiffRight', - ToggleLineBlame = 'gitlens.toggleLineBlame', - ToggleReviewMode = 'gitlens.toggleReviewMode', - ToggleZenMode = 'gitlens.toggleZenMode', - ViewsCopy = 'gitlens.views.copy', - ViewsOpenDirectoryDiff = 'gitlens.views.openDirectoryDiff', - ViewsOpenDirectoryDiffWithWorking = 'gitlens.views.openDirectoryDiffWithWorking', - - Deprecated_DiffHeadWith = 'gitlens.diffHeadWith', - Deprecated_DiffWorkingWith = 'gitlens.diffWorkingWith', - Deprecated_OpenBranchesInRemote = 'gitlens.openBranchesInRemote', - Deprecated_OpenBranchInRemote = 'gitlens.openBranchInRemote', - Deprecated_OpenCommitInRemote = 'gitlens.openCommitInRemote', - Deprecated_OpenFileInRemote = 'gitlens.openFileInRemote', - Deprecated_OpenInRemote = 'gitlens.openInRemote', - Deprecated_OpenRepoInRemote = 'gitlens.openRepoInRemote', - Deprecated_ShowFileHistoryInView = 'gitlens.showFileHistoryInView', -} - -export const enum ContextKeys { - ActionPrefix = 'gitlens:action:', - KeyPrefix = 'gitlens:key:', - WebviewPrefix = `gitlens:webview:`, - WebviewViewPrefix = `gitlens:webviewView:`, - - ActiveFileStatus = 'gitlens:activeFileStatus', - AnnotationStatus = 'gitlens:annotationStatus', - Debugging = 'gitlens:debugging', - DisabledToggleCodeLens = 'gitlens:disabledToggleCodeLens', - Disabled = 'gitlens:disabled', - Enabled = 'gitlens:enabled', - FocusFocused = 'gitlens:focus:focused', - HasConnectedRemotes = 'gitlens:hasConnectedRemotes', - HasRemotes = 'gitlens:hasRemotes', - HasRichRemotes = 'gitlens:hasRichRemotes', - HasVirtualFolders = 'gitlens:hasVirtualFolders', - Readonly = 'gitlens:readonly', - Untrusted = 'gitlens:untrusted', - ViewsCanCompare = 'gitlens:views:canCompare', - ViewsCanCompareFile = 'gitlens:views:canCompare:file', - ViewsCommitsMyCommitsOnly = 'gitlens:views:commits:myCommitsOnly', - ViewsFileHistoryCanPin = 'gitlens:views:fileHistory:canPin', - ViewsFileHistoryCursorFollowing = 'gitlens:views:fileHistory:cursorFollowing', - ViewsFileHistoryEditorFollowing = 'gitlens:views:fileHistory:editorFollowing', - ViewsLineHistoryEditorFollowing = 'gitlens:views:lineHistory:editorFollowing', - ViewsRepositoriesAutoRefresh = 'gitlens:views:repositories:autoRefresh', - ViewsSearchAndCompareKeepResults = 'gitlens:views:searchAndCompare:keepResults', - Vsls = 'gitlens:vsls', - - Plus = 'gitlens:plus', - PlusDisallowedRepos = 'gitlens:plus:disallowedRepos', - PlusEnabled = 'gitlens:plus:enabled', - PlusRequired = 'gitlens:plus:required', - PlusState = 'gitlens:plus:state', -} - -export const enum CoreCommands { - CloseActiveEditor = 'workbench.action.closeActiveEditor', - CloseAllEditors = 'workbench.action.closeAllEditors', - CursorMove = 'cursorMove', - CustomEditorShowFindWidget = 'editor.action.webvieweditor.showFind', - Diff = 'vscode.diff', - EditorScroll = 'editorScroll', - EditorShowHover = 'editor.action.showHover', - ExecuteDocumentSymbolProvider = 'vscode.executeDocumentSymbolProvider', - ExecuteCodeLensProvider = 'vscode.executeCodeLensProvider', - FocusFilesExplorer = 'workbench.files.action.focusFilesExplorer', - InstallExtension = 'workbench.extensions.installExtension', - MoveViews = 'vscode.moveViews', - Open = 'vscode.open', - OpenFolder = 'vscode.openFolder', - OpenInTerminal = 'openInTerminal', - OpenWalkthrough = 'workbench.action.openWalkthrough', - OpenWith = 'vscode.openWith', - NextEditor = 'workbench.action.nextEditor', - PreviewHtml = 'vscode.previewHtml', - RevealLine = 'revealLine', - RevealInExplorer = 'revealInExplorer', - RevealInFileExplorer = 'revealFileInOS', - SetContext = 'setContext', - ShowExplorer = 'workbench.view.explorer', - ShowReferences = 'editor.action.showReferences', - ShowSCM = 'workbench.view.scm', - UninstallExtension = 'workbench.extensions.uninstallExtension', -} - -export const enum CoreGitCommands { - Publish = 'git.publish', - Pull = 'git.pull', - PullRebase = 'git.pullRebase', - Push = 'git.push', - PushForce = 'git.pushForce', - UndoCommit = 'git.undoCommit', -} - -export const enum CoreGitConfiguration { - AutoRepositoryDetection = 'git.autoRepositoryDetection', - RepositoryScanMaxDepth = 'git.repositoryScanMaxDepth', - FetchOnPull = 'git.fetchOnPull', - UseForcePushWithLease = 'git.useForcePushWithLease', -} +export type GitConfigKeys = + | `branch.${string}.${'gk' | 'vscode'}-merge-base` + | `branch.${string}.gk-target-base` + | `branch.${string}.github-pr-owner-number`; export const enum GlyphChars { AngleBracketLeftHeavy = '\u2770', @@ -403,23 +97,91 @@ export const enum GlyphChars { ZeroWidthSpace = '\u200b', } -export const enum LogLevel { - Off = 'off', - Error = 'error', - Warn = 'warn', - Info = 'info', - Debug = 'debug', -} +export const imageMimetypes: Record = Object.freeze({ + '.png': 'image/png', + '.gif': 'image/gif', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.jpe': 'image/jpeg', + '.webp': 'image/webp', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.bmp': 'image/bmp', +}); + +export const keys = Object.freeze([ + 'left', + 'alt+left', + 'ctrl+left', + 'right', + 'alt+right', + 'ctrl+right', + 'alt+,', + 'alt+.', + 'alt+enter', + 'ctrl+enter', + 'escape', +] as const); +export type Keys = (typeof keys)[number]; + +export type PromoKeys = 'launchpad' | 'launchpad-extended' | 'pro50'; export const enum Schemes { - DebugConsole = 'debug', File = 'file', Git = 'git', GitHub = 'github', GitLens = 'gitlens', - Output = 'output', PRs = 'pr', + Remote = 'vscode-remote', Vsls = 'vsls', VslsScc = 'vsls-scc', Virtual = 'vscode-vfs', } + +export const trackableSchemes = Object.freeze( + new Set([ + Schemes.File, + Schemes.Git, + Schemes.GitLens, + Schemes.PRs, + Schemes.Remote, + Schemes.Vsls, + Schemes.VslsScc, + Schemes.Virtual, + Schemes.GitHub, + ]), +); + +const utm = 'utm_source=gitlens-extension&utm_medium=in-app-links'; +export const urls = Object.freeze({ + codeSuggest: `https://gitkraken.com/solutions/code-suggest?${utm}`, + cloudPatches: `https://gitkraken.com/solutions/cloud-patches?${utm}`, + graph: `https://gitkraken.com/solutions/commit-graph?${utm}`, + launchpad: `https://gitkraken.com/solutions/launchpad?${utm}`, + platform: `https://gitkraken.com/devex?${utm}`, + pricing: `https://gitkraken.com/gitlens/pricing?${utm}`, + proFeatures: `https://gitkraken.com/gitlens/pro-features?${utm}`, + security: `https://help.gitkraken.com/gitlens/security?${utm}`, + workspaces: `https://gitkraken.com/solutions/workspaces?${utm}`, + + cli: `https://gitkraken.com/cli?${utm}`, + browserExtension: `https://gitkraken.com/browser-extension?${utm}`, + desktop: `https://gitkraken.com/git-client?${utm}`, + + releaseNotes: 'https://help.gitkraken.com/gitlens/gitlens-release-notes-current/', + releaseAnnouncement: `https://www.gitkraken.com/blog/gitkraken-launches-devex-platform-acquires-codesee?${utm}`, +}); + +export type WalkthroughSteps = + | 'get-started' + | 'core-features' + | 'pro-features' + | 'pro-trial' + | 'pro-upgrade' + | 'pro-reactivate' + | 'pro-paid' + | 'visualize' + | 'launchpad' + | 'code-collab' + | 'integrations' + | 'more'; diff --git a/src/constants.views.ts b/src/constants.views.ts new file mode 100644 index 0000000000000..464b21ba4ed3a --- /dev/null +++ b/src/constants.views.ts @@ -0,0 +1,139 @@ +export type CustomEditorTypes = 'rebase'; +export type CustomEditorIds = `gitlens.${CustomEditorTypes}`; + +export type TreeViewTypes = + | 'branches' + | 'commits' + | 'contributors' + | 'drafts' + | 'fileHistory' + | 'launchpad' + | 'lineHistory' + | 'pullRequest' + | 'remotes' + | 'repositories' + | 'searchAndCompare' + | 'stashes' + | 'tags' + | 'workspaces' + | 'worktrees'; +export type TreeViewIds = `gitlens.views.${T}`; + +export type WebviewTypes = 'focus' | 'graph' | 'patchDetails' | 'settings' | 'timeline' | 'welcome'; +export type WebviewIds = `gitlens.${WebviewTypes}`; + +export type WebviewViewTypes = + | 'account' + | 'commitDetails' + | 'graph' + | 'graphDetails' + | 'home' + | 'patchDetails' + | 'timeline'; +export type WebviewViewIds = `gitlens.views.${T}`; + +export type ViewTypes = TreeViewTypes | WebviewViewTypes; +export type ViewIds = TreeViewIds | WebviewViewIds; + +export type ViewContainerTypes = 'gitlens' | 'gitlensInspect' | 'gitlensPanel'; +export type ViewContainerIds = `workbench.view.extension.${ViewContainerTypes}`; + +export type CoreViewContainerTypes = 'scm'; +export type CoreViewContainerIds = `workbench.view.${CoreViewContainerTypes}`; + +// export const viewTypes: ViewTypes[] = [ +// 'account', +// 'branches', +// 'commits', +// 'commitDetails', +// 'contributors', +// 'fileHistory', +// 'graph', +// 'graphDetails', +// 'home', +// 'lineHistory', +// 'remotes', +// 'repositories', +// 'searchAndCompare', +// 'stashes', +// 'tags', +// 'timeline', +// 'workspaces', +// 'worktrees', +// ]; + +export const viewIdsByDefaultContainerId = new Map([ + [ + 'workbench.view.scm', + ['branches', 'commits', 'remotes', 'repositories', 'stashes', 'tags', 'worktrees', 'contributors'], + ], + ['workbench.view.extension.gitlensPanel', ['graph', 'graphDetails']], + [ + 'workbench.view.extension.gitlensInspect', + ['commitDetails', 'fileHistory', 'lineHistory', 'timeline', 'searchAndCompare'], + ], + ['workbench.view.extension.gitlens', ['home', 'workspaces', 'account']], +]); + +export type TreeViewRefNodeTypes = 'branch' | 'commit' | 'stash' | 'tag'; +export type TreeViewRefFileNodeTypes = 'commit-file' | 'file-commit' | 'results-file' | 'stash-file'; +export type TreeViewFileNodeTypes = + | TreeViewRefFileNodeTypes + | 'conflict-file' + | 'folder' + | 'status-file' + | 'uncommitted-file'; +export type TreeViewSubscribableNodeTypes = + | 'compare-branch' + | 'compare-results' + | 'file-history' + | 'file-history-tracker' + | 'line-history' + | 'line-history-tracker' + | 'repositories' + | 'repository' + | 'repo-folder' + | 'search-results' + | 'workspace'; +export type TreeViewNodeTypes = + | TreeViewRefNodeTypes + | TreeViewFileNodeTypes + | TreeViewSubscribableNodeTypes + | 'autolink' + | 'autolinks' + | 'branch-tag-folder' + | 'branches' + | 'compare-picker' + | 'contributor' + | 'contributors' + | 'conflict-files' + | 'conflict-current-changes' + | 'conflict-incoming-changes' + | 'draft' + | 'drafts' + | 'drafts-code-suggestions' + | 'grouping' + | 'launchpad' + | 'launchpad-item' + | 'merge-status' + | 'message' + | 'pager' + | 'pullrequest' + | 'rebase-status' + | 'reflog' + | 'reflog-record' + | 'remote' + | 'remotes' + | 'results-commits' + | 'results-files' + | 'search-compare' + | 'stashes' + | 'status-files' + | 'tags' + | 'tracking-status' + | 'tracking-status-files' + | 'uncommitted-files' + | 'workspace-missing-repository' + | 'workspaces' + | 'worktree' + | 'worktrees'; diff --git a/src/container.ts b/src/container.ts index cbb280852f0df..85afba5ab0bd2 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,49 +1,81 @@ -import type { ConfigurationChangeEvent, Event, ExtensionContext } from 'vscode'; -import { EventEmitter, ExtensionMode } from 'vscode'; -import { getSupportedGitProviders } from '@env/providers'; +import { getSupportedGitProviders, getSupportedRepositoryPathMappingProvider } from '@env/providers'; +import type { ConfigurationChangeEvent, Disposable, Event, ExtensionContext } from 'vscode'; +import { EventEmitter, ExtensionMode, Uri } from 'vscode'; +import type { AIProviderService } from './ai/aiProviderService'; import { Autolinks } from './annotations/autolinks'; import { FileAnnotationController } from './annotations/fileAnnotationController'; import { LineAnnotationController } from './annotations/lineAnnotationController'; import { ActionRunners } from './api/actionRunners'; import { setDefaultGravatarsStyle } from './avatars'; +import { CacheProvider } from './cache'; import { GitCodeLensController } from './codelens/codeLensController'; -import type { ToggleFileAnnotationCommandArgs } from './commands'; -import type { FileAnnotationType, ModeConfig } from './configuration'; -import { AnnotationsToggleMode, configuration, DateSource, DateStyle, fromOutputLevel } from './configuration'; -import { Commands } from './constants'; +import type { ToggleFileAnnotationCommandArgs } from './commands/toggleFileAnnotations'; +import type { DateStyle, FileAnnotationType, ModeConfig } from './config'; +import { fromOutputLevel } from './config'; +import { extensionPrefix } from './constants'; +import { Commands } from './constants.commands'; import { EventBus } from './eventBus'; import { GitFileSystemProvider } from './git/fsProvider'; import { GitProviderService } from './git/gitProviderService'; -import { GitHubAuthenticationProvider } from './git/remotes/github'; -import { GitLabAuthenticationProvider } from './git/remotes/gitlab'; import { LineHoverController } from './hovers/lineHoverController'; -import { Keyboard } from './keyboard'; -import { Logger } from './logger'; -import { IntegrationAuthenticationService } from './plus/integrationAuthentication'; -import { SubscriptionAuthenticationProvider } from './plus/subscription/authenticationProvider'; -import { ServerConnection } from './plus/subscription/serverConnection'; -import { SubscriptionService } from './plus/subscription/subscriptionService'; -import { FocusWebview } from './plus/webviews/focus/focusWebview'; -import { GraphWebview } from './plus/webviews/graph/graphWebview'; -import { TimelineWebview } from './plus/webviews/timeline/timelineWebview'; -import { TimelineWebviewView } from './plus/webviews/timeline/timelineWebviewView'; +import type { RepositoryPathMappingProvider } from './pathMapping/repositoryPathMappingProvider'; +import { DraftService } from './plus/drafts/draftsService'; +import { AccountAuthenticationProvider } from './plus/gk/account/authenticationProvider'; +import { OrganizationService } from './plus/gk/account/organizationService'; +import { SubscriptionService } from './plus/gk/account/subscriptionService'; +import { ServerConnection } from './plus/gk/serverConnection'; +import type { CloudIntegrationService } from './plus/integrations/authentication/cloudIntegrationService'; +import { IntegrationAuthenticationService } from './plus/integrations/authentication/integrationAuthentication'; +import { IntegrationService } from './plus/integrations/integrationService'; +import type { GitHubApi } from './plus/integrations/providers/github/github'; +import type { GitLabApi } from './plus/integrations/providers/gitlab/gitlab'; +import { EnrichmentService } from './plus/launchpad/enrichmentService'; +import { LaunchpadIndicator } from './plus/launchpad/launchpadIndicator'; +import { LaunchpadProvider } from './plus/launchpad/launchpadProvider'; +import { RepositoryIdentityService } from './plus/repos/repositoryIdentityService'; +import { registerAccountWebviewView } from './plus/webviews/account/registration'; +import type { GraphWebviewShowingArgs } from './plus/webviews/graph/registration'; +import { + registerGraphWebviewCommands, + registerGraphWebviewPanel, + registerGraphWebviewView, +} from './plus/webviews/graph/registration'; +import { GraphStatusBarController } from './plus/webviews/graph/statusbar'; +import type { PatchDetailsWebviewShowingArgs } from './plus/webviews/patchDetails/registration'; +import { + registerPatchDetailsWebviewPanel, + registerPatchDetailsWebviewView, +} from './plus/webviews/patchDetails/registration'; +import type { TimelineWebviewShowingArgs } from './plus/webviews/timeline/registration'; +import { + registerTimelineWebviewCommands, + registerTimelineWebviewPanel, + registerTimelineWebviewView, +} from './plus/webviews/timeline/registration'; +import { scheduleAddMissingCurrentWorkspaceRepos, WorkspacesService } from './plus/workspaces/workspacesService'; import { StatusBarController } from './statusbar/statusBarController'; -import type { Storage } from './storage'; -import { executeCommand } from './system/command'; import { log } from './system/decorators/log'; import { memoize } from './system/decorators/memoize'; +import { Logger } from './system/logger'; +import { executeCommand } from './system/vscode/command'; +import { configuration } from './system/vscode/configuration'; +import { Keyboard } from './system/vscode/keyboard'; +import type { Storage } from './system/vscode/storage'; import { TelemetryService } from './telemetry/telemetry'; +import { UsageTracker } from './telemetry/usageTracker'; import { GitTerminalLinkProvider } from './terminal/linkProvider'; -import { GitDocumentTracker } from './trackers/gitDocumentTracker'; -import { GitLineTracker } from './trackers/gitLineTracker'; +import { GitDocumentTracker } from './trackers/documentTracker'; +import { LineTracker } from './trackers/lineTracker'; import { DeepLinkService } from './uris/deepLinks/deepLinkService'; import { UriService } from './uris/uriService'; -import { UsageTracker } from './usageTracker'; import { BranchesView } from './views/branchesView'; import { CommitsView } from './views/commitsView'; import { ContributorsView } from './views/contributorsView'; +import { DraftsView } from './views/draftsView'; import { FileHistoryView } from './views/fileHistoryView'; +import { LaunchpadView } from './views/launchpadView'; import { LineHistoryView } from './views/lineHistoryView'; +import { PullRequestView } from './views/pullRequestView'; import { RemotesView } from './views/remotesView'; import { RepositoriesView } from './views/repositoriesView'; import { SearchAndCompareView } from './views/searchAndCompareView'; @@ -51,18 +83,27 @@ import { StashesView } from './views/stashesView'; import { TagsView } from './views/tagsView'; import { ViewCommands } from './views/viewCommands'; import { ViewFileDecorationProvider } from './views/viewDecorationProvider'; +import { WorkspacesView } from './views/workspacesView'; import { WorktreesView } from './views/worktreesView'; import { VslsController } from './vsls/vsls'; -import { CommitDetailsWebviewView } from './webviews/commitDetails/commitDetailsWebviewView'; -import { HomeWebviewView } from './webviews/home/homeWebviewView'; +import type { CommitDetailsWebviewShowingArgs } from './webviews/commitDetails/registration'; +import { + registerCommitDetailsWebviewView, + registerGraphDetailsWebviewView, +} from './webviews/commitDetails/registration'; +import { registerHomeWebviewView } from './webviews/home/registration'; import { RebaseEditorProvider } from './webviews/rebase/rebaseEditor'; -import { SettingsWebview } from './webviews/settings/settingsWebview'; -import { WelcomeWebview } from './webviews/welcome/welcomeWebview'; +import { registerSettingsWebviewCommands, registerSettingsWebviewPanel } from './webviews/settings/registration'; +import type { WebviewViewProxy } from './webviews/webviewsController'; +import { WebviewsController } from './webviews/webviewsController'; +import { registerWelcomeWebviewPanel } from './webviews/welcome/registration'; + +export type Environment = 'dev' | 'staging' | 'production'; export class Container { static #instance: Container | undefined; static #proxy = new Proxy({} as Container, { - get: function (target, prop) { + get: function (_target, prop) { // In case anyone has cached this instance // eslint-disable-next-line @typescript-eslint/no-unsafe-return if (Container.#instance != null) return (Container.#instance as any)[prop]; @@ -109,8 +150,8 @@ export class Container { readonly CommitDateFormatting = { dateFormat: null as string | null, - dateSource: DateSource.Authored, - dateStyle: DateStyle.Relative, + dateSource: 'authored', + dateStyle: 'relative', reset: () => { this.CommitDateFormatting.dateFormat = configuration.get('defaultDateFormat'); @@ -130,7 +171,7 @@ export class Container { readonly PullRequestDateFormatting = { dateFormat: null as string | null, - dateStyle: DateStyle.Relative, + dateStyle: 'relative', reset: () => { this.PullRequestDateFormatting.dateFormat = configuration.get('defaultDateFormat'); @@ -140,7 +181,7 @@ export class Container { readonly TagDateFormatting = { dateFormat: null as string | null, - dateStyle: DateStyle.Relative, + dateStyle: 'relative', reset: () => { this.TagDateFormatting.dateFormat = configuration.get('defaultDateFormat'); @@ -148,8 +189,11 @@ export class Container { }, }; - private _configAffectedByModeRegex: RegExp | undefined; + private readonly _connection: ServerConnection; + private _disposables: Disposable[]; private _terminalLinks: GitTerminalLinkProvider | undefined; + private _webviews: WebviewsController; + private _launchpadIndicator: LaunchpadIndicator | undefined; private constructor( context: ExtensionContext, @@ -163,78 +207,140 @@ export class Container { this._version = version; this.ensureModeApplied(); - context.subscriptions.push((this._storage = storage)); - context.subscriptions.push((this._telemetry = new TelemetryService(this))); - context.subscriptions.push((this._usage = new UsageTracker(this, storage))); + this._disposables = [ + (this._storage = storage), + (this._telemetry = new TelemetryService(this)), + (this._usage = new UsageTracker(this, storage)), + configuration.onDidChangeAny(this.onAnyConfigurationChanged, this), + ]; - context.subscriptions.push(configuration.onWillChange(this.onConfigurationChanging, this)); + this._disposables.push((this._connection = new ServerConnection(this))); - const server = new ServerConnection(this); - context.subscriptions.push(server); - context.subscriptions.push( - (this._subscriptionAuthentication = new SubscriptionAuthenticationProvider(this, server)), + this._disposables.push( + (this._accountAuthentication = new AccountAuthenticationProvider(this, this._connection)), ); - context.subscriptions.push((this._subscription = new SubscriptionService(this, previousVersion))); - - context.subscriptions.push((this._git = new GitProviderService(this))); - context.subscriptions.push(new GitFileSystemProvider(this)); - - context.subscriptions.push((this._uri = new UriService(this))); - - context.subscriptions.push((this._deepLinks = new DeepLinkService(this))); - - context.subscriptions.push((this._actionRunners = new ActionRunners(this))); - context.subscriptions.push((this._tracker = new GitDocumentTracker(this))); - context.subscriptions.push((this._lineTracker = new GitLineTracker(this))); - context.subscriptions.push((this._keyboard = new Keyboard())); - context.subscriptions.push((this._vsls = new VslsController(this))); - context.subscriptions.push((this._eventBus = new EventBus())); - - context.subscriptions.push((this._fileAnnotationController = new FileAnnotationController(this))); - context.subscriptions.push((this._lineAnnotationController = new LineAnnotationController(this))); - context.subscriptions.push((this._lineHoverController = new LineHoverController(this))); - context.subscriptions.push((this._statusBarController = new StatusBarController(this))); - context.subscriptions.push((this._codeLensController = new GitCodeLensController(this))); - - context.subscriptions.push((this._settingsWebview = new SettingsWebview(this))); - context.subscriptions.push((this._timelineWebview = new TimelineWebview(this))); - context.subscriptions.push((this._welcomeWebview = new WelcomeWebview(this))); - context.subscriptions.push((this._rebaseEditor = new RebaseEditorProvider(this))); - context.subscriptions.push((this._graphWebview = new GraphWebview(this))); - context.subscriptions.push((this._focusWebview = new FocusWebview(this))); - - context.subscriptions.push(new ViewFileDecorationProvider()); - - context.subscriptions.push((this._repositoriesView = new RepositoriesView(this))); - context.subscriptions.push((this._commitDetailsView = new CommitDetailsWebviewView(this))); - context.subscriptions.push((this._commitsView = new CommitsView(this))); - context.subscriptions.push((this._fileHistoryView = new FileHistoryView(this))); - context.subscriptions.push((this._lineHistoryView = new LineHistoryView(this))); - context.subscriptions.push((this._branchesView = new BranchesView(this))); - context.subscriptions.push((this._remotesView = new RemotesView(this))); - context.subscriptions.push((this._stashesView = new StashesView(this))); - context.subscriptions.push((this._tagsView = new TagsView(this))); - context.subscriptions.push((this._worktreesView = new WorktreesView(this))); - context.subscriptions.push((this._contributorsView = new ContributorsView(this))); - context.subscriptions.push((this._searchAndCompareView = new SearchAndCompareView(this))); - - context.subscriptions.push((this._homeView = new HomeWebviewView(this))); - context.subscriptions.push((this._timelineView = new TimelineWebviewView(this))); + this._disposables.push((this._uri = new UriService(this))); + this._disposables.push((this._subscription = new SubscriptionService(this, this._connection, previousVersion))); + this._disposables.push((this._organizations = new OrganizationService(this, this._connection))); + + this._disposables.push((this._git = new GitProviderService(this))); + this._disposables.push(new GitFileSystemProvider(this)); + + this._disposables.push((this._deepLinks = new DeepLinkService(this))); + + this._disposables.push((this._actionRunners = new ActionRunners(this))); + this._disposables.push((this._documentTracker = new GitDocumentTracker(this))); + this._disposables.push((this._lineTracker = new LineTracker(this, this._documentTracker))); + this._disposables.push((this._keyboard = new Keyboard())); + this._disposables.push((this._vsls = new VslsController(this))); + this._disposables.push((this._eventBus = new EventBus())); + this._disposables.push((this._launchpadProvider = new LaunchpadProvider(this))); + + this._disposables.push((this._fileAnnotationController = new FileAnnotationController(this))); + this._disposables.push((this._lineAnnotationController = new LineAnnotationController(this))); + this._disposables.push((this._lineHoverController = new LineHoverController(this))); + this._disposables.push((this._statusBarController = new StatusBarController(this))); + this._disposables.push((this._codeLensController = new GitCodeLensController(this))); + + this._disposables.push((this._webviews = new WebviewsController(this))); + + const graphPanels = registerGraphWebviewPanel(this._webviews); + this._disposables.push(graphPanels); + this._disposables.push(registerGraphWebviewCommands(this, graphPanels)); + this._disposables.push((this._graphView = registerGraphWebviewView(this._webviews))); + this._disposables.push(new GraphStatusBarController(this)); + + // NOTE: Commenting out for now as we are deprecating this + // const focusPanels = registerFocusWebviewPanel(this._webviews); + // this._disposables.push(focusPanels); + // this._disposables.push(registerFocusWebviewCommands(focusPanels)); + + const timelinePanels = registerTimelineWebviewPanel(this._webviews); + this._disposables.push(timelinePanels); + this._disposables.push(registerTimelineWebviewCommands(timelinePanels)); + this._disposables.push((this._timelineView = registerTimelineWebviewView(this._webviews))); + + this._disposables.push((this._rebaseEditor = new RebaseEditorProvider(this))); + + const settingsPanels = registerSettingsWebviewPanel(this._webviews); + this._disposables.push(settingsPanels); + this._disposables.push(registerSettingsWebviewCommands(settingsPanels)); + + this._disposables.push(registerWelcomeWebviewPanel(this._webviews)); + + this._disposables.push(new ViewFileDecorationProvider()); + + this._disposables.push((this._repositoriesView = new RepositoriesView(this))); + this._disposables.push((this._commitDetailsView = registerCommitDetailsWebviewView(this._webviews))); + const patchDetailsPanels = registerPatchDetailsWebviewPanel(this._webviews); + this._disposables.push(patchDetailsPanels); + this._disposables.push((this._patchDetailsView = registerPatchDetailsWebviewView(this._webviews))); + this._disposables.push((this._graphDetailsView = registerGraphDetailsWebviewView(this._webviews))); + this._disposables.push((this._commitsView = new CommitsView(this))); + this._disposables.push((this._pullRequestView = new PullRequestView(this))); + this._disposables.push((this._fileHistoryView = new FileHistoryView(this))); + this._disposables.push((this._launchpadView = new LaunchpadView(this))); + this._disposables.push((this._lineHistoryView = new LineHistoryView(this))); + this._disposables.push((this._branchesView = new BranchesView(this))); + this._disposables.push((this._remotesView = new RemotesView(this))); + this._disposables.push((this._stashesView = new StashesView(this))); + this._disposables.push((this._tagsView = new TagsView(this))); + this._disposables.push((this._worktreesView = new WorktreesView(this))); + this._disposables.push((this._contributorsView = new ContributorsView(this))); + this._disposables.push((this._searchAndCompareView = new SearchAndCompareView(this))); + this._disposables.push((this._draftsView = new DraftsView(this))); + this._disposables.push((this._workspacesView = new WorkspacesView(this))); + + this._disposables.push((this._homeView = registerHomeWebviewView(this._webviews))); + this._disposables.push((this._accountView = registerAccountWebviewView(this._webviews))); + + if (configuration.get('launchpad.indicator.enabled')) { + this._disposables.push((this._launchpadIndicator = new LaunchpadIndicator(this, this._launchpadProvider))); + } if (configuration.get('terminalLinks.enabled')) { - context.subscriptions.push((this._terminalLinks = new GitTerminalLinkProvider(this))); + this._disposables.push((this._terminalLinks = new GitTerminalLinkProvider(this))); } - context.subscriptions.push( + this._disposables.push( configuration.onDidChange(e => { - if (!configuration.changed(e, 'terminalLinks.enabled')) return; + if (configuration.changed(e, 'terminalLinks.enabled')) { + this._terminalLinks?.dispose(); + this._terminalLinks = undefined; + if (configuration.get('terminalLinks.enabled')) { + this._disposables.push((this._terminalLinks = new GitTerminalLinkProvider(this))); + } + } + + if (configuration.changed(e, 'launchpad.indicator.enabled')) { + this._launchpadIndicator?.dispose(); + this._launchpadIndicator = undefined; + + this.telemetry.sendEvent('launchpad/indicator/hidden'); - this._terminalLinks?.dispose(); - if (configuration.get('terminalLinks.enabled')) { - context.subscriptions.push((this._terminalLinks = new GitTerminalLinkProvider(this))); + if (configuration.get('launchpad.indicator.enabled')) { + this._disposables.push( + (this._launchpadIndicator = new LaunchpadIndicator(this, this._launchpadProvider)), + ); + } } }), ); + + context.subscriptions.push({ + dispose: () => this._disposables.reverse().forEach(d => void d.dispose()), + }); + + scheduleAddMissingCurrentWorkspaceRepos(this); + } + + deactivate() { + this._deactivating = true; + } + + private _deactivating: boolean = false; + get deactivating() { + return this._deactivating; } private _ready: boolean = false; @@ -249,15 +355,18 @@ export class Container { @log() private async registerGitProviders() { - const providers = await getSupportedGitProviders(this); + const providers = await getSupportedGitProviders(this, this.authenticationService); for (const provider of providers) { - this._context.subscriptions.push(this._git.register(provider.descriptor.id, provider)); + this._disposables.push(this._git.register(provider.descriptor.id, provider)); } - this._git.registrationComplete(); + // Don't wait here otherwise will we deadlock in certain places + void this._git.registrationComplete(); } - private onConfigurationChanging(e: ConfigurationChangeEvent) { + private onAnyConfigurationChanged(e: ConfigurationChangeEvent) { + if (!configuration.changedAny(e, extensionPrefix)) return; + this._mode = undefined; if (configuration.changed(e, 'outputLevel')) { @@ -273,53 +382,121 @@ export class Container { } } - private _actionRunners: ActionRunners; - get actionRunners() { - if (this._actionRunners == null) { - this._context.subscriptions.push((this._actionRunners = new ActionRunners(this))); - } + private _accountAuthentication: AccountAuthenticationProvider; + get accountAuthentication() { + return this._accountAuthentication; + } + + private readonly _accountView: WebviewViewProxy<[]>; + get accountView() { + return this._accountView; + } + private readonly _actionRunners: ActionRunners; + get actionRunners() { return this._actionRunners; } + private _ai: Promise | undefined; + get ai() { + if (this._ai == null) { + async function load(this: Container) { + try { + const ai = new ( + await import(/* webpackChunkName: "ai" */ './ai/aiProviderService') + ).AIProviderService(this); + this._disposables.push(ai); + return ai; + } catch (ex) { + Logger.error(ex); + return undefined; + } + } + + this._ai = load.call(this); + } + return this._ai; + } + private _autolinks: Autolinks | undefined; get autolinks() { if (this._autolinks == null) { - this._context.subscriptions.push((this._autolinks = new Autolinks(this))); + this._disposables.push((this._autolinks = new Autolinks(this))); } return this._autolinks; } - private _codeLensController: GitCodeLensController; - get codeLens() { - return this._codeLensController; + private readonly _branchesView: BranchesView; + get branchesView() { + return this._branchesView; } - private _branchesView: BranchesView | undefined; - get branchesView() { - if (this._branchesView == null) { - this._context.subscriptions.push((this._branchesView = new BranchesView(this))); + private _cache: CacheProvider | undefined; + get cache() { + if (this._cache == null) { + this._disposables.push((this._cache = new CacheProvider(this))); } - return this._branchesView; + return this._cache; + } + + private _cloudIntegrations: Promise | undefined; + get cloudIntegrations() { + if (this._cloudIntegrations == null) { + async function load(this: Container) { + try { + const cloudIntegrations = new ( + await import( + /* webpackChunkName: "integrations" */ './plus/integrations/authentication/cloudIntegrationService' + ) + ).CloudIntegrationService(this, this._connection); + return cloudIntegrations; + } catch (ex) { + Logger.error(ex); + return undefined; + } + } + + this._cloudIntegrations = load.call(this); + } + + return this._cloudIntegrations; } - private _commitsView: CommitsView | undefined; - get commitsView() { - if (this._commitsView == null) { - this._context.subscriptions.push((this._commitsView = new CommitsView(this))); + private _drafts: DraftService | undefined; + get drafts() { + if (this._drafts == null) { + this._disposables.push((this._drafts = new DraftService(this, this._connection))); } + return this._drafts; + } + + private _repositoryIdentity: RepositoryIdentityService | undefined; + get repositoryIdentity() { + if (this._repositoryIdentity == null) { + this._disposables.push((this._repositoryIdentity = new RepositoryIdentityService(this, this._connection))); + } + return this._repositoryIdentity; + } + + private readonly _draftsView: DraftsView; + get draftsView() { + return this._draftsView; + } + + private readonly _codeLensController: GitCodeLensController; + get codeLens() { + return this._codeLensController; + } + private readonly _commitsView: CommitsView; + get commitsView() { return this._commitsView; } - private _commitDetailsView: CommitDetailsWebviewView | undefined; + private readonly _commitDetailsView: WebviewViewProxy; get commitDetailsView() { - if (this._commitDetailsView == null) { - this._context.subscriptions.push((this._commitDetailsView = new CommitDetailsWebviewView(this))); - } - return this._commitDetailsView; } @@ -328,12 +505,8 @@ export class Container { return this._context; } - private _contributorsView: ContributorsView | undefined; + private readonly _contributorsView: ContributorsView; get contributorsView() { - if (this._contributorsView == null) { - this._context.subscriptions.push((this._contributorsView = new ContributorsView(this))); - } - return this._contributorsView; } @@ -342,8 +515,27 @@ export class Container { return this._context.extensionMode === ExtensionMode.Development; } + private readonly _deepLinks: DeepLinkService; + get deepLinks() { + return this._deepLinks; + } + + private readonly _documentTracker: GitDocumentTracker; + get documentTracker() { + return this._documentTracker; + } + + private _enrichments: EnrichmentService | undefined; + get enrichments() { + if (this._enrichments == null) { + this._disposables.push((this._enrichments = new EnrichmentService(this, new ServerConnection(this)))); + } + + return this._enrichments; + } + @memoize() - get env(): 'dev' | 'staging' | 'production' { + get env(): Environment { if (this.prereleaseOrDebugging) { const env = configuration.getAny('gitkraken.env'); if (env === 'dev') return 'dev'; @@ -353,86 +545,91 @@ export class Container { return 'production'; } - private _eventBus: EventBus; + private readonly _eventBus: EventBus; get events() { return this._eventBus; } - private _fileAnnotationController: FileAnnotationController; + private readonly _fileAnnotationController: FileAnnotationController; get fileAnnotations() { return this._fileAnnotationController; } - private _fileHistoryView: FileHistoryView | undefined; + private readonly _fileHistoryView: FileHistoryView; get fileHistoryView() { - if (this._fileHistoryView == null) { - this._context.subscriptions.push((this._fileHistoryView = new FileHistoryView(this))); - } - return this._fileHistoryView; } - private _git: GitProviderService; - get git() { - return this._git; - } - - private _uri: UriService; - get uri() { - return this._uri; + private readonly _launchpadProvider: LaunchpadProvider; + get launchpad(): LaunchpadProvider { + return this._launchpadProvider; } - private _deepLinks: DeepLinkService; - get deepLinks() { - return this._deepLinks; + private readonly _git: GitProviderService; + get git() { + return this._git; } - private _github: Promise | undefined; + private _github: Promise | undefined; get github() { if (this._github == null) { - this._github = this._loadGitHubApi(); + async function load(this: Container) { + try { + const github = new ( + await import( + /* webpackChunkName: "integrations" */ './plus/integrations/providers/github/github' + ) + ).GitHubApi(this); + this._disposables.push(github); + return github; + } catch (ex) { + Logger.error(ex); + return undefined; + } + } + + this._github = load.call(this); } return this._github; } - private async _loadGitHubApi() { - try { - const github = new (await import(/* webpackChunkName: "github" */ './plus/github/github')).GitHubApi(this); - this.context.subscriptions.push(github); - return github; - } catch (ex) { - Logger.error(ex); - return undefined; - } - } - - private _gitlab: Promise | undefined; + private _gitlab: Promise | undefined; get gitlab() { if (this._gitlab == null) { - this._gitlab = this._loadGitLabApi(); + async function load(this: Container) { + try { + const gitlab = new ( + await import( + /* webpackChunkName: "integrations" */ './plus/integrations/providers/gitlab/gitlab' + ) + ).GitLabApi(this); + this._disposables.push(gitlab); + return gitlab; + } catch (ex) { + Logger.error(ex); + return undefined; + } + } + + this._gitlab = load.call(this); } return this._gitlab; } - private async _loadGitLabApi() { - try { - const gitlab = new (await import(/* webpackChunkName: "gitlab" */ './plus/gitlab/gitlab')).GitLabApi(this); - this.context.subscriptions.push(gitlab); - return gitlab; - } catch (ex) { - Logger.error(ex); - return undefined; - } + private readonly _graphDetailsView: WebviewViewProxy; + get graphDetailsView() { + return this._graphDetailsView; } - private _homeView: HomeWebviewView | undefined; - get homeView() { - if (this._homeView == null) { - this._context.subscriptions.push((this._homeView = new HomeWebviewView(this))); - } + private readonly _graphView: WebviewViewProxy; + get graphView() { + return this._graphView; + } + private readonly _homeView: WebviewViewProxy<[]>; + get homeView() { return this._homeView; } @@ -441,49 +638,70 @@ export class Container { return this._context.extension.id; } - private _integrationAuthentication: IntegrationAuthenticationService | undefined; - get integrationAuthentication() { - if (this._integrationAuthentication == null) { - this._context.subscriptions.push( - (this._integrationAuthentication = new IntegrationAuthenticationService(this)), - // Register any integration authentication providers - new GitHubAuthenticationProvider(this), - new GitLabAuthenticationProvider(this), - ); + private _authenticationService: IntegrationAuthenticationService | undefined; + private get authenticationService() { + if (this._authenticationService == null) { + this._disposables.push((this._authenticationService = new IntegrationAuthenticationService(this))); } + return this._authenticationService; + } - return this._integrationAuthentication; + private _integrations: IntegrationService | undefined; + get integrations(): IntegrationService { + if (this._integrations == null) { + this._disposables.push((this._integrations = new IntegrationService(this, this.authenticationService))); + } + return this._integrations; } - private _keyboard: Keyboard; + private readonly _keyboard: Keyboard; get keyboard() { return this._keyboard; } - private _lineAnnotationController: LineAnnotationController; + private _launchpadView: LaunchpadView; + get launchpadView() { + return this._launchpadView; + } + + private readonly _lineAnnotationController: LineAnnotationController; get lineAnnotations() { return this._lineAnnotationController; } - private _lineHistoryView: LineHistoryView | undefined; + private readonly _lineHistoryView: LineHistoryView; get lineHistoryView() { - if (this._lineHistoryView == null) { - this._context.subscriptions.push((this._lineHistoryView = new LineHistoryView(this))); - } - return this._lineHistoryView; } - private _lineHoverController: LineHoverController; + private readonly _lineHoverController: LineHoverController; get lineHovers() { return this._lineHoverController; } - private _lineTracker: GitLineTracker; + private readonly _lineTracker: LineTracker; get lineTracker() { return this._lineTracker; } + private _mode: ModeConfig | undefined; + get mode() { + if (this._mode == null) { + this._mode = configuration.get('modes')?.[configuration.get('mode.active')]; + } + return this._mode; + } + + private _organizations: OrganizationService; + get organizations() { + return this._organizations; + } + + private readonly _patchDetailsView: WebviewViewProxy; + get patchDetailsView() { + return this._patchDetailsView; + } + private readonly _prerelease; get prerelease() { return this._prerelease; @@ -494,77 +712,45 @@ export class Container { return this._prerelease || this.debugging; } - private _rebaseEditor: RebaseEditorProvider | undefined; - get rebaseEditor() { - if (this._rebaseEditor == null) { - this._context.subscriptions.push((this._rebaseEditor = new RebaseEditorProvider(this))); - } + private readonly _pullRequestView: PullRequestView; + get pullRequestView() { + return this._pullRequestView; + } + private readonly _rebaseEditor: RebaseEditorProvider; + get rebaseEditor() { return this._rebaseEditor; } - private _remotesView: RemotesView | undefined; + private readonly _remotesView: RemotesView; get remotesView() { - if (this._remotesView == null) { - this._context.subscriptions.push((this._remotesView = new RemotesView(this))); - } - return this._remotesView; } - private _repositoriesView: RepositoriesView | undefined; + private readonly _repositoriesView: RepositoriesView; get repositoriesView(): RepositoriesView { - if (this._repositoriesView == null) { - this._context.subscriptions.push((this._repositoriesView = new RepositoriesView(this))); - } - return this._repositoriesView; } - private _searchAndCompareView: SearchAndCompareView | undefined; - get searchAndCompareView() { - if (this._searchAndCompareView == null) { - this._context.subscriptions.push((this._searchAndCompareView = new SearchAndCompareView(this))); + private _repositoryPathMapping: RepositoryPathMappingProvider | undefined; + get repositoryPathMapping() { + if (this._repositoryPathMapping == null) { + this._disposables.push((this._repositoryPathMapping = getSupportedRepositoryPathMappingProvider(this))); } - - return this._searchAndCompareView; - } - - private _subscription: SubscriptionService; - get subscription() { - return this._subscription; - } - - private _subscriptionAuthentication: SubscriptionAuthenticationProvider; - get subscriptionAuthentication() { - return this._subscriptionAuthentication; + return this._repositoryPathMapping; } - private _settingsWebview: SettingsWebview; - get settingsWebview() { - return this._settingsWebview; - } - - private _graphWebview: GraphWebview; - get graphWebview() { - return this._graphWebview; - } - - private _focusWebview: FocusWebview; - get focusWebview() { - return this._focusWebview; + private readonly _searchAndCompareView: SearchAndCompareView; + get searchAndCompareView() { + return this._searchAndCompareView; } - private _stashesView: StashesView | undefined; + private readonly _stashesView: StashesView; get stashesView() { - if (this._stashesView == null) { - this._context.subscriptions.push((this._stashesView = new StashesView(this))); - } - return this._stashesView; } - private _statusBarController: StatusBarController; + private readonly _statusBarController: StatusBarController; get statusBar() { return this._statusBarController; } @@ -574,12 +760,13 @@ export class Container { return this._storage; } - private _tagsView: TagsView | undefined; - get tagsView() { - if (this._tagsView == null) { - this._context.subscriptions.push((this._tagsView = new TagsView(this))); - } + private _subscription: SubscriptionService; + get subscription() { + return this._subscription; + } + private readonly _tagsView: TagsView; + get tagsView() { return this._tagsView; } @@ -588,19 +775,14 @@ export class Container { return this._telemetry; } - private _timelineView: TimelineWebviewView; + private readonly _timelineView: WebviewViewProxy; get timelineView() { return this._timelineView; } - private _timelineWebview: TimelineWebview; - get timelineWebview() { - return this._timelineWebview; - } - - private _tracker: GitDocumentTracker; - get tracker() { - return this._tracker; + private readonly _uri: UriService; + get uri() { + return this._uri; } private readonly _usage: UsageTracker; @@ -621,31 +803,27 @@ export class Container { return this._viewCommands; } - private _vsls: VslsController; + private readonly _vsls: VslsController; get vsls() { return this._vsls; } - private _welcomeWebview: WelcomeWebview; - get welcomeWebview() { - return this._welcomeWebview; - } - - private _worktreesView: WorktreesView | undefined; - get worktreesView() { - if (this._worktreesView == null) { - this._context.subscriptions.push((this._worktreesView = new WorktreesView(this))); + private _workspaces: WorkspacesService | undefined; + get workspaces() { + if (this._workspaces == null) { + this._disposables.push((this._workspaces = new WorkspacesService(this, this._connection))); } + return this._workspaces; + } - return this._worktreesView; + private _workspacesView: WorkspacesView; + get workspacesView() { + return this._workspacesView; } - private _mode: ModeConfig | undefined; - get mode() { - if (this._mode == null) { - this._mode = configuration.get('modes')?.[configuration.get('mode.active')]; - } - return this._mode; + private readonly _worktreesView: WorktreesView; + get worktreesView() { + return this._worktreesView; } private ensureModeApplied() { @@ -685,12 +863,12 @@ export class Container { get: (section, value) => { if (mode.annotations != null) { if (configuration.matches(`${mode.annotations}.toggleMode`, section, value)) { - value = AnnotationsToggleMode.Window as typeof value; + value = 'window' as typeof value; return value; } if (configuration.matches(mode.annotations, section, value)) { - value.toggleMode = AnnotationsToggleMode.Window; + value.toggleMode = 'window'; return value; } } @@ -711,7 +889,7 @@ export class Container { }, getAll: cfg => { if (mode.annotations != null) { - cfg[mode.annotations].toggleMode = AnnotationsToggleMode.Window; + cfg[mode.annotations].toggleMode = 'window'; } if (mode.codeLens != null) { @@ -732,32 +910,56 @@ export class Container { return cfg; }, - onChange: e => { + onDidChange: e => { // When the mode or modes change, we will simulate that all the affected configuration also changed - if (configuration.changed(e, ['mode', 'modes'])) { - if (this._configAffectedByModeRegex == null) { - this._configAffectedByModeRegex = new RegExp( - `^gitlens\\.(?:${configuration.name('mode')}|${configuration.name( - 'modes', - )}|${configuration.name('blame')}|${configuration.name('changes')}|${configuration.name( - 'heatmap', - )}|${configuration.name('codeLens')}|${configuration.name( - 'currentLine', - )}|${configuration.name('hovers')}|${configuration.name('statusBar')})\\b`, - ); - } - - const original = e.affectsConfiguration; - e = { - ...e, - affectsConfiguration: (section, scope) => - this._configAffectedByModeRegex!.test(section) ? true : original(section, scope), - }; - } - return e; + if (!configuration.changed(e, ['mode', 'modes'])) return e; + + const originalAffectsConfiguration = e.affectsConfiguration; + return { + ...e, + affectsConfiguration: (section, scope) => + /^gitlens\.(?:modes?|blame|changes|heatmap|codeLens|currentLine|hovers|statusBar)\b/.test( + section, + ) + ? true + : originalAffectsConfiguration(section, scope), + }; }, }); } + + @memoize() + private get baseGkDevUri(): Uri { + if (this.env === 'staging') { + return Uri.parse('https://staging.gitkraken.dev'); + } + + if (this.env === 'dev') { + return Uri.parse('https://dev.gitkraken.dev'); + } + + return Uri.parse('https://gitkraken.dev'); + } + + getGkDevUri(path?: string, query?: string): Uri { + let uri = path != null ? Uri.joinPath(this.baseGkDevUri, path) : this.baseGkDevUri; + if (query != null) { + uri = uri.with({ query: query }); + } + return uri; + } + + getGkDevExchangeUri(token: string, successPath: string, failurePath?: string): Uri { + return Uri.joinPath(this.baseGkDevUri, `api/exchange/${token}`).with({ + query: `success=${encodeURIComponent(successPath)}${ + failurePath ? `&failure=${encodeURIComponent(failurePath)}` : '' + }`, + }); + } + + generateWebGkDevUrl(path?: string): string { + return this.getGkDevUri(path, '?source=gitlens').toString(); + } } export function isContainer(container: any): container is Container { diff --git a/src/context.ts b/src/context.ts deleted file mode 100644 index 1c72dc40517d1..0000000000000 --- a/src/context.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { commands, EventEmitter } from 'vscode'; -import type { ContextKeys } from './constants'; -import { CoreCommands } from './constants'; -import type { WebviewIds } from './webviews/webviewBase'; -import type { WebviewViewIds } from './webviews/webviewViewBase'; - -const contextStorage = new Map(); - -type WebviewContextKeys = - | `${ContextKeys.WebviewPrefix}${WebviewIds}:active` - | `${ContextKeys.WebviewPrefix}${WebviewIds}:focus` - | `${ContextKeys.WebviewPrefix}${WebviewIds}:inputFocus` - | `${ContextKeys.WebviewPrefix}rebaseEditor:active` - | `${ContextKeys.WebviewPrefix}rebaseEditor:focus` - | `${ContextKeys.WebviewPrefix}rebaseEditor:inputFocus`; - -type WebviewViewContextKeys = - | `${ContextKeys.WebviewViewPrefix}${WebviewViewIds}:focus` - | `${ContextKeys.WebviewViewPrefix}${WebviewViewIds}:inputFocus`; - -type AllContextKeys = - | ContextKeys - | WebviewContextKeys - | WebviewViewContextKeys - | `${ContextKeys.ActionPrefix}${string}` - | `${ContextKeys.KeyPrefix}${string}`; - -const _onDidChangeContext = new EventEmitter(); -export const onDidChangeContext = _onDidChangeContext.event; - -export function getContext(key: AllContextKeys): T | undefined; -export function getContext(key: AllContextKeys, defaultValue: T): T; -export function getContext(key: AllContextKeys, defaultValue?: T): T | undefined { - return (contextStorage.get(key) as T | undefined) ?? defaultValue; -} - -export async function setContext(key: AllContextKeys, value: unknown): Promise { - contextStorage.set(key, value); - void (await commands.executeCommand(CoreCommands.SetContext, key, value)); - _onDidChangeContext.fire(key); -} diff --git a/src/emojis.generated.ts b/src/emojis.generated.ts new file mode 100644 index 0000000000000..b3609fbb821fa --- /dev/null +++ b/src/emojis.generated.ts @@ -0,0 +1 @@ +export const emojis = 'N4IgjADBIFwovBuHqdkAacAmAzAFlgwRLuogDUYe8gsjtGkD6ALgPYB2ApmXJYDwbg3/vVj3MW6chU6Af/b4DWmEZ0C/+5MatcHUYD/9xYICsswP/7RALTsEgOR3D/JW3Ime5qUOvi7lmRxsK0R+ytOcNni21HfTQwAGc6PEA+DcBxXaJwuhoABwAbAEMAYxYaAFsWABM0lOi4tHQmfOjACV2icvzk9KzcgqLqokwAJ0q4KMBJXfauhszsvMLinv60AA4AIyLxkE5ARv2iNLxOQAdd1ZoZlIYGbsWttDSZ9cA43dWZnb2Di6vMgFcw6MA73auM8kASXY/D+EBCXdWGSySUicE4gEU9oEZBhdACWzGigHS91b5AAWLDCcIAbtk5hU0gBzaKATz3URisbidmkCYSWKTUTk4WEscx6HCMgBrFh0F7gwD5+6jHikwSAooBF3aFIvsxglthO+WFiUswh64qcCqV9lcYvVHhAaUV0ssPjl/gNRuVQTVnBCBpYHThRXoHTSOQA7mkAJ7kQCFe6sAGaEtE05l0GnrQBnu5xANe7qzhHVSNPpcEAEOTxxPpVg0NIdR3YuFMYkcQA3exmk9n8iwkrm6I8OimEIBrvdWWfqzLbfMW0cAv7ut3M5GgZPZcvCAB+JW/inVHOIBH3dbLByMzheEA6WSAeD+F0uOWvW3THWtwYBL3c4gH/d1twxvwQC+O5uTilL0xyNeLywmLlmBEHeRAH47rZSNCEvsfyAGJ7/6AcBMrkCB8oGikAFAQc9iqggMESPeCGQS40HyOBiH1CaOHmvMEFIZYOgcDBdpujMwo0p8HCAIl7d4GkudFMFk5CMaseSOhkNI0GEboMIe3acIAH7s8UkaKwqJnCAF/7qxMGkjBMvxAEYrW0SAAN7SkZDJHR4IAyWRKfksKujOR6mR0DBwn8gCPO0pdILPAgA+O05LAAZYxhubBNLOch5CuRqBpFp52pBbhJxhV5gg+L5xExfYFEIMF1FhYZHCAEU7TkMOkM6AC+7TkdD6HCAAR7xVejQAbDOQFXRYSJU0HCORJOQgD+O05jxwvBcnRu8DXdWEGJ/IA5HtdcyI3VbVHDjdForwIAODtKeGHQZHQHJ9ZwgBvuytb7KdSHRdvAgBvOytcKEo8aSHbRhQzoAN7tKQAHgizzkIADHvPa9YTTZxHCfScSSpI2nCAHL7qwAI5XY6b1wIASmSrJliyABX7iN0kwG3KTOgDvu4jl5doAEmSI3kTDTuC0aAE+7iM2e61Jcp6XR4IAA6TUwwtNmY8uzZGZ7pPnAgA3xKz7MMJzKTZI8bVwIA18RCzQPN4IA4NQsbmNNy2zfMgArss8++YQ5PM5CALy7ssBrCDOHIAbaSy5p+SFoSau83ggCslNbLCGnbNAS3ggAslLLYsBqKgCg1H7LABzQMkMJyeCAJWEftsw6ND+6KgCZhHH7oJ46waioAGYSy5naKioAheR5xdBfh/sUdwIAVYSy48eCAGDUyt5mznuSyA9e10kDsa4AqYSd4J+vweQgA8u53SQJ0neCAGmEY8Z6XoqAOmEQs/cODD08yV6AMC7y9DiLGMOqv69hFegAgu4joqcKjJwdBtGR0XQBSJ+ZpUIIA2Xvn3CAYck6AFCQ/8FwgfuQQAD3sfwiNEcUgBYAkvmAq0rBZTqm4NAq+Bob5hkCjaMQyDYERRtHIbB18NoRG8BAvwBDUFELgSwFKcpdDkNzJzLagAf3dWGELITBWTvg7DSQ4nBoyAAvd1hD9HRhEriAAAVOuQAxwRCOYJNAo5BABMe7I0mw1H41T+ggZRJwIg2WUo8UUEpIHwEAAF7Qi9FpAMVBG0SDTHmOYJYqhKE5RYLsTougFirHYTwcYsx7jPFUNNOqNQvj7H6KoTQ9UdC3EGjoGiMWt9BIySvIAfR3Vh0ByOsQA5/vpIYJkuAgBsslyYOMIXolx5TwEUk4zwPFFDJmJQAX7urBqQeGcgBP3eaYwGgfFOTDTSO6cggAvvdWNiNehoGDREAIq7qwnqNiiIAZL3VgAC8HRzDhAAKwjOTM8RAzjgkAI67uybjAXWIctAcwZgv3gIANx3dmnCqjMBgdA6Bi3WIAH327mXKHGiDkYj4CABIdz5VVSnlJcoAHr2gXWIQNc2CFyqoqnIDC9CIA4W4OhVFFF9ySEcBheaVF5FEW2juaODggBMXeJZyGsJLFiACT94liIeiAGVdu5+R9zREAIW7LKmQYwZYsQAH/t3OctEQA9ruCsJESbIw40gtXIIAEb3BWPB5A/HYDZDTREAGq7dyQz6y7JwQA57sSS1a6Qs6wDWAFvdu5WYmhsKKI2QAaWSWsOEYqIgAm3cdbkLZCBAAWO5A117rvKkKQX685RQCKCGceqLBwbMUpDDdIUh+Do1zFjdi3UZCk2huSqQuhGaU3ujyZ6+AgBLHd9W6kN8EeRJIYJiaIgAHPctbcDWnBAAR+w2p5OwGBPXIIAZ9222JEeU9Gg7pAFom+SwalgBEMjueUiZ4JACx+3c5SWNwSABl9xdhQ7IMhDUWfExYQCAB4CRdhJ0hVmGqazgVlt3rNnWKQAqXuLrEZwQAZ/t3I6N8rSHBABTO6+uYZldUGr1T+78HBAARO0Bt9SQ8pXjAyG4+yaFiAD6yO5oieTRCabB7kdB4PrEAAP7yHMPwY9RrQAn2TrkgYAITIWJzBQ1hki+b9YkbI4AATIqMqWiIAcN27lxPILpENcSoXwG0rClSo6EUcCE8iuY/HvEICE/qKTom4o8b8FxxTrAUqCaJXxtENk8nkEAN17qm6x7IQIAQb2uPCKuYAaF3dlu30usQAa/u2cyKOx4S4Gy9Uc85/SQ6R2e3c55fK4InPnLdty+2GiryAEA95zHDoiAA295zSN4CAG4dpLv0rxpdC7mKs9Rx5HV5VEQAK7vOcxvbTSN9yCAGSd5zuIkZREANW7tm/iAHm92zV5AC4O+1pGnBABe+913VgBvffay8uZgAmvds4SQsrBkuAAZd2z6QOiw0WAawAf7sLam2589gAr3YW3CFZ57zyhaHhwQAKLsLYAuPIGwGxSAEW9i7Mk27wEAHN7D2GBdxmIF8gr3QukybQawA97u2YxusQAjvvA8YGDiHDAO1dvBOD0LHQciPDuuCA1VNQt1YlbD6IgBh3d2WiAxnr9WcA6ecjkXphxXgm+TjIlOHzgI4IAFr3dkU+HOggNzPYVs4Z048gTOQrLjp+z4hMn4AC/kzzjnSmud4ql6L60CABd2mXNycgNOUVwk5HbfnrPtdFiI/zijVGtce050r7n+v7ZiYt5J03BuxcS712bmXFu5dW6zVzlX9v7b0cLUzyBLHnek3IIATR3WcVvqPxNuURABHuxHh8OXdUrHJwwEMSyct4EAMRkrPGYcEAGQ7uf6iRfIIX8nN80SFBfpwQAQfu57iVXocaQ1fgjr+TsIhXAA1u6zsric4Qx8AGd7uzGicibwtQAEDuQMANjUw/Mij4yAmKneBADVZLPrk1V0h7s4IAF/21+j4qwtQAJbt75oNe7kPXADzOyfxbdJBJQ1zI2Gf5yR/NFtm5geJEwj34bHgQAf6Qn9GDhHfy/2hkbEAB/SE/UmEzQAJHIT89ZP9v9GxAAq0jgMQJ2AMSUHIEACZd4fZgeoEMBMBRDgQARx3cCKhw5pUGx6gGNyBiDjcyD8DKDH58sO8NZ4BSDn88CKDCC40rASDYU9hyCCCqCMEEBiDBdBDGCeC0V2CMVdguDhDH5CJ+C8VJDuCRCCV+CVc1DFD6g/c2C6Cg9OCKhsh9DaDA8qM9gWQ9NwRAAefdwPdC/jPQ4EAEIdhwj2a6bXFkcgQADD3h9FUO1I5yBAANnf8MlVzHIEAEy9sIocRfV5DgQAVl2YiD9yBABMnZiJAIf3IBfWf0VU+zzHxmiEAAW9/w5whAQAKD3dkRJktAB0HaqLYzgEAFeyKouYOWR0JgMRKIQAad2qiHwCw5IDUipzk8kTN4BABinaqNYGiEABbdqom9eAQAUp25i/lAA1neWP1mS0ADRd9Y3MP5bY4YyODY0fcMGYY6QARZ3ljjpAAtnbmPyQQCWION4n8miEAGC9uYhaQAIx2qiwhSZroMQOgVkgICxl0Vs+EqjeRPQQSScWFhjHgOIPZCMaC4BSN6DYT4SDdCMWDeViMqi4SF8MS6MC1GMLDcSoZUMOBAAEndxNxESG4QqHPUAG/dqogZDgQAcT3mScxyCVY2Z1hABP/Y5PN3gFZNhRbhtyFMF1FMEB1HFPk0lOUHIGFLxTlOoQVK0xRTZg9iRIQFZNRPVOHQNzMLZJJOGPdAAmHX4zCEg0K0AGNd5kh8IsdYQAZv2qiXoDdj0GBcRohAApXaqKuUADMd30gTP0kU+FcNcgYMyTBgUM+NDgYM2U6MlgeKYMpUhMjTYMlXY1DWKIQAA93dlXQlkepz1cZzlVVeFAA9fbzLdmIQDBYAKB1x6EAEZdystIas2s22B3QQWUBs2FVVVsusjs1gZxbsyTXsxIGs/s63KU6Ibs+TUc6qNsl3eUxslTEsqsschcgclU5cjM35aIecEs35LsKIfclFR0KsXzfjXEQsjgQABx3jTTy7Jb8VIaBSYs51hABIfbzMvADH4mPnIEAB6dr80OI8wAJz2vys50DnleV4BAAwXa/KIVHULFtgEjXjdnfGEUdEYBKnWFeE4CGIfIQpoALA6Cmy4TCE7HWAZM4EehLLXhhAfGiEADNdvMyON8cON2SrDgQAFJ2WKbCxQydTz1ZYi1p4iEBAAT/ZYt5nYo/TFEADhdyS3WRA8gQAa/28y4SWAVw0dOBe1zk64OBABsHd2U5i5noDdmiB6N0pmC5iPMAHO9oyrkcksUQAYr2jK914BDLdKep2z7YYQOEPFHh1oER1hAB1/ao26ljQ9l8t0QCo2l5U4GCqMpckAEKdxK0YlK3SlIcVVpLSmilFYUBJWpQsaqPRBaQAUL3EqCrMzBI7IrxAAQvYquPi9BBkAHr9oyjofXa6GqDvc9OMXSpHOSCmTgDHPKgoyHcEQAen22q4TbZz0Bo8rjpAAYvaMqxFYBZEEm6kSDFgLHDDK3IEABdd5aiId7cgQAeL3lraTdYeoZJFVnkrxAATHfOp+iKqxBSGuqVXIEAFMdx65qS616kWJVK8L63S26+rQACd2jKQaAwUgX4ohABo3aIH4nWmW04EAFZ9hG5vEGQAB330axYCRktAAxnZxrfEKDfUtITFaA4EAGXdnGgCPICg+knoclNAdSWm7IEMcg83KIUlWCFm5oem3g5xbm5FXmum9m3gnULmjFEWtmnhVNSW80aW/mz3MUbmu0Fm+shAQAJ530bylkL1hAAH3c4AtWZrdE8nIEAGodnWh0USeAQAd52rbXQN8WzyBAAPnYds6vSDKPgDdpNqeIvNHShpbLRFdodv2CbX4SGp1qSA1s4EAFX99G5SVHRYCOyMBO3MKqOk/IXVXCwRE2jiG7QAaTIE78hRL4BRU878hq9AAbfYTtYGLxOR6EAGtdhO6tcgQAN730bx4iKHQqx1gI6mSTbo7wwAJTgMg/hAACXc7r4lhA1kAEUydG5LFEE2wYL+MWDtcgXHFifiFenqPETtDehe9sEwuHBAQAcZ3D6fqqxB1bYCwqwjpyBAAh3YXrhCsrQpfJ5H+Ioq0t2mXpFmPg0lhD/PBEAAL9heruXRMLG7TgQAeP2wG3wPFgV+lohAAsvYXpskMUAFVdheugd0DETyDWqIQADl3sHcHaz7TJz4FohCGebaxSH8HNznFqHhbaG8HyG0UiGpaWGyHFzEyqGVyQBt6cHWGeGaFqG1aWzRt1hAAH/fRoWkAEYd2RlCeAcfWRjLcgBRk2spASTO3VQ29bZmv4QAfp2Ea3wO8qDohAAaXZMYxldBHoDD4gEgbCSFFh3HBAjt6oEZYCJ2aAfE83BG2zBOZpDF4TaQvQRoILizgEAGWycJyg6IbiIJt0GsQkKY8EQAP33YnOKEBABOncycSCrAyF7I1vgEAEmdvJn6wpqs4pwACZ3ynzTR0dZzYfoPFibyAynEmb5/bW4mmXQ3xyBamOnEh6numk9emGaEABmBGMR18ji8BAAUcnCfHVHyOMgswLgEAFByRZhykQwOrfQAQf3FnMQ5lAAB3cWYKJrXBEACV9s5pqeQ6wu4zgQADn3Fm1qaxHY4BAB9MheboCYAMXWEAAZ98J35PpjgQAFB2gWHL3wS8wWgXY1ohAAl3dhd4NlHhZ5p+RTRtyiFReFvRfFoRaltxbltRYVsJc0LFFRbVtxYbHfEKYYBZBNQ4EAA69pF6locGyelh0pl2FkGCOk8/SQsOSCOnSqZ/vZHDo8gQAfh3wm148oVI8QIjwRABrfelaSAiEPOiEACNd8JkRDJFs5qcinhPuzgPsIJnVnVXpkGQAEP3wn6x7M4BAArsgRougf1uvIEABq9p11gfWdYQAMv2nW1pnhTKQdwRAAq/adboCqkVFEXWEADB98N4FOEkREGQAcH3431qOFHLOA43mbAEvQbVx51hABN/YRoWE4EAETdktpJ8gQAHd3K2ntAB5HbrYE3rZ5vSC7jFJbeFrbZkJbf1ClXbdd3gBbYVu7bJaHbVP7fy3WEABr9ut6Og3MW46Rt5mtt+d8rHhZpzs8gFt3gFdtIIGD2Rd0Q8drt/dtd/mzdmMhAXtudw9jd1Ncdkds9u9ioS9rc69idz2rEdSdArymOwAbf2S2WplxOWxRAB13aA+3ANy1KiDA91PZyg4ob4PA9beA54ecTA5CgQ5A6Q4lsw77YfEQ7lsw5HbQ83JoUw7VsI5w6HSJOiDg6MIEftI92PRbOPiPMACFdoDpIR5TPDgQAaZ3uP8slB9W2wjXABX3ZLbXj+WOCY+k58kAAVdqTrkSAcgQATV3lPORIBMBoAOBABTXc07AB8kAC1dwzsAHT8gZild+TpRwAbV3DOsBdOEBABzXcM4s44EAB1dzTpR6ZazrkRz8gQAXV3NPpTAAlXZC/c4QEAD1dzT+KZlPzzkbASL+AQAfV3NONNAAVXfS+S8AANdzTgANnICwYS/y+S8AENdzTgAdnIE1QS8q+S61YS6mHIEAHVdzTqYZL20hLgATnIEAA1dzT7r5LwAE13NPh0gGEBt5rPj56hHkgiOBABVnak5m6fnX2GZbwQEAARd5bx+EcUfdblgK5bb6bx+NzGYDzILRYQAIH2pOUc8BABAMlu70L8wfCzj+y30AEp9p7rp17gud7zk+oTMvAQACbJvvhmgfwRAByfbB78x+J5PBEAAp9p76vQAUn2pP3mxRARrOpKNFogse5OceZpMeS3OYuxABjMhJ7oB8zmDcsAF0dhG8O7ShGkSQxQAU93meuQbIXNohABmvY585HDGvMWEAA99jn5gP5noAz5mmVp6g119o13OgRhgAMccvAQAVDJmeVfNKQBAAGsi3uV6/g1h1+Z4ZbFGiOl7hZ6EADdd5nlNaFsUG3i3+oMIdOBohAAGJXgBThcgB4z3kY+pTgCO/CmEPIUUQAEDJmechYQ6WDbI7pfWo2PohjalfWoGx6XoLa34/nHhFyBABuncj+z4TltmjY4EAF6dgvgxBOKP54K8QARt3memAaznk4RqQTtFhAAo/Yb9NmmqIIQEADQ9rvveXLNRjgAf6Xxvmvv4QAVD2u/J+R+EAZ/x+gIVU0glkM8CwK0jXcrfLl/9c+khIjXL0leixXQ6x0hYrImQBAB0pgb+isCvmMAHK92//y+/tgp/8fu/i/6qoBcEBKj/l/r/vmnapXhAA7jvP8lsr/WjsAOSxgD/+EAwAbCHPyG4OAIA+Dp+AAEIh3wQApAYKRAE810B8AzAVAJwFhkUBWHAgTFSIHYCE4YuPAX2woGQDqBb6ZQggDwEK0GBCA6AcrXgB4C1aHAqgYgITiGlWB95Xyh4jyg0BOQTABgKcXWCABs/Yb7iCK0PWQAHX7DfXEKTDfDWosKIMQAFf7zPebrqGgSABnfYMGj4dGRrfWmYKhRygkEnAUwdL0jiiEXEJg6wTJjlD4J7Bbg13GaFcGODjiiuWhH4KV6sVAeHIEGIAC19swZeHWCAArfeiGgcvBjg0toACTd5nk9kAB2O+kIEwZD8BA7QcuQFyHC13sMhXIfQPyG8MOAuQ9gRUI0y5C+BSQL0PnFFAABK5nrY2iDMtpeHQJtIAF595nhEGugOMjWxZJXsqhvq2Qf8bjTgHNRhABVR0stdIK1HICABpveZ5wlhEh8TkON1PjrDgY5AQANY7ewtel4WOiAB4neOHZBThAmM4fgIlgnDmQb7MAAYAjQ8BIEgAE3IyM8AQANE7vqSNJcMkGPCZQBgCWogg+FfDfhHggEdcO8gGAgkbwz4cYkhHBJoRQI2EZEgREQi/hE7EWMDEBFrUxStw4ofcKuFojw0RgUhFgkRE/DsRu7JXiSPxFvt0AIIykeCKRHYj6BDImEeSPhFUisRZoVEQSPJEYi+R7I2hIKLfbSlbhnIvEdyOkAUifE1I5EXcNlFkj5Rrw/BEqOxHEjVRQo+UfCM1H8iUR0vLkWqJYCYADAGIw0WKKiQSiH2tw9gaaL1GJkFRaaEJFqNsF2iTQLw0hO6KNHkCnRb7bACyJtB+ibRUtXEQ8OdHBiMRYYmkeKJNG6i32GmW4XwMDHJRXRQQj0Ygi9HaAfRNoOhNmIDFJiMxoI20GyPjF6hcx6mOEdmgrHKjHReIwUgcJVFr1YRSjItG8KiCABcXeMRepixbYzsiGIQCdig0vY+AP2JlGDj4EtYjgKON9Tjj+xjY6cWwEtHkB5xPYvsTiIZFikWxOolccyJ8idio0i41sdkBVDDji04gBcVuKnHnieR6468ZuInH8M5hTY4UY+JPFbi0x74q9vABbF3jtQroq8YmlPF0i3xK4i0R2PkA3iXx+4+8fqMfGgStxy4hCeaLXFziYJz4/sT+JXHxQWxqE7wMBM7EhJsJZ4oidBNImnj4JRE6UiRNgmTiAR3ojTPRLIm4S0JGmFsexIzFHjyxZE8CZGI4n5iRxfE6iUxLzF0TRJt48STWPiidic0p4wicMxSIcBAAiTsAjlJHFRIAxmQHeoKxXqDSX5gPy5A7iE4/SYZP4zGScgg4QUv2KQTUi7JFk0dFZJslDilGdk/SQONMJGStJJk4EdKQ8kOSIxDIzSbWD8mwj4ogUr4UuKcnSVOm1k4ERpiinfjYpLk49v2NFFwTUpvkhKReJ8gZTPJAkkKT5LCm5TyRAU68UFMAmhT4prkwcrOO9SVTopr4wSV0zSkXikpTUlKYmLXo1TtJdU80eQEnGeTqpJU2qUBPykwSgpRUvEX1PClSlhJL460VlJ6neTLJOUgaRaMilTTmpSksaf1KAmdTlpOE7KaVIGnbS4xMU1aW1I2lETJpl08iXNLKnKBFpS4zyTRKennTLxb0qqadPGnMShpZCIKdxM+ncCcJnkkGftPmk1jJphY5qTNN6lQznp1CV6eWKCkfSkZA0rQN9LRnNTRp60s6RmO2lwyUJf07SSyRHHmTrpT02mIKXnHTSyZuQWmW5MfH2TmpGMgmfFOZkzjJJbM6SdTKRnczVxckzEaTIFmczyZiU1mRDMZkehj2x496bLKFmHjPxhUpWchEvEKzfp4s5yTlOVkNSrxmUq6fSNmmCyNZLErqS+MhkSymZMhTscdOCmmybZcslwMRJ2lbiEZa03WaVKFlQSkJisnWXFMlmuyRZDsvac7N9kYSRJDs62d7K5kPt6JQU8OXHODlxQ3ZD0z2TdJ9kUTHxD0jmSnNtnejJJD0/GQXJdlpyLZD02OUHMLmK4rxJMq2erPIhuyG5jkwOVZKFnYzoJrcryVnPjnNzJJPc0uTXPLmyTHxPc5OTXMFKqTHpUM9sYGjZEuJGZsIssXzKhHtzfJEUheYiIFEbywp6I7eWRgTEmzEZNssUjPPzlTzyRCCLqZ6L3mdMLxZY0UevJPleyr59U3kYvONGvy+5TiKOS4J3m2j75VCaUjPOHnGTXZN860XfJ/lzTXZGor+b3LgULSDRiCyeRAoWlWjEF1cjBUuQQAzz0Fm8tOTfLDEwKIJb83BS6NeGkL/hwC2ib6MQXgKiFL02MdguXl1yZ5OC5hSjJvkkyyFrU5BTWNeF8LaFsCueRJLrGALHZp8guc3PhEiKWpxUm2e6FMkloHJjMlRQJnpnwyNFA054dBL5krSxFyivRZrNFlLTdFwIkWYYuNnkLf5Q6UxRbJsXbinZBczRWKS1nszLFeU1WQzLoXuLypvivGd4ofGYSjZii1xTXICX1SLZ4SrhWFOiWDTMJDsphQks2luyHZmcuaYkr9nJKA5xitxektDmeTCFaSw6f7OBkhK8FV4h6aUs6aJLgxvEjOVUqoW5z8ldi7JV9OLkjSWlMYtpZUv8VYzx5MswZTxOGV+KClUSrGYtPkntKBFUMxJdjMHk9LRlci8ZbtI0VEldJxaKmZMuMn6FaOWpHZdSKLQaKKZxyr4acv8VCzeJhi0cWcuBEGKKxx4h5SvOlknLpFFC3ye6BuUGztFxiEia8qHFOLnlLimRVEuVlBKAVSChZfrN4mZT7l1yjWZJIRWfL7FPyjWSLNRV1Khm+s2JaCviX1LfZFSy5WiuyWRzeJy0xFXsu+WRzoJVKmFcosjnFKPlOKodJHItkMrCVuKhOUDNJVsqMV3oppaCqyWwqc5mEuMS8qRVFz+lpK1JUSqImVyCVQK9TOsuhXcr2VYyzCQ3OpUdKxVzc7uaCsvn7LO5ZinVWSv1V5iRZ5q1CacPIAXDVp1wnSUcp9RaiJxlwp1QJGEEXL2R7qx1WaJ8i3DPZco1ca8MMXKj4JIa54avPrEciPVAahqbYNjW7zX5Ua/+WCKLHsSQ1SjIkfGudEqzMEyanMf6vzXDiAF/ou8dmsTWRoi1ESsWFWpFG1qs1ZoqUZ8pDUWioFta4NS2uEkeDa1kantagqLG2qe1WCzNXmqDH2q61LAENY0oYVFju10Y3tcEn7UTr6FoY2tZWrNF9KN146ktcmKnXNrnR2M3hV2rXUGrJF/ogdcerLVRJN156q1ZevDEjq1qBy71WoqxHFpme5ywAHQ736pRoABAd79fP3gB/rpe7oR5DDUnrgbIN8/KINBqV4QaoyFBMckT3g0I1XQJmKIIAADdjDd6GgqABY3Yw11lEg29P4IAGad4jWwngYfg4qgATH2MNwLQxIAHTdxjQ5USDio8g6wQAPP7GGlngHzNScBRhtLGELbFLqAAoHb42PCaQhiJTszXZY/RZmcAQADLkfGtais1ohQUNYgAOXI1NK3Q3vuCPKAA8Xb03qJN8uqQADH7pm53kAKzp4BAAKWR8aMe8AQAIo7GGoaNkGGj95yAgAZ733NVPYOj0EAA+exhquSAAiPdC2RU3e8AQAPx7kWg3PxFQ2aJYt8WiLET3gARb5NebYeq3xciAA6XYRqMIjW6GARgFTO43Yog4XZmhLC6a6IkGPQUbtVqSD8QNuUQQAIO7hW6On8EAAZO4VtdD8Qb0gfTgDCVK0dA2GR1VJmKEAAyu31uhrqFog9farWNqqi6EIEvqRbaNrm26FtlPqKIBtrvjLb1CShLdjYnW2zaVtTBAWqyL23najteLHxDdqW1bbLtctFdY9s20XbpCZLIIe9oO3PbpC76s7U9qqi3A24gABvI+t1LOnDQBYBPR9I/kK8IAEadyHdXkADa+yju6ThD1g6OpbStTS2aIogVjarYMLzyLBAAdvuFajqOQY6IAGG9wrYkGV7NA3eUQQAJa7CNenIIHWCAAAffZ1JAlsuqCOvowEYrIfMTjFxgxGTo7JmaIun5IKyl0gBCghINlBwEACHu0QEKD51ksgAYJ31d9EGgSdoQBa7YIGurIMwNIGG6QoJu/XX+KN36grdZu6pUbvND27uBRuu0PbuOiAB7Hd13ol7YJMcgJ7t1Im6PY9GEFggED2McNdQEdYIAEN93Xe1XTascWQFzBAI7wV0yVOArVNAIUC9qAAKnd11eaDc/Sb0OQHz3Z65W5AQAKM76ukGCeF2zZ63YAYXSVEEADzu0Hsb1EYbBLet4ZRhr1pAm9OkzFt3qwS96G9/ezve4O734JR9CujvYPp8Hd6QkM+qsOPvn2qqeg3euhMvo71YksyLevvU3t31d7jdO+h0KwWcHD6D9yQM/WyEn0YoV9h+m/ehQX38MH91+grM/vX1ihN9V+4Qa3vvJv6DlnNbvUgkj0d6gD5u1vdeLAPj6IDV7KA/ghgNN64DlQ7/WQiQOHKBI32zfQAfHR2RHgf9C1usEADM+zXoq2AAY3Zr19EHQ8KCAX8lQYN6mASmxYCeCsEN6awN8EmIkCOpTDFggAG/2a9HmDkIkGuoTdOAgALf2a9x8LJpwEAA7+1IYdC0k5evCQALv7UhgXiUJD6F9ksgAUt2a9D8QKp6Q4CABXXf0PjoNoVIQUsYZP0GGLDaEpRtYeRRVhbDOINCdKWsN26eQ5h1ww+2sPO6vDhhjieQGsPu6nQUfcgn0i7rDNroZkC6liF7ocBAABTvq6wjXBaI2rAWjJHs9qR+XnAEABmZCkZYDL8sqC+OSCeFyq2xBwmmtZggEACguykYbDrRw4gCdYFnoV3Mgz2tkfeH8EAB4eykaEhAwujD8YvOlr6PZGBjkGQsMMe6T4MWAuIP4IAFI9/ozCGiCABKveWMw5sMPQdY+McugbwnUgAD13+jex/TWhqOPZHb6jYQAOnkKR4En7pbKdFAA/Lu3HmQmAvADceyNehroSwmPIABS9lI2v3ICABqnYBNLIrkIJ7I2vyqiHcU98AQAKx7xiCE+0ahMga4T6u9ZC/RFgbR1gJ4IHNnpBJRAKjMOaQZtUAQ58OAgAer31dBwG9FEGPzZ6GAblQAGw71JwkEo0ACoO6yZA0smGTvUZLIAFad6k/BF1SAA4/aFPSRTe8AQADQ71J7lJtBxNx8FdeSQsJtHfBi7dgrjFg3hWpOwhyAgAKr3qTt1eoPaWyD28oggAU73qTosSVPWDm2g68AgAfvIrTJlWHVKn1gX88Ah6Bk9afYoPgT6hJq08GF+YXxAAlvvUmjDCAH0gyec3GxozHECRiBsADIe+rtdBAQ2CgASB2UzRINkPb3gCAAmHZTOYhjogAdR2UzeSFoE1H4h5AXIgAKh2yzeUAhj8Gz26Y2GZppswrpshdxGd8hO4D0EAAee2WeBgLRAA5TspmtsPQQAIK76ugKp0UABhu9OdajkNogaXbPdiD+CAABnaIAsADA+sIXvAEADnO1uaJCiUoggAUN2jzyWQAEI7F5mgIzphA9D1g/QtABxVvNN6+IIMQAHz7N5wUpedggvmxSv55FP+anIcBfz+oYC9Ut/PmgIL77eAL+btAvmapHhTdD0EADduzeaQtF67INg1C3+dzB9zkLV2tCyFEQtjTCL7DXC+BfwuYX7Y0qXgqaFwvQXqLZFrC7wRoS4WELtYBYfY3lPghAAsPsXn+MptBxrqkABw+4JYWFYg5IgAeH2JLMO+sO9hBgCXnzd8cZD1gCaK8ij0QUzs+aV3aWtzSuisBfEABi+wZa9Cgh1gATXKhpTfJwApg0iLc/PGSBDHH4uwOfIJFWh4BAALOSOWIKlpUITmC/AiIxEgAZnItz6ycw3gEADzxOFfMOrNeUMV587/CKDYg1L6wcSJwAHogBPI5hviA0EeBuVAAMLtbmxY0kGTeQEAAYOyVbmMqRdTHAQAOt7JVgMNEEAC7u01eb0tXdSnkJvZzRat/mUgPVyA31aAsDX2GfV8C6NZYFRA+r0Fya4EL6sIXRrf+zq4x0XDSoXIh55861Ajbv1GcYoQAJ17W58ZvAEABYu0ddY7jMd8gAAW3AA5jtXWPiV1j6ldYyG3WrrnUZ80wFxB7AC2cAQAJDkR1r64pa6bclzlgAS52tz9+LCj/EAjdRWAcke6DtC3OYVVUlloTVud1QBNJIz5ibQHwCb17srLvFSCqfSvy6P6CIaOnJACYCUvGqNkrV4xsjkBAAbTtbmFL48ASL+UkbggZGz51m2/UgzEIDeWO8EBJR5udm36cJN4/404B026sjUWsuhQbAgxAATPtbn045AQAIg7W5uHUsKJu8pAA6uRa23Tut98GSTVa8pAAkgRa2QwpdQAHR7xiMvdlaejW3imdt+AA7dh2pADgh7N2E6kAD0u1rb51FnMBYsbwhwEACIewHbT6cIQ7P0XMxHefNNVyAgAQR2tzTVH6oJHHjjpR0tEKyqXSTtfD+ELEGExndrLU9jKedgu0XaapJ72O5AQAMo7qd2E4AAEdogJFhh1O26koHeAK7Ydtt2ZIEVAkuvgfi5gjygAT13W7wwdO6vBRzHRAAbHvQJAA1PssQ27oyJkLtR6AFa0Abd4ZshTTPOhq+3GHoIABO9ie00GUmy0PSVfLE4FrFCABaXdPtfLDQ1IWkHMkACouw/a6bhG14J5wAAu7H94ZopffAwmfoYtW83Vg/AGIb7UQQAEd7/9vzIA4geH2EANtuB/xkzse4YT0QQAMd7qD0dMahW4wnzu8PMUIAApd3BxnfJoARgH5ABE/ACRPb3YeZSR5CkB+hsgD7UDonSAAYfSZ/iUfUPhVsABIu+Q6UCXQrwgAbD2H7NYFIHcSiCAAyXckdFA9F0QWR7BEixSOBpziFR8ijUeKP2GKj/UDo+kdy0VH5oQx0Mp6AqO7QajvEjffgCAApHdPtYVq8gAW/3HHsIKqEwLW2cBXHW9zIE4+IHW7kOSa7x246ahMDnBNakJ74/WjuOAnDupJWmk8E+OuHfj2J+E6msrqonKTmJ2E8EHxOMRdCLJzVATAw1AAW7sT2Snze0p7qWKdNROapT1R5QQTLOIGn2jpp+wwacGP2nU1hp6Y+6eBCGnVj9p3/uqeMcao6QGsgsCiCAAx3YnspBOQbYUThu3WAxhOAbBlJxWnfBix+96wQAEL7E9pkNDXICAArHYOc9QqoOkyDUNOMQBlfHhzi5wJFg1XOOAPqeAH6RudnO5tOkqbGNuufwAbrnzh5++B+cARnnek/5x87ufnPO9ILwCAmBcivObrxiAF1C6+cCRLnUZP5689ucpP7nnejFw8kxcvO+xNzyF7i+hfOqBIILrF8i8Bf4uqX8LjtFcleeIuyXNUPF5S+BeMvqXxLsyRC/+d0udJBysF3y5LRvPBXXqrZU86JfguxX7z8V6i6BeYGuXvz3l2K5Rfku0XWArZbC5FevP1XbLt0BS8lffPuX8Lv5wa4FeKvcgoxbF4K6sp/P5XOL9lxS7cqIvBXhIW18i8Nccu9LvLpF1a81cXOlwNLhV0G5MkOveX2Ln1xS5yBuuSXgbl1187jdeu+XAb510a+Td+vwXrLxN5m4ucQaLXMbr5xBtTdyvi3Bb+Nzsrzccv3Qnrot/y4ze1vs3or71zW+heFu5xFbodJG8plvPu3db9cbS+td1vRilrptx25beWuNXSbjx1K5lfVuJ3c24V8hpFfluw3s75V3C9Vd9uZ3+brd7q4Xfju6XBy2Fzy93fDvw3b6+d1cjFdyuT3Wyld4S9vcArSXG7/d9e9Nc7vq36rh90K51eMu13r7xt3+5NcMuxt27lyHe7bd7u8XPysd8B6XcePS3Q7xdwO8Hddvq3sHjt6O9Q/HuR3dbtytB/5d7ukcFWqBJwC/O+OyP8Tm+XYKo8pOaPET68ZR4ntMf3BeoaBAx5qjsefBmT7jwOECcFOuPbH6tEs9yOLBVn+FLqvk3Vh4BBYvj6smbD44gBAAl8QT3iEDYOrBN0ABXxBp8SBexpYE9k+vAEAAHO8Z49T8tJtZn1uxxQDDChyAgACj3bPuYezwBFzPOet7bsOJBVsABte7Z8crwBAAfDu2fB4jYJjKF9tRywAjdh4I8xmXtrW16WIFJngAi9efNd0QQAFy7tn33Vl9s8FEfodDUtoAEL9/L9hTgCAAXslbuXgU0MkBytXkACL+9V4xMrPOAeJrh7vSbwgdAvgAEd3qva9U2LGhuzwBAAgzv9fBw9jU2rqkAB5++N+SA2QIrOT8gIAF+dub7om83ghZvW9u2GfrZZ0sZu0QEzdt4ygrwFNx2yhj0CM2qOdvR0Pb8nqItigrv2jm76d/2/neEnUQK7wY5e93eVuU1q76Y5++0t7vytT72qUN5idwQqzual/GSyABSXeq+8H4ACP7bw2Bh1FhCwV4QAEl7iP7ILDrKxDR/i0Qdr7D/HT9bz80QRXqT6/hZxyPxiHH6j9DjzxAndH+n7j5p8FxAnrw1xAz468NgOfwidhpx/gC8/qfzP+J/CJCQi/2f4v0H3eul+M/sBuqQAGH7iP4hEmCaCjAKaYoUoHz+ITf5BfUfOKoAGR9tX4kAN9X22Qwze3pwEADY+9V69qAB8HYd+joWtIMQAKb7Lvj2JBlEqcBAAxfte+DcPv7ILLScI33/fDv0UIAChySP4nFDiGJAA2Lux/mAIfYpg4+2+afpUB3uADH4z+JBmh0QQADi7sf83FH+u8K4ChOfkKE4RAV4Ay/33iv6gbL+A/G/KUMv1Y9cN4AtADlre5vhzAS6+EgfVu337VTbIj+uzHMCDGjD42J/Oz7ZB41n9b5owuVWf9r0H8w+R/pbaMCeVn8PNKYw/okDmEG3RhpPI/iGDOB/pcOR/PWaMKMNn+6powWNq/4f7d6D+BKs/vSo0gP+0WKZg/kbbP5PpB/E1mf9aLJZBnAjsEAOpBz0VOl79D/XtxJxAMWAPtgZgXhANRx/Pv0+wdsb/x2BmrLSgX8MArfANQV/DAPD9zUbAOXBZqcgPWRz0EnwwDS2A1B38MAh5nRxyA/7CZ4kA9ei0pT/OAPP9v6cgJ6wDUO/wwD/0Q1A4CZgC+ANR3/DAOxBGScgN/8DUf/wwDq8NbHICwArSggCJ/fiCNYYAyAOBAFebALHpD+AwNwDJdfAMP8MgLfAjpiA8wPD8I6ZPk0C1/COg39zAx9Ajp1nTQNLZeWAwIeYI6Yak0DGeYVk0DBtIPgMC24IbUk4OAwphGEDAz/yG0StTQJkDphLK00Df/COkUDzAwAIjpgAzQOrxBdAwLUDJdDQL78EjLUxn9igrfHKNsA/IGoDjwNryqDH0VgyqCHmE8D8DigwbRPBuA+2HyACgi9Hl0J/MyilsdA/oIH8NLbANrJUbMoMP8tLKW2sD7YFgHD8AmewL78oGAJiECpgjG1EDIAzNipsxg2IICZ4gvvy/hWvZwIiwagyTzqCOAgMEfRVndwMOCHmVZ1aDD/U2Fa9Og4qla81g+2HFR1gBGyGC+/et3BAEbRAMgDPXN8C3xt8V6wesnrF6zut3rYENOJ1odYHBC7rB60ABnHautAAYx2rrQABcdt62wDPXU0l1QkQ+6yusQBK60AAbHaus0Q2EIn9CQXhARt0Aw/zpBvgzgEmDPgkwOopYwPEK3wEbWYMAhw/BGyWDGQtfwRsTgwCFLYEbRgMZCHmBG0eDPgptARtAgv4LCCEbCIOBDeAxYARtL/GkJ6wEbD4MAhdUBGyf8aQi+ARspAxkNiCEbA4MZDf/BG3SDPg6vARshdCfzRBH0QTVuDD/NEAeZBNWUPDgm0QTUVCPQnrEE09QguHWBBNM0PKxYgwTStD7YTUxmEh/DgJQtFgXCgZDYwkGFwpWQ5qFLZcKSUNjCHmXCh9DTeeMIDDYwwbVwpXguEHVD4wrUL78EwHCjRtEw7Ok2CJ/Fo3BBcKCMNPwQYAHBZDsA9ZAeZuwn0OvR1gbsNeD1kMIO7DVQifw241nHsI4DOQLfENpeQzkHD9DaQUPthtcWPlFDOQB5kNofQqQVj4SwyQTCDDaScL79OQHrENo9Q7YVj47QyQWrw9GbAM5Aegw2iKDD/S7jnBAmSAJSATMD8KBCJ/FIAH9ZwBMK/C1/ICNFD5ndYCAj3Q+2BSAesICL1CWHSCObC+/EUCQiOwlIFiCgImMMThEgxYCAjkglCOrwgIp0L79vWcEEGpfgw/xyAB/Qaip9SI3hEGpUw5oHWBBqTMJyB2QwajMC/dLfEGpeQnIHD9BqNcNyBH0QamgjcgUtkGpcwkyRYjFTCfxyAm0QakPCo+GSNeDlhciM4BTwqiKrDBqGsKoiesQaj1DqdGSKNDSIi+EGoOw5HBkjsInIFwihqTK2wCPQGSNvCcgQAMGpsg0iOrxBqEiKoiegwalfD7YKEm7RPwifw4h1gIKLojD/SbW0pZwyAMb4wozkI4DEhIKN5CQ8cECCjRQpgFLYgoqSOkF4o14KYAwgoKM0iAonrCCi9Q35nijsIpgB6Cgo/yNvMHmAqFki+/GsHWBxOYKOaiQYNqMzCkgdkLaiuI5IC3w2o3kOkhWoo2mwCkgR9DaixI1IFGipIpIAeY2on0KSAm0NqMPC+dUaL1DVWUaJMjD/Cy3BA2ojsKSBf/NqNvDGhUaO8j7YCGDkhtodqMP9eDITRiiJ/Bm3BBsYbUw4D76F6OQi7o2INejsIjoF/9Xo28IP5wQDK0oj7YMIB/CMrP8L782EEmwijwY3hAysmIsQwytMwsIC3wMrXkLKIJIMaI4CsQEm1FCwgM4JxjaAw/xL5FgDKzEjyKEmyki9YEmx9CfiEm0PDuqEGLejIAsIB6wMrPUNDsKYr6PBiL4DKw7CwgWyIysCIsmMACMrdyLJjq8DKwujBIHoIys6o8MHWBQmMGPoAB/UJnhj6AEJjCYOAugHZDQmfqLoAt8UJl5CkHUnFxjIAugGJjQmUmOtxH0UJjEiXkFWLnBsAjJBdifQn5hdjDwsakWBQmV4I8QXYvUOeQXYjsLoBbI0JjFjrcX/1CZbwugB6DQmOqKuh1gBpFuj7YAqxTjqKbAPHNFgVOJ9C4STOMPDkaVOJ2j046vFTi5Yx4B6DU4uqNStKKNOKIoB/Kii1iIzTK0ei+/bEC3wqKXkILB640UOxAm0KikPDsQWIKopsIxwnWAmEBKMgD3QXVCnjS4mgCehH0HsDWdsAxO3BBVsduMP8vQC+E3iOwjPHWBTwBuKWQHmI+J9ClkX/yPi7Q3VFgZYA8LFpNAASt2D/YaF+4FoeR1gDuDZJGiBAAf12D/LDFLJogQAGW94f3psDAKsAMAGcPAEAAcshAT6waLz7wuwGBN78nLFWBxBtfeADLBkEiCirAODOsCR8WwZBOuxksGU1793sRoTlhmQP5EAA+neH944CDHSAvQKLS6BdUQABb94f2eARocgEABmPfYS1EEY2S0eE3vxhpAABr3h/RhINxi+CXSiBAAeV2xEj2CEgAqYb0AAPvdbtGTdYAXtVEpXWrxAAQH3VEob0YIJPeAEAB5Pb0Th+I9kFIjE1RzygzE+9jFJLE7R2sTH4cxJAsEASxIMdHEgxKzoH2SxNMcPEi9m4FLEqxzwJFUaIEABVvdUSnkaIEAA23YiSqEWUCiSrE9tExYEkhxKSSXEqIAST3EtJLwUMk/hlNhsk99lySgkp5C2NFgQAAn92JL50pjY6HRDVEhPVlopBT+BBhAAF326k0fEaSayIYB/5FgQAF99upLrBswGEEjgYhHoDy4t7PeEF4NYQACeyVRI2FpUd8HHg2CeGnGT6wPAGwAe/LhxFg30bZyb1V4OrHWBAAAX3VE/00AAo3eOS4NM5K3sesVZzv9XQOmmGZo6JGiR9AAHt3W7O5OYJhDesCvBXkq5L113gt5PGDwQQAGTdgFI4gA6R0BT1OAQAH190FJ8w9jGkHhtp4+xi0FR0H3y9BVqAPj6jYU0dA7xIHB0HQoHQTClhBCicEENi3ky8HqAijLfAcEuHCFOd4dMFqDCDAAE33yUyFJhSrktRI4BAADh23kxkxA0eUjlN5himQADI93lKFSDce3kABOMjFT/uI5UABZPZqcaYf7l31yAOVJlSQ9Ojg4AFUsZ0/94AQAFTd1u0VQUgZxjUiQAQAF+yQ1PghaNJtEABUfctTaaMOi6YbfQAFx9w1JmxnQesA1hAARrIiAcVEeR5Qz8M41lwRFOGpONbmBGTFgQACb931NzAHwKRL680AcVEtJogWB0TSfwwEN9SOKPAEABMskzTTJQADidvNOJs4AQACkyTNM0FogQAD3d8tMvBm9StN1J0YWtL/160xjjpBYQKbERT8bfcAYxq8OvV9T2aRFOT5gwOlgWhAAbx3fUl+lsZPLAEJmEJ0gOHWBAAQP250kQy0lyAQAFydidNdAVeOZDSFE081w4ANXWF0FIbrWCCPTzdf5xCgz0v8RPT9QK9NQML080DvSNME9LtAa7V81yAeoTons5E0r9lhMG7H9JkE35XiDsgnQJtEAAEfd9S9gT7E/sHQEDJpBdUCDIAzcQI8kAAT3cgyNSA3BJ11gQAHx931JEgAISbSiBAAUV28M50FYBDEEjMTSWecgEAAEHbwyNTNgkAAvHbwyldEO2iBAAX128MgazwBAAZ7IuMmsh6xAAGf3+Mln3WBBM09LygBM0Qk4BxM5FCAgBrQJx1AZMjFHkypMlgWUzH0yTKE8xMtUlUyY6YTKoyBrTUmJxBMsjBn09MzcmMBlMukQsykOFCGUy5MyTJ4YlM8TNvSnMzch8ANM/jJEYdM19PczfcTVMWBTM+8iAhHQPxjFAiNRNNdBFQY2ybwwgwAGT931NdBx4XVEABxfaSzS7EGF1CMstijPZffQAHl9nLPfA5uP5EAB1naKzhKJfA4BAAI/2KsirHIBAALJ26s1dI4AmsqLPltBIIoFH8xQNrnay2KTIiR9AAS/2isyHw1DXY9rMTpEUo/jltoTbWzdBjbPAEABVcgyyqoU2w9M4AQABVyJLJahMgYpkAAaPe2z9YfEnx0rwA7KizJTGLHOymAd7nIBAAAD3ts67Ki0FoQAC49h7P+5EtLplXgCHJqmOhAADj23sj2FzN7sq7P+5uHbOwugYdH7PIBAAYD2AciVMnthmL7MfhqHDgEAAQPbhyTs2rVd4FoQAFA9pLIwzysFrIQBAAdJ3fUq6CrA9gCWCyyNIsnLdBmQkrUugVPeAEAAgndpzBgQUmZzT06GAe8Wcy9O5yZCTnNvT+clgV5zH04XLrlOc19O5z4CNgmZyG06XO9U5c1tKugH4QeERSBKURx6FGqZkNyo9jc8jMg90KIEAA63bJzMfRFOFY9jOGx2BpNC0OG1Tc6dMWBAADv2yctgkABqXbJyvjKEkdCiAYQjvgFoQAHCdn3MoI/c7ZX9zdSX3K8QDdeAH9zYICPL5wOAGPORQ48mQhjz9QZPJFyY880HTy65GPLtBk871TDzGOAgmxNwQcMKDy9gcgEABwPaDy2IEiigZAAFX2a8rjQ4AyHNAG1ReIcgFbyQAdvIThZaR5LERAAJLIm83vPIJ3QKHVsdAAA72a84GLFAPWNvLdAgrcgEABOHenzBfXMxXz588ZkABociDzyCS+zfR2HaIHvtN857n4wkKWHWqgbvAHnoBCcJcF+9e+KIFAQT8rpnPzB0AzRHyCIW/OuBgfP7wN1H82PNlphmV/MvyMoa/LiQzue/Ie9/8pPMAK/MYAvfy30WWnAK78n/Pe8JaYBAxRQHIAuPoQC/cDAKv8yArloMCrPNgKz8nAoQL8CiAtQK2LaIAwK88nhBp5yAQAB0d3fPyBSkqIEAA+XdYLMSJ/WiAuCk/MIxOaDgoAKKgQQsgNhCmAtEKSIdwWEK08hgukKfBYQpIKpCq1O+1hC+goqBNoa7EOBAAfzJWCvpFa1uxfQpDBuQGwW7ERCrOhMK0JfRWiBAAR72uxQACe9kKEXYrC/yTsLHCzAo3ZXCreR6B7CoNCcLlCywoxopZXwo8KNCoItMLMWcwskKIi+w0zF7CqNCcK6RFwuCLH5dwsSLPC19m8LQlMUASLfUAIuMLUij8VCKMi8IoMK3DaIHMK5CrIqKL5RWUHsLE0JIsKLIihaWcQGi/IucKvC2ovQlTQdoqiACik/PKLylUIsaK1SFIpaKck8wsCKhioVXcLSJJosGLsil6TaL00JwpiKZitOQlp7C+YsyLYixVTmKOisoqWLCk8wqOLuik9XcKc0BYu7yuiiYpRkViq4s6Kaiu4qWVLijouqK9itZVCLHi5Qu5ArkQAEqdoPLKIAAYg2SQwALUfh+WalHgBAAUh2g8gLUiofkaEsABiHZ9yfbaSEEBdUQAHL9tEsNAJtOZEAAyvdxKRQUdE8c1QSBEABUshYhNIEkricbBMESpLiS/jHSdIDGtUZK28t2FpKWS+A0492S7vM5LmSvJ1e0yEPkppLBSrgW+071UUpfNpBGHQ35yAQAEk93EsGBqIsyGUgU0CwVLzFTCrEItogdCw5Lcwci05pcLZIo4ojSyA1wsk8s0tYsKLTAutLaLbCymtGLZUvNKv9KIA4tlS0UHeFPS6L3vMFsjgEAB8nZ9LXTHW3WyQAQAGNyYMqhyOAQAFg9qMsxAx8cgEABuPZ9LF2aIA7oDSzpmcSLvMUDbpY83yWzKHAHoDzKrSsKULKJaPMrTyCy2xJ8E8yrPOrLX2UHzzK883yRzNCCPAGpEUffkoSU/MEG3IBetTMqGY/MR0CspoKDdMHLPsaLRqxBy5xhYcrwUnMHKuwQAFMyXEuxAqofSCWYPUdqnmZVyyNhxBXjNkGS8NYD427K1ytWD5NqqFLw4BAAJp3dymHXvwWHS8rYJubU8tmyjbL/mYMDbA0rPLjJEMvmywyyMu/KLnQsEDZkvPAEAA08jvLuUUCougNYSCqArcgJUH7w402LJPpAANHI7y1IBgq90QAFTyTCowifoI8rwA8KjktZR5gzlLFBAAFN20SuNPexyTBAEABAvZorQ+T7IU08AQAEWyNEo6AfwwAH59n3JfpmQO+F1RAALn3+K4MBVR54VamOg4KNvNLhL8iMx3zZK8Sq/hLDA3S3zY8uSpUr7DPAHUqk8zSp8MZMdSrTz9K1SrwV1KrPJMqgjOAHUq88uStYZyAQAAMdsStHRRkEUAlQ8AQAEKyZyqIo8oNzCvBAAKF3+Kj3Dm5DEQAD9d/ioqBHgUykXMrwcJNkrBjaIEABs3f4rBjSDHDBkcI8mSq28teBkhyAQACpdn3Ok5DudYEAAl/cKrS6QAGdd8qr2BaYEQgXwRKRsEABVakKq8gXVEAAN/cKrIq10F1R/QzqsO5nLC+EAB7fb6qvQT7E6xOq9cOYAt8N0MKrDBKIEABcvdmrXQGGiqrsqo6CvBAAFh3Cq9apA0tqtauPhukXbMSFAAcf3tqg6v60eGKzOOrY8wBmyALqhhnWBrqpPNurDq47LRROAa6rTyXq+6qQ5PM66qzzvqo6qQ4UoD6rGK6WIem19OAQAFP9wqsMRTDbKqS05keGu7z20K7CnZwQQADZ92GoNz1gQAFp92GrNpwQQAAv92GstJ2iQkC7BAACzJCq+sBrs8AQAGxiGmpIov2DfAJzOs8ZkABn4hpqxDQAAP97mpD98QNUt74JDfmq6YsqKsCbRAAQ/3+a9qp9yM4noEAB0XblqldAhiVq28gq1IoTs6IDVru8gqyPIdawnCLB06MMOls5avhPIBAAej2zaqaFzMra9WoWhAANb2iAIWzFBAAKd3napoEqY6c8EEAALfY9qbTTAJ6B3atAHCFy4er1Kr/awSE5AK9OAEAAPsn9qvah5kAAzff9rRs+MKf8kwzgGoqQ6qPBU8ogA0xzqWARk2SyFhfIFSsMYdyvBBAABT3na01PgBYQoqmns7NDgHntOAJe2dqmAAdBdAS9DgEABTnY7rU/A3DfBAbH6wQBAAC52O6vWjbD2AkACQpMSgPhTCO6w3lzY8AQAD8yJepvRAATkIN6/qi/5T0UfBVSOAQAECdnevdMiBXFLWhGwbepDrrs1CmDYEAavJvqMKGQWeI4AS2xDqMStgkcgP6mSGs9v62etdAm0XClGFyaKsNwpL/QgjTqMw52uUN1gNQxDryKbIEZ0tSeMOGpmQV0EJrkw8bNnqL4dsOdrh6avDwaQ6jPGjqvjROBk18U9YEAA7/aIBNkfvxBATiX3yhA0AOhryzNoMMs4B/slhuug5gEinmT1gGuu4bd4E/hUh74TAS7Ab+IRskSJk9YEABOPdobOqRW3fT4dOvPWBAALD2FG9inujLskADobwjOJDm1hIDYXWBAAdj3NG2UtSt+IDiGrw0TIRpJNiqWsmUbcwJkPBBZ7TRoQdTYN9FogVqIs3WBXsoRprAOWT4OiyQYQABY9zRr5008ASuGN1gbRF0ahhI5g6B5jdYDtr4mwSDJ9HKQAEymTRukMCwT2pDBVG8EEAAgPc0bLG+iGrxBE+JvXwYcLMGEQm0QAGD9hRt3MtA8EAHCFGmsDYIhdTZA6aPUJ7C6b92ZMAOqObX30AB7/fabBm7ICAgGM8gGYzuGjpqOZbzOW3IBAATx2FG+rEABNvdoa0KY6EAA1Ha2b1q6vG7D8bDEwpr6tMUCP5r0eCCqgbIZXUWBt8SBEAACMhYhr0LoGJwRw2hoXcSmlhuQ1EtcgHkbvm7LV3IOAcexYbxwmnJYaUcFxqME7UZ5shbrCheRhbaGuFuY8sERFohayKoXxgk0W3RuRaMnMhGxb1kXFsCE71Alo1qxYerEABOXaRbWM0TJ6AKW2CEJaaW+J2cR6W5FEZbj0RTOiB6W/UHZbyWuWnpbzQXlu0y6WtUl5aCGKlohbWM7314K6W6luPRLqrloZayW9DkVa2W5Vs3IJablrla2GKawFbtWnzJFa7QQltDqB0PHCIBo604BtpAAaV3zW/yFzB9gaIEAAM3dtaSGtEEGEm0F8PNa3wL41j58bP4vIBAARF2vW0auqJDgQAApiYNujwg2cgEAB8XfNbfkQSCGT6K+AEAAXvfjav7cgEAANHfjaRyom03D42nmJ+EC2xTVMlXVT9QMk0AJ1SUcXncxV+EXxOkSrbHlQGXDUtxZFEba3lGtpbaLFStqBFkZZ4W2ku242XbbgVZttjVG5HtvU0NHQGUyk62jKWLboZIQGAkCpN1TbkQAYdvqkKpGdv5k123to0cDZZdvLb+GddsXbOpLdvHad2ydpkJhpFdoxRj2jtUBllpWdtgg721GUfbW2+duRktpB9rHah23duGLGpN9vPbj2i6R/aj2v9tmKa2uMSfaG28DrTl3JPlV9U52idpLb11RqSg7t24DqjkXxdDqA7YOr/StlQOu0GPaLimtobloOj9umV4OsjvfbkOhdteLSO0Dv1BiO/dtxlEOsDvU1zlD9UQ6P2m5XeVD2mDo46m2zCUHaQoJ1V+VeZRjp46rFPjrY7zQMTpCKRJETqI7e2yFTCVQOttpU6NZeFVA6BOxTX1kUVSTto7R5RdqxVQOuTs06OpKFRpFcOwTrFx7ZQzova9OiaRJU2O3Ttrl1RFzus6kOxzvc6eizzqfbzO2zswV/O7qR87jOkWRw7f2oLpelhVG9rc7wumZQQ6vO0Tos7Ni2VTY6mO1LpYV0u5LuU7ou2C1mUb2vLqc7m5XiWo762qTovVtVdTsq6JFarpvbMu/LuxlrVMzvnaVFIdxXa2u6tsU6dOrrqE6eum9o07X1UxQk6Guvrp8KBuw9sC7CvRxRk7cu8bvPTPFNjqG6ZurTqs7yO2jsSVmRAzrG7NuvdtM6b26bocVzZdbtC7rhHJRC7u2nzpyUMlXrr26gJelRq77ukOUu6ou1btdlOVQjoW7qlROSm7vul0Vi7+O/7uDFKJJ7uu6ulHLqfbGu97orlIes7qBFFlNVXm7nuvMTK67u8HozFDVQbv+76OkSXK7GJFHrHl6uv7s27OOsdquVru3juE7ye59oR7flJ5TdUpVSnrcLqexntva6e6TtZ7P1QFVJ6FOq8S7b5JNrtU6RJM9qZ7zuuFVO7+eoXuRVJe+2Wl6LxA7u572OwrzxVZetUnF67Zd2Ws65e3nsgVLu6lQ174FfXpS7X1ZlWN6juwVWC68lNnuK7NVEXN+7fVHnuZ6IOkSRw6Dejnu9FQetnpW67etLolUae6Ht97su/3pt75euuUK6le23st6YZJHrrb3e03sx7Y+6FR97o+lGWWU2ewPtT7mupPpqVi24pkAB0Pbz6EtaLUABePaL6fKKwmRzoyhAA0bkOwHPS1C+uvvhyz7PzCRzKU6vvgBa+xzvr6W+i0kOcPYFHIQBAATD3y+oOV8JR+vWB6gB+jvpH6e27pKiAmKntuHRogYLiX6W+exg2oV+81tYB6GQ3MAA53e37uGaDk9QogPft1IpBI/vthD6sUAP7K2nfp1a/8vfufb7+lVp6An+ttpf6NW6ICf6mOz/t+rv+o9r/7QfJ/qI6gBv/TP7GODpKvACqu/qKr6gLExodt+yJJ6BAAIr3zW/DJtpAACh30BrrJA1sBytsjhBtHRs5A6WD0kG0V49Z3PDY+UYU5BHgT0FbDFgQ2hG1OQJoUJAvQJZHdbY+XKnSBv86jJ6BAAH12iAHgZBhAAd/2WIYQdLYRBoQcyAzvaIEABZXekH8gO+miBAAHL3FBh5FrJObBAEAA8HekH94B83BAaGtAHSAY+cECAidAttkYAnsfPmMHnGvEAwjJUOIivAkiWwbrybgAImQpwjP4EAB2XekG3B2SDCgKE4SGOtAANl3pB/X2hhDfMOnWBAAdH3wh830iHLfLAT8wbfQABx96QcxhfvPAEABccgyH+iJCKyt0gAqx+Qu7QADA9oQcb0qoOWzD0ogQABtdiobSBkM9YEABhfYqH8QOKiAigQsWDIrksQACWdiobDgNEQHLDBogJP2MH4/X6GGHG/WUAT9YIJOEmGJU6XEr8xQWYeRR5hoYcWHG/CWlmH9QdYaBrL8xv1NBZh80D2G3qmv1B9Zhu0HmGMIpXUrqEAQAFG9gYfz8UE5uAplAAFMInhkuyzt0CXOyvBFeJOB6YQbNqQrgY4J4Z6YxaaIAcKwR0ZlAdOaa4oBGYRpAsgM1i6EeYSladwScLdh+P3BGkRnJIGKQABEbRHYRwIScKrh6YLFBAAVt2KhpDllAKRuYaKNnBOkbWGGR9wTpGsR//p6A6Rk4ZZHAhOkbJHwjdYEABpfYqGb0QABkyEUY4M/gQADAdioY7w4kQbSAin/LanChyKGqh6xAALP2Kh7ahjqEAA6nGHtR5VEFI9qekYNHtKjgGNHmR00ZkJjRrEatGRc40e5G7RuuWNGyRg0eKY9RgkbmNAEIm0sz9qE0e9He8MUgtGtRgMecy/R20dDGPMv0cdHIx4Gr9Grhl+lgy5IeCKEHJ00SEAA5MlTHLkZMc4AhdB8CzsH4Pyg0om0MCNTGayWiAXdAAfb3UxiClohvw8gHSoCRpy2NQXIQAAi9msb+4Y6L7mMHCwP5B1qHwQrEABA3dTGczNDWHGexraI4BAAER3Ux1VlvhyAQABCd2cbVZoSpcZ7HwC0MBzGBKB8BBprctgkAA6vdTGM8UnSiBLgYwZ1togQAEzdoQf1h5iL5oJGZBRfJ6BAAPN2hBteHIABHcQek4Pxt8bW44Cjomv02CQAHhd38e5A/gIR2MG4Brpn9aOAY7gfGHKU/NRSw9eABAnIJlyHKG0Jh8EgwwgwAFt9t8YkSlsGR0AAMvbfHjqOACdNIJr/keRnkE83wo9gOsCPJAAdr23xhiecssQMMqiBAADd2WJwNn5tAEIgRg5OJ3UnoneJuln4mrfQLK4n7ySnOd494cgECrIJlHEEgh+eSbfGUcObUKZxErWo4BAAWj21JrOjZsKfDgEAAjnbfGqQGSH/h1gYbMgmqQAqmG9AAGJ2zJ7IC9ARYQCExA8EuZDjabJ5ydcm6QCIC+SbBGNrmHL7GgBcmoqvyY8nnBIKbWGQpsKbcn/JtH3cEgp3YdinfJ9yYCmprIKZOHUp8KfSnEpwISCmrhluCkxLMaIEABqvbfHaYc7kkrfGjgHqNIJ2mFcq/K8gEAAIXaEGNau4aiBhWDCKLAjyczGMHHgWZHKRaapCJK1oaAhi7Lxp5vrmQuyu4kAAhkiIAyIhAEABYXZYgjstIEG1BqfCnWnYgracWnhgNUoD5RI/acV0WybehkjcqfWDcplp/aftgC/DgHko0AK6bmRAATd3bpkYBP6Xp3UmembBF6dggfpzFj+nkUAGfSS/p/UBBmckv6fNAIZwpL+m7QH6b/0vpxjiumOQIdENYegeqiemiQcjOiBhqfWDRBr0RISA0sZnqFNaOAQAGud/adJnO0IcEr6/gQACudqme/CaZ4Zl3MXIQABudpma7rhmWUrZnyAQAFud/afSBl+9SJG01cvNiOncxoWZvpIUwaiyswvPAHIwhZ7mBi9O/EwzMy1pqLyIq4AJWaxmGKdSJPJB4ZWPUiBKfWGsknkKB0ABi3f2m2CCtpAAdJWsBGHiXGBCxn3wR2emHR2l2ftmBId2fjzGpLBC9mHZtBC2Hv2wOZ9ng5wJEBkQkMObdmI5sGXLEY5wK3EFwkP5z8RvZt2d0QHESPJzL620JFdmk5gJHSlrxGJAdnM58JCvaYJEuZ9my5xxAfY3pKuYznk52ubHZwZBudb4/gXbTLR059uYEwPJaNEudM0MUgKl+5x50HmXEpaVLQbZnuZFy3pEeeKyx5/DvBk55nYA4onUIrG+nHnVeYfz15mfUuct55FmiAisHvU1nis/eecEj5kfRPmV5nLDQLD5mCV3nN5m+fos75pfSvnJyroHe8aEI+a3035n3CNwH54rJ9wBMAXCQQAF63I9wxSAXEvmp51XDDGucafV/mIFwdgFxX5mBaAWx2ZXA3nisnQku1zCMBfkIhCF7UFJxCUBbfmcFr7XPTxCaBfzmCFqQg0Jx58QgQWYF8hfoXqlcQlQWaFlhc/naCcsXwW94N6tKSUSPhbxIESBQpzLiMY+ZgX+F0RdULzdCReoXu5x5BEWCSWRavYJFphZoXpFlRdig8FCRY4XFFrReQCxF99gkWf5qRf1I3KHUj4WLFgTGFJSF8xdf7tSa8WsW4FpxY0WDFmxZFzFSLBY7RPFuuWFIzF/OcEYivAhkIYfF4JeEZfRnoGoZ7FoJa4Zd+5wSYZwl+JYf74DahncWdJCJcv65aahn0XMllJYNaxQMRnCXqOAhjg4wF7DgVaegTDliXu5ypYerql5xavn6l3Dno5755pdKWoxxpbyX2bTpbjHGlwJbqX+BCSegFyAVAQqXhl7VwlKo8vAVqXMlyZbpKxSPAQUX5lvykIERlkgT/E6BcJYWXuS+9LYEdltZcoENl4VtYFeF5pZBHnZmlPmWnBWyTeEkhIZduXz0gqQeWblgIT/FhpV5fZsK4OubIQvlmlh+WW5hOeuX2bW00+1zGYl3e1MlsFbu1e5rsQ21oVw7W20h5p8QRXQVpFaIXx54aShX0V/7VYX702ebRWaWGFeRW65JeaJW5YeiGKZAAL12p54PSiWEAalf+mBIelbszyAJleBmWVqla/6OAJlfBmuV33QfYmV6GYFW3qsdiZX4ZlldVmIzeAGMMNZulelX4Wkw0kX855w28NTKosoQBHDHxbVXAjGQg8MdVxVd8MyEMBd1W7DbgRCMfF7q1asrVuaxzLprFVe7nurZwWGtbVwa3gNxrN1bloZrL1e+0FrHxbMcCGWRwDXhgdRyqWxQFRzmWBIQNYaWI1ppanmY11pYsd2lhNdDXFHHhlNATHENZBB018jmUdzl1NZyc53UZednknHSQ0R/HPZcmk7BMtejXUnXJ2mXlhuCWgRa1qFnrXi1zZYScJ5rJ3LX21xZcHY3pHtbrWi1/tcXmE51temgSnaIGqcwF2pwTJZQBpyjW21yp0xZWnENZXX0kzp3XW6nHwV6dt11MmnWC1/OZ48O852YY9e13jxzmPJVj2PXBPZlunaRPW9cvWu1z5fPW6159ZA6b17uZPXTlgjq/Xy1jciMXLka504APkY9cA3qQL5DuW7BMDe/WIN/FGeWWPWDYA2JySDYTIKpTwWQ3o1+DaxQZ5v5aw2oWHDag2yVhOYI3L8/n3F8/nXn3LXCCAX1pbGpWxGo3o12jdl8UVnnwTWWN2n3icMNtn2PXONznwl8o53je/X+NwXyBXokJjahYlU4pgVTZ16TYZX4AOVMdXy1+TbZWtU+NePXVNmQiU2Ml6Na02RcpTZ6WpN9WEKXFNo9e7nG0uZHrSwFiKfrAPYXKI4BAAUT2fF2zfJqXyGHEFJHN5Tapc8p+zY83z0rzZWWfNhKb82ZCLzd03gXXzYNxZSkXK82jN+KbwTQtsdi83Bl75yi37YQwXgAQIFzfS3bzd5eQ4st7zci2Qtg3CeWm1tCBy2StjLfy3pSGCAi2EtuzdK38t+KBgh4t1zY9gyt2Cyogct5vgXZg8iXgQAw8mzaVQ72RfGzmCtmPKXW3J3rfXYxtv2ejyNNizZG2+tubZTyU1/ObpAZt9QhDyM8k1avnNt3vGzz8O3PJ63e8HVCEgoW+AEAAwndO3jMlkGxxBSK7aK3pts7bY4Ht89Ke2gtyLa23zt97b/Ent+rYO27ti7bQl4oJ7ba3lt+43u3b8Mdie3Utql38yxM+VY23/MqFGsyXN1HZtwHMjHaMyeVoLPW2LNzHddwNMnHYM1laZTPh3gXbnPIA5cmzfFyc5znKm3Gc9nPPTOcr7ZhscsAXIJ3vnenfvTOctrd52NMSXJ8WxS0krydrnPkp0lRd0dYK2PJSXYEhpdvZfg6sEeXffBFdoUrFxhpVXekouSjXYHWRSq+fV3G12C3BltdoqhEmfoPiY4mhJsBfN2nkUSfYmiBTmk4nntu3dYmrdp3cgMXd9nbd2Hd8Sc/0PvF3fq3fdy3bEmv+Kaxd34tkPbYn/d0Hxd3Kd9OwYwroM0lYZ4dOsMhXAAXt2p5oqiT3nQIrzT3aPUdqiAs9/OZz2aQZPaHRU94QiLmo0Eve7my935jz2q9yggrnE0OvZ0kG9ivfz3q9vDdIl297RnfBc9lPbIYC9+OZzR+9rhEH2nkYlJWQ9CZvfT29JKIB+T69qfahtZ9yvZH2e9qPL7nl9jvdX2Z9x+G72W9tjd9Rd9gfY/A19w/fn3uN79qX3s9/fcdB19o/YTBfl0iTP3J9i/YP259zfeP2SNnNHf307L8DdBrm/YBkdAANd2fF56mHscgEA7yQbBMA9d3dYaA9gOp2noAQOfdpA7CwUD9hgQPg9zA+APukUA7loEDqPfwOYDwg7gPvtBA4T2iqOsFmQzIaIASJID9CkGmCgfzftWEiRA/oBWDsyGcFODjA+4P6D9g4+9ODvA8EO2DuWk4PSD8Q94PvtTg4T3CWsiuudsWnSUUOYd7fbeEVDgSDUOzR/2cgQtD98B0PW9/Q6vmjD3vZMOp5sw7/2LD/ObFbQOKIApafFuw5pHFWqbecPnBVlqcP1WpNbFBuWrw6laul3w723LD7w9B96WhPYv7d+7/p8XIj1JaCcn+qbdiPHF0/sW2dJJI7x2Uj+rfSOORm/uCP857I+AHzNnSQfAA4EPRssC4XVEABp/eR3u5ko97x04eeCeF1gSo+e26jso8aPpMlo/Z22jg0nKPeQd6paP6tno99w+jydXBAWj+LeGOh0UY/J2WjhPeenogL6bAWYZ2UD+mptmGecQgZnxZhmJaMGe2O/t1AyiAoZ/Y/UO3SuGe2Os7UMAl0+XQAHJdqebyB4dUmAyA4V2xDuP85h4/ZpUZk/fgA3j7uY+Orj4w5+P7jy46ePX94xF+PnVEE6+PrDoE/eOJk+ZPAXimQAFa97Y/hOiqWBYU2kT57er4K69E/QXz0rE/Z2cTyZMRO8d+ACxP6t4k4ROMTnI/JO8jv47RPAFpBfw6sThPcFsz7cXd5dAKfOfZPTCPXavW3heAG5Pu53k5l3ld4xGFOdJUU72WeNoU6nnpT/k4JWyEOU55OteDk+N3OpaJElOBIaOnonrnVMCvndTgpOrXIEA06nmjT+bYKkzT/OYtPjD60+7nbT8w/tOdJR0+sPnTnU4YSX2NgoNgegDgp8WkwLSf5oxC+1eEKptgM69PgzzVc4LUjj0+9AIz4xYlpZC/089O+tlQp0XDjpQuTO4z1M+9PVFwpPUKszwM89BBfH31pNDYQs5D05WCDDyhhD2UENhnt8M4NIqz5yz2BnBes/Z3Gz33GbPSz9hnrP6tzs7RmSzms7lp6z+LYHPizhOB7Pvtes4T2ffUOt5PksDIX9OGzJoAXOchBs5XOkGtU659ChGM4WTNz18y/hTdUoW52dTg87XORc6oWXO40rc6PPf1+oWXODEYpkAA5PcfPAxqPOfONzp89jX4AT847OsTVxd/PTz/c+/PaTz87HOALvNY4BPzhPfwcvT8gEABpPZ8W4LnM4EwEL57ZQv12DmnPT0L9ncwv0R8efQv6t/C+JHqldC/i2SL3Edgt0L2C6cTcFyFcAAG3anmRCUlYFOg0Ji/zmWLzFabXh5ji+7muLihY+WsJPi50kBL/FZA6ogES8cY6LwS5N2+JKS7VM2a3jgWgnKZC6UuWeATCcoML9S+fIxSLS7wudL2vw4AtL4i8MuH2LS4ouzLsdi0vYLuEhuzwQQAGH95C7svw1zgAcuMLly9jW3Lvc+6RPLnw+8viLvy9TRvLii6CuyWby4T22ER8EoQ/nThx0kor+BiWHZdwU7iuBIBK4xgkr8U/gBUr3WAXxErkOZrb8EbK6nn0ryhDBPir/OdKukrzU+MQcrpJCWwVa+w+8GfF4aAavNamwW8Hnt1q41rkjzq/Z3urxq58OogTq/q2Br9q6mtOr+LbGvCl4a6KO0rm7xA3AAEv2Srq/Og3oEZa8qvVrxDYDmNr7ubx1OWwq/WuVr0AvMPOAXa/iutrsdcKdzrtK/DBNC+w8ABZ3Zau7r7yhsEHrrq5evkj96/6vPrjI/evRr365yOogd66mvAb0H3evIrusAlqFoH1E4BEsyq6huaNNa7huSrxG4xga96BHhu9rtG6MuAOzG9RuUcJG/13o5rG/iucbsffxvKrqnI6B/iWk0AAO3Zavqb2m5sE6brq6ZuHQYQ+cRWb/q/ZubIdhlZvRr3m+EPTQVm6muhb0H1ZvIr6m4350gU3iiBAATt3Gb/LBluETzmnlu2b5W56hZbgPecR1bnm81veoWg/ST1bwW4NvtbuWnVuxbs29VvAhdW6luOgAzXWBAAEf3Gbh29cunbtm7duvLj255uvb/y49vBbv2+CuPbsW6Dvwrj28ivh0ayRjpAAKf2WrqO/Cw0dmO66uE7xxc4Bk7/q9Tu8d9O+AvBILO5yOc7qa/zvyd5O4T3h7DcuSxYb1Snzny7/4hePoEau+7na7nc5raA5xu8H07MOu812YJTgHbusDTu8CdP1vu4JSXMX9fBle7qeYMM0QaQT2ApsPaz5cbBpu6zsZ7xkySuTT+AEXuO7/SBXu57y0+LnN7/u+3uaz3e8BOD7glKPvZ7pK5A6N7ye+Xvj76q8BlokM+/ZAD2HM/IAj6nxY2hX7rC4Pnj657a/vz2Ui81X4AI+p8uAHr05kJQH+rfAfULkXNAf4tmB5/vuBUB7LuNDukTX1gH7zoweMNye+JvcHq66nnUrVqHbKegQAG3dnxaIfo6fKftXSH57cofCCZwVof2d+h+oePvWh/q2WH0HeiBaH+Lc4fQfWh4T3PQeZ2KZfPHxaEfmTgrd89nt8R8cX4AKR/Z2ZHsk6kf6txR9pOpH+LdUfuBKR8Ef7S7g+4rC0FELEfdHvBPxABMFEOkfjH+sFMexScx4UfLH/R4D3pScx5Uf7H6x8HZzHjR9cesDOuXMfBHn5GVRVtSFcABm3anncGQBBlpZLk06iAQn/ObCeAn7i8wenxGJ+7m4niJ/xWeN6J9Cf/HtJ/e8JL5J6Fdsn2FfE3fUfJ69U/MRnOSAW+W8fIBAAJx3QnvzEiaaOKxqvBdtQAHrd+p7QcbIJp+TB67qIHafYnhp66eotbMBP2+njp9RShn4vpGesVrCX6eUnwZ5kFhnrh8g7fUOZ6FcFn7p+merr1Z/Ge9H/EHIBDHsBeGYryFyDvIjnvzBOeBMG8mkeLnjSnTOlGa54UfbnnqBkJrnlR+ef0z+KGueNHj5+4FrnwR78xUnpvGs82n3Z6Bfmn3p7Weyn5ksKeIX0Z6hfkhmF/CfgXiosOuxngZ6RflUOF+JudnjF9JLYXnp+Kf0X7uaWR/eOZEAB93awWSaIsMAApfZtno9HoEABbXZtnCKr+I4BAAPR2bZ/+Gjpx0SVD25yAWTiH2N9zyAL3ogfvbxEdk9YEABBff2mjoa2wAhNSxYEEjZXjaGdBawH6AltPGmRxtasZtBFJgySGSMv8dUP5DMasZ6HavArt/afNftlG7YfnrXx7eZX7Xj7ZChft0467WAdq15B2H2cHc9eDjjTDh3fX2/G9Ubt5GZ1GogQAEnd/abs27ryWdGF9YaN6AJdUQahK19YFyddYN40nEWnvPW82KzOdcEEAA1fazfHjzUyMRYT+2ahPnjzmjBEy3/49BPMWGtRreK3zFqKuITpt7xapfVt+LfK34lvLFG3rt7VekcaIEAA+3aLfPjn9jFhDcwAH7drN7GAP0nceNrwQQADT9md+dB/LG+F1RAAUf2Z3zaAWAKS7d5/ZjX8gFNfy322B/YQUZhzwA93p6brJ38JqfkirwQAEhdrN6+COAQAGOd59/3Zf6q8Hffr3wQnWBAACX2s31CJ6Ap83997wzTUD/LfTJQAF2drN+UiOAWD+vfEX7OzsvoTEe2SBawLSYD1vptijHz3Jpcx6Bh4HD/uvCcmSh3kyQJD9khbHQADldrN8v54AQ62vf+qJMKiBq2e8l4hdzX+7FBq2N4WRmHQTj+cEePrBD4/mP+7W4+YJET4E+prHj5CRJPui1B8ePuhDk+syNj93n+P7x/tWePuZfU+db6ICE+cP/qkce9P7nZ0+5aGT4M+MHmhEU+DP6/tY+s3xMCf0bBLT/s+j9TFiE+XPxz/cEeP8GYdBXPnwRk+PPj/QU+1SUPm18ogQAAxdrN5y1OacL/+meQZ0ExZYv4Gfi/0ziWli+fP6L58FYv6GZS/QfWL/hmeQZ6IQBAAcL2s3l6BWN1I4ALUgunuZEAAC3cWmOQPRExAA+B4Ia/aWT+pBhzrxr7pYYQUeoqv7ZuyBLpTTBa44ADUp6cG+BvS64K29U/6Ym/hvk6/PSZv4Gbm/cCg64QAZv8GZW+KCkXJm/oZrb6m+NMGb/hmp+1aBW1PIUPmiBAANL2Gv+d6agtfUtmXfxviCJ6Bv0gb/mcxQxPhe+bvlgbRnq8QABh9hr6gJlseAEAAEvcB+QM2GLL5Af5bHgqBv5uHqxAAPL2Gv+H9y01jRaZEhteeAAgCo+ZcDXoOv8gH/qcfzrw6+cwV4fIBtaJ6cWe8fr98PPyAQABed9H9jQPSAal1j7ZhxAq+lX4CPCN+q9hzg0g2yn9YAPHQF+KZAADp30fwX66YLF46DF+Bfw7kYKOAQAAad8X/kzJZ4VnCMYJhAEAAkHfF+/ikDUABWHfF/v7U00yBogP+wF+jfuDTN+2f6QRbHyARaoF/CxooyK+7I/G30bpDU/mMj0fuKkAAUfa9+aWDGmiBAAO12/focAdBFwZgGrxAARP30fmyGBBNpz8Kj4b4fC145SdFG8p+QCK8Bm10/qGEARaTQAHzd9H6woO0aLUtNKfov7YR9gZNsABXvcL/YQZS/IBS/tn6wphcX30ABZ/dr/H9q/Z/2F9u/bL/lPK5BwdKfjYWqhHgFrw4B5Zxk7wBAAB7J0fuZLxOW/p2YQAkT2f9xPAFhf49mOALE/QfGT63PX/5twk5X+SToXHpwkr6UgpPD/6k/lxI5zf/4YqT+f5P/W/cgFZOL/vE6twn/l/6ZPjMtghRPd5nf5pOBMLf4f/Uk5qbJf4uvP/74nP8Tn/If6r/YAEPsLE7QzcAESPDTDP/aAFH/IBbeqFE7IzHf78QLmCegK5CAAAr2gAS2M8AeQAkfqgCETnDxf/IAAV/dn+E3HgAgAFod2gHAPQACAO0wCQNIwCh/gdVakDHhAACV7s/yQc8ABnGlPwLA4RCeI6wEAApfvo/L4yY/bH4MADPDbgA17qRbH63eKVDLYKIBhDJ6YqAooZNHHoAhDf6ZaAyxBMiaIB6A4GYGAwNjuCPQHgzMwFjHMUB6A6GbWA0Hx6A+GZIVVIDsNSWwgADCpPTFwFHOOACeA+2bPAKrIIAQABVO4tMAgSuIfIEED/pmEDdDvABIgcDNogTIRIgeDMEgSLlIgdDMUgXXJIgc4DWrqAd1gIAARfdCB37GdAfxRT+vCEAAnfuFAnd7ubbpKcAQACt+5UCf2CSZIUoAA2/YaBq73vMIMEAAPfuhAhaCAAcF2egVeB+gU9NB0KoCjyBR5rWMMDaZoYD6SvcsJgfbMRgdoCUWtAg5gS5EpgeYD0kpx5OACsCFgdMD23ssDFpjsD1gW6U71FsDFpp7kNiDJFhqMpAXntvQrwIABQnaIA1wIAgtwIEwdwNggTwKbwrDyUYbwORQHwJeBYuDeB+oD+BD+AfYbwPNAwINYeGmDeBdoA+Bc5y9oDwLQAsIIbMw0FeB7wPWsLZ0mgx7B+BjwPRBcILUwXa0BBOIJeeeINBB/DCRBDOHxBUILVIykDpov6Eu2gAG2dnEFMgYNLxRHQLKQC/jOgDgyPoQAB7+ziD6wLEEyonyDj4HKwnUIQBEQYYDvPOog0NGKCQAMpBjxjmBkcAkhogBAFWAFyAS8ggBAACo7jwKWYG0CvAWoMRBRRiJsVIAGykJQU0W5TEQumgNBUjniiqoRmwTqEAAyLvaglehoaR0EGgvaxRARj6yg9yZdMKlJHkQABde9qC1WBIk8oIhEegIAAwve1BPnjG0yzjSi2DVYABiA6EHAEAACHvaghMHOgXMwpgg0G/+Mb5eg2mDqQNg6/EeKKK8VgC0wI3xNoQACI+9qDSwY6lrfETxOAIAAsfarBNABWQXWUustUSrBlpH3Yw3kAADzsdgmsD5YQg5t8eADVpA0FPQZUDk+PACAAW+JtQWODu6uvhqjLyhpwYiCt8IAA03ceB9xjYIzmxn0BpECy8AGc2jHCeOuYCJAycVjBc1FfIUDCCi+NnPB2VEWAyUUeBTlmGYJOl1QgAGB9+8GTabrgbJP7CbIaICAAd123wXQ1OaD+D3gYWAAIZAYgIb8CQIddB3BEBCgQZBC5aEBDwQXBDvtEBCYQUAQQYEFEStA5tTAI8DNjF9gOAO/ZEQZsYecLCY+eIRD0fAgw8AIAAVshwh5EKag2swQAFUzIh+EHIAjmxoh+EG2UW4JYgspXYhnm3eBMOB4hAWxCg3EKwg483C2bEJEh1Sji24kLIgdchS20kL0Iu4P3BXEJhwwhGiAXc1lKO42EQxTCpMZEKJ+1P0xK5AEAArzs0Qw/It1GiGCAT2AmEN9BgAZrgcAI7yygmHDjwU9AeIUDLHQZiZkQvWDDJLuyAAWr2aIbiljrEMCHIQJgbAHSIYtuekbAL8DhDtKR3ADRCRcjYBwQcIcNMDYAYQcwADAGlVTgL1IqzuQBAAI17OEIdueUHMEsDVjBHjBt+/GBIGKNnBAJ4TyhUYMH2hpQRS4nmbqnP1tB/fzyiOELoBgAGEdtqGeaaA7kAQAAke11CBMO1C+IVwDz0kNDIoSNC/xENCgQXSxlnggAhoQlCJobBYhoSlCH4CVlyAIABlnZwhq0J+WO9nHQskHNWMIH2SHAEAAKztbQ6sBP2MmiJghACIsQiEPwXVCtAxEHlaUmiwVPACglGRCPQxICy0ZhwLQQACUu0QAb0M1U0AAwB7MuFUgYcchezIsBQYSABUKMThAAFD7/0PWg2ZnVKT5XIA92CBhMTmcYx0EAAmDv/QiWrrAQABG+/9DtzusBaskDDtznE41tCqdoYeTCq1gvIqYQqdjdtz4JTkTC7zjLsyxEVdhTgzDO1pL5mYWTDWYXsthPPTClmhwBVmkDCXnvkBCJuQBU0tDDH0KxogYfvh0ZggBAADI7/0IVh2FxzmSsNggTgiAeSjE1hyKG1hVF2lImsP1ABsPVh96U1h5oFNhNBQ4AmsLtATgiOU2Wxn0Tgmv6WW1Vhyrmgg95GsSkgmKqHADasQMKG8iBUtA0QGpo/sLvoOYEtANgkpoWsIDh4cK1AmLCjh+sJjhhoDjh6SSjhJsKThEcKmsUcMthGcJThbpSjhdsJjhRyixC/0KLhGnwK2WIWjhYcIweSjErhicOrh5cOlIlcPThDcID28UErhOcNbh3AkrhhcLDh3qkAArjulwvuFbKQUj9wquFCCEeHnpMeH1wieHYPcgBjwluGzw8uHxQMeGdwpeEB7DTBjwwuFqzMUCAAJb3/oaZJJgNDCUGo1FhqNBQxhtDCOIMqYi9AYg8kFT8rwCRMgYVfDE7t41yAIABYvf+hz8O98B53OmHAH+MT8MHq1uDSAL0HIA13yfhjYDAAGyQshPL2tQUdzCAm/R6AgAEc9z+HuAqICAAUd3/oYslAiKsRMEWxRV6KaZHEslhAAEM7uCLV2MYIQAgAAUd0hH+JQUgUIrWFYIwspKMOhH6whhE1lP8R0Ik2GsIxsoi5OhGWwrhFeJMdh0Iu2FYI0yHIOahFncC7iiQQABAZJgifkPMJlsIAA5skwRt8GdAxfD+QxjCBhgDShanADpemiIR02CPIAgAE2d/6FaIxwYNVcgB81PRGBDFSRigNXRWI2/Amg8gA5IexFE4LMiAAct2TEfKMzICMC2KnABAAGRk/0JBo0QEAAZbv/QgxBd1WpBXIQAAnO/9DTSNEBAAJG7/0JM8gACAd/6HZaCrSAAKt2iAFSho6m5RAAGc7LEByRdw3gAeSOyR2OA2G6cXIAgABGdspG34CpGtwapG1It+R/acgCAAYZ2mkclhAAPs7ZSKmM53A4S5AEoMaABrAXhDuuo0XWcUjksQo0RG0UjncmKpn7uo1GJSkKTaiT/hmR6bxRgZSKMcOsGiAfmiGRujh1gNgh80sEHUc3cGcEhyORQxyP2R7gkOR+oAuRQlCmshyPNAtyKko32kORdoGORLTBBggABZ9jZGDgQzxigNNq7Ioxw1aTmgptI5G6OYFGQGUFHnI8FEVCCWigom5EwouWigox5GIo77Sgot5GKODV4DsTbZE+HoA4EQFHU6VuACAHFH0bKIBYEMFHSOLFHEoj+gt3MUDko6FGUoolGMAElE37PFEYodRxUo5lE0owTZsolFGMoiFEso0HzkojFE0guSBtRHQIdNN3xHkGLi7IgkDkAQAA8O2UiCQCBpFUbsj8sOzgnsIAA5nbKR6qKwmx0E4waqKug79FpgluQGCiwCGiZSP60hOHn6gACC9y1HHgnurmoqWa7I7qoSCNkCNQFSZwAQACPZJai3/uCBf4i6j0GNEBAADm7lqM+hIoHxSsWWYMgAF4GMNFzaeBHXZF+CAAUbIw0dXhAAJH7qaOcsRCXWAGaJdREbAIYq1RAAuCUDOZpkLRgTWPgoBS80T2B2RRaLY4kGE6YYgiK+8AEAAQ3tlIlkD1o0jTMAJQTkAVtG7Imvh4AQAAfxGUia+PFYNYEOjdkbTAqkhjBjoCF4hkXZhGwIAA6MmyR86MEgTDgqQcACXRc6O544IEAAivvLozIDfjHoCAALN390cGYjyF5wt0T1hAAAr7y6JnU2DmXRbBEuSRaK0Ee5kQ+z6PhIKQBQggAHhyB9GW5cgCAAch3f0bDYQNIBi50Rwgd4fABAACh7D6MpAxv2S00GLnR72DXo/dleuaZXJKm4glAy6KQxbNGsSXpx0BaaDHEtIiwxeIhQxeGP66SagwxoinHg2GPLgA9h/uTwlvUXYl7EL8moxJGNwxOZwYx8IkIxKalYxyGPYx9GIU6FGOYxQCmfRNGNIxHGKWBlGJCgvGJwxdGJoR18mu0wmKORRdTYxcmMYRjGK/ELGJUxfGLUxbCJM6imOxEjyO0xsmNQxemOZEjaikxbyOMxtGNMx3CI2BWEiUxNyOsx4mIExevQe0SmLpEMmJsxZGKN67mO1ExGJ0xtmIERKCgTQN4h4xzmP4x8mPlEWCksxAWJMxPmL486aCUxRmLExkWMLKc6g3UUmM8xEWN0xdmOWKDCikx5yJyxQWKDEjGKokcakQxqmJKx+xUyxSmKsxqWNyxwWOOBUkmPkXmJcxUWJ4UdYiyxcWO8xEmKq6aaAUk/mMqxgWISxNYjLEg2K0xDWOqxXxQGxYWO/kbWJ6u9h17RomMiUtEETRMOnQ+xaOKY3umGxphDT4LyAIYo8DnR+UKRwHhHXe6wEAAtfvLo9ap1YK8CAAYj3rscfA6sD317sY9i2QMmgnUFAho0Efp35rlhD5m9jT5k/MbBEfNssUF9fse95nEBfMAcdfMP5mJ9isOyjHPuDjn5j0Aj5kZiwcWfNvtN/NocTSddcMdjz9P/9BSCAtscRADgHlAticRI9pSE7g8ce9iScfFAUFuTjTNsrhscYYtUfsiRmccosjFnmdjABItQcfjiWcZGcUIPIt2cYKtIzjqB1FsLiBFsYsfAHosJcTIt0zilBTFsziLFgqQlceGtxSLzj3sWzUxSMKQisWDitcePNhSE5i9cX4tqlIqRVcVBcnFlZigvlktMGkQxocTbj0zrKAYlvbiUloktpMY58Hcewx0li7ib4EV4clvwwj9J7jvtGIx7cX0t6OKHiyOC4dGlhriaWH0tnBJhxdcefoWluwx8OBHiiOFNYSOGniaON9pKOPbiSVnEx0MRtoj9ImtlHNDjE1jYJI1mXi01tI5kjlo4q8Tmsa8Rkd9HPXjzoY3igbiY4W8WGsLcXI41SMXiiNjDQKPLBs+8ahsENvaswRKBsy8f3ilgRPjqcYRsR8bht7MZhtJ8fPjiNjklMnEPjHPuORXrqPjCkicCN8UF8ayLl4egNl5Z8cVQTNl3Y1Uqfj7GOfio8QgAlNjHiz8eKkQAWZtE8TmZ9NuPMdNmXj38dUpDNl/ib8dwIlNlbjz9O1totvMRWIafiQCQFFazixDlMUF9ICe5tj2IFtocfASwoX+IxIRATctqgT70lJCMCVVsECclte8Y594CZlswILgTEtk1sBMDBAH8cQT8tkow0IMgTctp1tatgji4CYwTmtkRAGCXgTOthpgqIAwSttnHlyAIHkMCfwT+tnElBCbATgCZDtttuNslGInk+CYdtRCWtsjcZISRCattdtmjiVCQoS1CTnlCCawSftm9tLtpa9hCa9snXjnMnttQSpCa68YgZ9t5CcDsDjtKQPXsYS7CW68wdgHiiCZYSDCVZUEAAG8ICYjtwQAZln0XASidjmVrMsgTgiZqsHMmETcdv5dXMlESyduplxMhoT3UeESQauJkgCWyB0Tr3x4AIAAZvehxmRK4+ORIkJGRNPmPOWyJ7uKC++RJkIZROUJxRJXmyOIQAZRKSJH+0+w1sIaJuhPP00ew92hWG4mp+M6JYe0929qxd2D+L6Jju1v0Xu3KJHRK2c9u1D2oxKM+PQCD2eRKmJ7u36JYxJySke0WJT8GWJsxLj27RNqJQr2f29WC+xdeyP0neyb23f0L2p2mL2GxP2J1+0kxxxMc+pxOH2Iry32PJSwk9xIqJtUMb2TxIAiLxMOOb2neJkxKIwXe1uJkpT4kAJL2J0+07+3+2eJBeKMEvf0CJgJKj4l+2hJPxN/2Y+K7Ey+xOJD+32wXfxhJL+3reT4kxJDxOxJT+xBJi+NP21xMhJOJJRJo+z2B8JKxJn+yhJwr1RJ+JJ7e/+w2JQB3IOumHAOHJOQOFB266UQAQOwxLIO2B0xY6B15JWB35JOBxYJgJM5JopJ8EJBwlJBB25JoPmoOHJI2ocfjdGXdg9GDJPgRgCE1JsYwEwxo2FJ61H1JW1ENJQYwmJtRL1Jm1C9GO1EAuNo3VJZpLtJPo1pODoydJtpK1J/S11GuxI/2dBzYOjBw2J/pNkOf+U4OJpODJnN0YOVpL9JPByihUZJqJMZKEOkhzcJHxJkOiUKjJ6RMMO3hy5a0OPcOnNHpaD+LzJkBlZauZNCO7gi1ap+KLJOST1alZLLJgQnCO0OOyO3/UbJQA05oCRxbJ2S0xY7/Q7JCS3cEP/R7JcR1NAT/SaJkglbJgQhAG0OKmODRyzgVR0nJn8HqOMx3NwnABaOD+KnJi5PN0y5OjJfeFKOvRw6OMmE3JCZO3JC5L3JxOwmOc5J3JIxxPJX+k3JmZIgcMAJxxm/2hxd/0/+mJyKJU+zn+L5OfxB/1Pxz5NgBYuCgBCJLYciAIzWT/xTJ5+l/JBOLrkKAMApCyRTOP93YK/BRgpQwEDOYtEjOsoFDO0OIHOqFOMWziAkKmFLgpQZwTOfBRlJbICwp8hTzOpoCUK+FOzO8FOMWNCHUK1FKLO3ZxrO0QFjMSFPHOzFNbOnNHrOD+I4pQ5y4pkBnbOjFMrO/FLjJPQD7OwlKbOolJHOYFNIpBFInO1ZwEpbpRnOmFMgublGfOqlNAuAmE/OvFLUpx7D/OmlPfOf4k/Oh5MgwWlJFy4F0Mppmxgu0OMoux1gQutlKz8ED0FI6FwfxdlJ5yuF0cprxlgehFxIpapicpPlLIuslP8p3lKQeY7BoutlJkuhxN9QfFyP0NMARIfAwQALlFPx8VIxIGl0FIWlzcpVl3PS+l1sp2VL/EJlzypNi2Uu5l2CphB2Kp6VLrkNl1spfl3WADlxqpj2QZW3lzcpYVw3J7l1fxaplapV7ACuDVP+46mXcuI5KWwjVO9JEV2hx01yauY1J0wi2Nvxs1wfx41JABs1w6p9V2mp7DBGuk1LauIFJ6Ak13WpK1O+0nV1vJoiDhAGtEAA72RjU6QRIaHLAx0QABD+2dS2YKUDXLldS3yYJBzqfdSvLo9SlqZQDXqf5dHqYeTPqaG1NqYsBHqSOS/qZdSLcZwBHqQdTAbtEAHrmNSwbpzR3rnNS4aZAZvrrDSeEK4tgbn5SPLGjTAjhjTgaUjS3ShDcxqfWBHbuCAnbkTSw7gbpOAB7c5qcTS07j7dyafESXElTTMafAiKaXgpmacDTaaWDSI7mNT87usAY7rzSWoInclycnc5qcXcsdhndBadHds7sndfqeLTidsndgafLTryaXdoceGBgqiJgeMGrTm8KItpMFHkhMA/j1abrT8QUowJMNrSNaXrS/xHJhzacbSH2EJgRyUbSCSJbTYLEJhbyYg9+aO/c1af3hAHlRcfIKA9Dad7TnKeelQHktT3aUA9pSFA8vad/cCLtUp4HlHSfaWbCNMCg9ocao9yAP55T8Zo9BSFI8H8ZnTz0vI8U6UUAKcWnTMabnTqlOo8C6cI8LcXI9fSdMdDSg7grHgY8U6V48A9j5BzHjnTm6cexbHk3S66dbgG6XMSEAM49u6W5sTHsvCDnmVTXeMPS+6dwJfHinTCnqpD0Mck8j9IjklUtEBHhhnTW+kqkbBPcMnqcvS7kZAZt6UtTd6c8j0ktvTDyUfS24avTx6RvS96W6Vt6beSHkpM8fKMmA1tMS8l6Rs8lnjMCg0HM836Z09FnlM9dDi4JX6Y58H6X/Sn6Vs8PvJx4gGUF8QGZs9Zob4IoGefoYGR/TQSTmhv6cAzYeLQN4INHhaninSMGflQo2oKQanjvS8GVgz92MewiGYfSSGQBEyGWLgiGWfSqGQQzB2EQyRyY+DMGdQzahLU8a6cMwR6WwQUQrgzLyHc9yADeR+GS5U7nlc9iGQIyXnmKRHniIzu6FIzx5m89ZGZc8Rct88lGWIyx2P89ZGeC9n6ehi0GdAzAXgS9wGaz4EGVb4DGci9sXssNAGXozEGWYysXoS9ySSYzkPpXtzGfYy18emhrGaYzMXuERwGcJ4oGbEEuosuiXIRQkyiFEAPGBKYsJvOwlkZbF8fnABAAHBk2SKBaIAEAA82QJMrkDAougzkAQADfewky5kK5whkRdByAIAB2HYSZbJnIAgADQdkpkgaYpn5MqAl0AwAC8OwkyhwYAAoncaZyAyME9p1dO6JKQQHTJ6gBSSZhPTPomzb1NOhSN6Z1/zdEwzNaZESDrEPTPZBokCiA5VHyZYKWiAxVkWZPmE5ohViORhYDWZkBg2Z5yK2Z+IIloGzJuR+zLloGzMeRJzO+0GzLeR+zPUQL3mWZCTLBStzJO8Ngg2ZnmJuZxeB+8mLF2ZDzI3KHzOeZ7giOZPzJtqnzJ8EZzKBZTzMM0oPiuZQLPguPQFQmRaP2ZQdPtWQE02ZYKSRZUZxRZezLRZgVI+8KLOOZ2LLCpPghRZ5zIJZMdMKSKLOuZcNiBgvvkAAsvsPMq/pTVdYCAAZf2EmafxTTJvhEQo80RmdRouwIABlMgSZR1Bcg7uXyZVPEoIZFC/oTqOFY0dDX4ckEAAqvvZIxoB51d/hFoxoBbnKARVo8gAlEIZHn8ZpEGIclrV4PpLasx1FWwI1lVQWEAYfEdELgjWCAAe+IFWd6ALWRNxbWaayPUdNRBIA+ASgvABAALt7CrJ9sBDFpW2rN9Z002iA/rJVZcJFuaUQEIB2rOWwJFSLRQxm7g5AEAA4jvZIuNmXIqPIJso5Epsm+nAPdNnnIzNnH0v8Tpsm5F5si+kcAdNmPI4tncCdNlvIuNmeNc2DRAGv5DImtnKeFnb2rKv4ZsqYzVQZtlQFNtm5sjtm1sznbuCNtlFsvtldsuWhts8tkjs/6IDswIRts6tkdsyeAcAQAASO8mz52RMNBSIuz22ejd5hmKQN2b2yt2Wuzx5huzh2fuyw4CLkN2ROyT2VMyl2b3i42fdMEAIABJHZXZ6N2aEAmHvZm7OeGEFDFIb7L3ZH7LLgYuDfZx7N/ZYzPgAb7IvZQHO4Eb7LnZ6Nz+RgAFwyJ9kGeLuBKMQABiO/ByiUegAoUDBz32WhzRCJhyf2dhyZMJhzAOfhzXcJhywOcRyv9JhyoOQhyBMEhysORCim1rRy8OfRyu1rRyiOcxz4oLRyyOcxyNMLRzq2WnU2oieQffMn8s0hwAUsJAhAACLkhSNlYXjRfMuZjE5knOTZN50+B5AEWQjbKU5C50KEinPnO25wuJCAFyEnmPPOOnOPYRQi05q5yM5YuDKEpnNvO4Qh5RenIDxhnLvO+Tl3OfHPCERyiXOM+jnOWQG9US50Y4VpHIAgJUbZBwFLogAEod5NlkTBAABc2NnNaGejrAQAD++8myb4AVZIagdFk2QLZiYeCBAAMf7qXJy0BkDYIgAHud7LlkzBACAAO53k2Qzom9PkleEIAAnfbK5GUNMIWUI4AoiUbZO1FpMWXGa50xiMs0QBZwjbPmE5AHp4PXJFATUAne6wEAA6vvJs6ajkAQADtO+Nz6sI1YeuW5sHwDn8nUHNzY2QNt4AIABCPfG569gQAGlJ65veF+aHAEAAfHtbcl7HkAXbmrc17ZsEV84ecvSmCkHSkncn84GUvbmAXEykPcsC72c8yl1yGynPcncFHKV86+c5arkAExJDI5aoCYSxKeY0Hl2JaTGQ88eZuJbJEw86pQ+JeHn2ssdiBJZHlNUGTl4lcgCAAf534eUUYl0NpIT+nToQefjyZNBPo/8jTojkQ2AUmGTya4dEBKeecjqeQTzyefAZKeTcimebTzR6T0BKeY8iOeejdLPvTze8XzzEgLZ95UCTyaeejc/PvatZUFTzSeZLzPPpAYZeYzy5eSLyFefAYZeezyVee/pz9FNYZebzyteVLzCkjLy3kcLz3YT0B5sOLzmeSgZZQLNhZeRLyhmJPDLGbbzlefbyt3O4JbeZrzXeSgZTQLbz9eV7zHeYUlbeSbyeQCsgpnIAALXbx5BYEIMtSGpQgADviSPnfQOcGj4K1l4AePkg8rZlzIQAAsu/DyM+TYJM+VTzc+Zix8+YzzC+ekl8+ezzS+Tkl8+bzzK+YUl8+SbyM+YW1AAB47OfPzozoh8gTfIL5bfKMBHAE75JfO75MhE75FfIH5IuU75NfJH5dck75DfP0G5AEAAFbvw80BkovaIDf0xVA3wGHDT0DaLZImxzkAdPxFomxwCYOxxHI/flikQ/nnI4/njzQ/k3I8/nVKQ/mPI6/mwWQ/lvI+sB4ieqqBA+ABZcoZHP85DFE5eACAAHJ2t+ZmBPNEpQOAPoJP+etVyAIABtHa35w0Gjo5AEAAMzvQCtEDzseAVb80ky7jIqiAIS3aqs8gCHjT/lr8PH6XgAnRH8K6IO5HaCfhNbLuAwADK5EQAc/pWh/IX8A2pmgBMNMuAFoIAAGHaIAzAsAQSjEAA4DscC04AsCkDTsCpgWZAIZJZkQAC5u3wKsgAZA6AYAA4HckFTCXWAgADn9vgW2weYiAAb52VBQiBR0eQBIvsILVBX44d4YAAiMj4Fl2025wgp6geAOU5HADeI5gpSAlgpj5fyBPs5gs7qxDk4AgAAh9vgWFgR5C0wXZjrAQADP+2Rh3BZ4KmANXhoeOYKVuHMBqmk3oIRj0AzrOELXLO5ZGdEA9ZQCdZYIPg4EhVEKyWc4hUhcih0hbNxEhdELDYdEBUhfqA8hXTBR8EkKqLqaBUheaAyhZEKKhYULE6cUK1SGULCHDTBogF3kyhTEKQAIpUQAF0LfaTpU0hVn46LmbCUILpVPBStxw6YMLShcMLPEqmhzKhMKRha0SbKosK5hdb4fvJaQGElkTAAAm7qwqVo6wueZmwu9A73h8g2wqGF+xn2FKQw2FAZwhx5ADOFuQtmFlwv4wCBUIqNwrE+8ADOFMwouFWBSuFhwreFD7DOFtQseFPwueF1wq2FrRI+FLQuBF5CPgAgABk9vYWFlHyCwi84WTCszHkAZEUPC74V6Y6UjIir4WoivLH3pZEVAirEUEijTDIiu0DuUxC4TCiB5HKJC4z6dylm8hABIXRjh3JcgCAAeB2+Bf7pwQIAAdfb4FC0EAAADt8C0l71YXnyNGDSh7RMUBC6RoywgQ4CAAL+IOBXtDBgCny4APKKmBeOh6cHgBAAN5kLEEaM9OEbAmooVFC/0wya6IWABorVF9QDyyIMHBg5oqsFCACXofQshKTg3IA2fJtFw9U8gilmiAQITEu0QD4uYlwfyX2N9FUVMfgLqhipCoroWJwoXkklzDFRT1ZKT4kDF4YrhxkDPjFMYrcZpEmTFrF13x8l2jF22kB0UYptFNiO9KNooBh0Yv7BE8AoaBg0WARgwdFzvGAFCABhqaoqmwDiAVemhUS05rL6gCosbFyMKQo7DXNZP4S9FRRkwEzoG7FrYrfQA/kV41PMHFzYtPetVjfQvCCP4E4qbFl9B7Fb6BBg+NgXFXYpbFM4uqgKzg7Fk4qXFI4sAg3wV3Fi4uHFW4vD8yfHXFQ4s3FRfzX8c1EvFU4uXFp+GHCx4o3F04qL+j6HWc94v3FW4tLYJ5C/Fp4qL+DzGGo/4uvF5rKbQwrBAlb4vNZg2nwokEsfFYQVVCcEoPFVYUv8SEq3FPWFGEaEvL+6VhfFV4qglzAhViuEofFB4tiCJWiwl5rNsiWVnIlb6F/8I2moli8XWAwAXol1eClFA4pPFoErfQPQQgCE4qCsvfAAAdQqL0gPQc8AIAAuMkEll4GSaHAEAAMHviSuYxSg5LQyStUXcoMOEjlf1LrAQADp+wqK2bAtBAAIC7WkuzeebwQAgACBdhUVsIAIjhODEDSORyicVNUWZzPijwAQzBqiiWxxUV6JriuSXgCuAAswNUWuVO4xByY6CAAPJ2FRfqQ8ADp4dRSwB9SIcA9PEwKShrSZAAPG7HApilpulMGYoHilTApfoakvBAgAAD9jgUu1TgBRCNKVNAUpKcAQACa+zlLPauT4oGIAANfbKlnmnYQIMGj+aUogoCOi8a5divA7XhfZQuGrA5ABCBjUrLgnUoqEEQLSFTln6lx7DiBOUtrG4QlhR3UoxQHUsmlD7DSB40r6lc0rHYWQMWlSWimGhiGL8vUvWlmwzEJPQEL8Q0ogo9SPOGmLAOluQqcsx0tP+RfhmlF0v2GJ0p8EB0tqFt0rOGD932lLQvnggI1eGwI0MENcG2lOI3GYUQFsKa0v+lXH0Blh0oLgIMucECRWBliIzNhWxRulWcEhlU1m2KMMqJGVFxoQ9hQpFxTEAAsTs5SjEgEGK5DVjNKVhQPLBZgRyhRAMH5MCmQRtMqID7EPoXUyxGrRAOmWx/ToiAACr2OBUVVyAGnMWZYF5uZcrwpnIABiXY5lVqUH68AEAAInvCygCCM6UsX1YKiUhg07hdwEsGdg9GoIAEcH0y5QS7wCRhQMQABF+5LKE4KIgdRvABAAD97ksun6sJgllVMoYwAfFeisEv2AT4wQAgAGQdjmViGQACc+87KAaOsBAAK/7HMvtJBuF+45AHZlVMt9lMEXngAco5lrrMxhSAslMgAFmdjmUXUlS5xykqkZUtIV3UyqlNrXKlUy+OUyEQqmZypOWDsCy6JytOWwWaqmZyr/7kAVS70itmreqVS4siqMhFACNir0jmVfGEUDzrS+l0iGyDNynayYsA+lNy+uXobS+mlCuuUtyuWjb02oVDyruWBCO+kcCn6Km1JgUFWL5D5JWjBt8TgCAASf3p5ddkoGPVS55cNS6qevLZUsTgnLvSKgrl5oZDIABe/b3l8iR+Q9Slh4QdHWA58u3lfVMpp7l3blXVIiJ7VIvl2d3cupQtflnmQGpH8pGp7lwpFQV2EEbl3vI/Oiks6wF+iHAshISdAeiI2hnynAHvGQkEB4sxnmMIGiWMaACEgTfCqgyAp6AowiwVPICqgTQx6AgAC7dogAEKnazIZQxBkKzBVEgMkyGlZbCAABTJyFetZ6/k0QWFdOFAAG77LCpPQ0QF6yIACEgwHzFAT9FoVGEXn6gAEO9nhVaUs0ySK2hVR8OSDzxFhW1Q9oiDaDKzDUISAEgFyCAAQx2lFfkBh0HawxQIAArXaUVxs0WAgAFD9kxVQQymlmK2CAaK8MDSZGxXIoOxVWK7qk2K/UDOK4K42K80AeK8K42Ku0BCQfzZ00KdEL4Ueo4xAShCQR0DRAQADeuywqWQEC537rqQhIHErGRSA97yH/BAsOE9yAIABDnZYV/8B3GIfn3g12RtoOStoVRCC/gWRIwmAitEaFYoplLCpRwLfCPBLINZiOgQUSB0Bg4EBxn0rSuugtnzAOdSoOgf+ggOjHAUSnZghhUQBzBQkFGQbm0q55AEAA0HssKp6DvYL97rAQADt++Qr8wV2AHUJgrt6CYEogIABj3bWVEcFLYjdzYQhys6yEJVLYdKC2V0V0X+pb04cVVw3+BGNquayuuVjfiZh/X3uVuNz7U7yryuGV0OGDCm+VLyqvZs2IBV1hA+iIAEAAQORrKyv7DeAFECK+8zR0QrCAAIN2oVYmBE3ngBAAPJkUKpeQisOusyIUpCGIWxCuIS2VqqFMkgAEY9tZUkqxMocAQAACexSqwsB7AiqDWQFWAgByVcSrwpRLDXDPVh34MSqQweQBAADs75Cs5K0QEAAtbuCq6LSAALp3BVb5RSZZPZ7vm0BMFRk1RQNk0FVdkBZSl6NyAIAAJPcFVqqphwXoxcgWqpVVJdCLCgAEZ97VVGqxISmqhVVGZOZCAAZl3BVdaqbBDarbFd1Za0piwnVU4qXVai8xQE6r3FZ6q5aE6rvFX6rvtE6r/FW+BDQaWwMrMKxpDE0rFgK9En/Lk1Q6jjUOAOyZfUDQqBFXJKNYJVwNksfAbFjv0hvtEB/QAqrKcJZK2MqzFFeH0h4IC3AemozpplT0ABuJgrtUCzK8AIAAMMnIVBTU6IgAAjdttWNDR+C5SwADm+22qO9Izp3zOsAGPGogupRwBNbA2q8IQgBAAFg7bapq8fwFkVAipKGXSFaumPjwAgAEuyBdVPYNAYNqhMALQQACqOwuqFoJFzhoK3QOXm2rK/vIlSBYAACfavV393IAgAFm9h9XnsGnjHQF9UNqshJMJBaD7wr9U3wN3zrAbGj/q3kDRAQADOe1er04MlhAAP17basdApqSiAuwobVDV2iAvg2Q1BVg6utiqmpC1L6ucGow17gjWp6GqBu21KI1oPn2pbaseAKvFLogAABdijUhM4VkrqjhKncnoAMarECEpb4xF1KKIZWE8hHld9I+ecuBa5aIC6CgRWwVPjUYgATUcIGwSxfOkS8axnT8a3Lk98sUBJfchWiauTXiahTXsMdL4qalJhianDFa5OWjZfbTXvgNTV6ayTXfafL5Ga1d4tMIsACAs6CYKu2D7sWEAgwTGIqajKBeOXa77XXTnBODzVTfJmFnXVzV4FDjw93HzULfVMVHXezWhazMXXXVzWE82sBh6HGLABLEAn0fLjZquECDoSYzdGLGnJYf2yYK6OrbQpnKAAT73yFRuFwQIAB+/eK1l4CRgp1Ny1R1NA41WoEVbDPIAgAACd4rX5Ua/I/5f1Ip6B5qta8Vg9AefmYK7obkAQADve+QqxYNWBimOE0BtbWRz2CIK8kFHxCgGGUciaNrptR4Q4/ubMFtQMSCtmUSZNWNqZtWtr5tbFkxSGUSnFbtrVtSHwDteHtx5tUTlteNqi9PtqDgIdrB2I0SbtXtrztQ9rLtfh0yif4rTtRiQsibkSptbdrtJggBJtQIrdtVchAAFR7L2qqguZkh1A2pqo5AG9ZcOqzgc2mvxT+JA0gAEE90bXzweNH99INkcAQABCe6Nr20EdkShiDBAAA37ROqQcUQD0MA2uZ+2uDkglMSJ1GggD4GVjmo8BCtSlBArAV4EAAl3vkKweCguBwZBDLwbkACVoCK/nW3mUxFC6vAjkAYhiYK8XUaEA8BFgUugn4sXUkQBXWgZDlrxsjgCAAbl2+dbvRyAOjk5dZ14DuQgAuGmLrd6FchYckbqxYOuVotP41zdWwwPsnNJRZSmVrdcxqQAGJK3ddNMumJgA/JdEBZOJP1WzAjlW+nTNIcrCZKiF7rMcs7qO+nGVI9RlhatGGzf0sdBAAHB7euqD1vfVEwInLFAgACddtPUvYrphxIJRoH4I8gB63HV5hJ7CAACD29dduUOAIAB4Per18+Gi0gAB49hvXFMevVG69qju6+ADt6sXVeQtyhz5ARVPA8gCAAGB3yFcpANuPABAALA7Y+rdgRkwQAaSEwVO/QLI9h1+OPxFrIy+qB1UQFX1L1NDaUDBupi+tTlN81054NNsVO+qP10mXepY+sP1H81ZRgNIxQINJv1wVyBpV+oupj+vCuENJf1X1PWA++sH11+teuS5MepMmrP18IDepIUAf1/+qZpP1M/1/1MCOJ+u8VwBogN15I/1B+scI6QA244nJf1WpEAAI2SYGmkAoQQADAZLganGZQC8AIQaUDdXgkeJgqRIDHh4FTIJyAHpMqDcCAbsIABesnIV0nCPILSuV43BgdArcRTqVBoDg7BVo+VBr2A4RgahuqChibBsHgLOrZiHeGgoW0oEVyUs4AzrSoNE3I4AT7xUNQlh4sLTUWAR8U4NkDjcm7aSdAyk30NGir0VHIEQoyhgNCmwVxSZUKc1ckEvCbBpMNKOGJwGVnjVVKGyAhYFSsygw4Af4EwVNYFPQeAEAABmTkK5JgYgZvjrAQAC6+yEb+GlLY3DbsRRKIABxcmiN7VHJa6wEAA4fvJGhZwp6QAAU5Jka2GDYj4AIAA0nZCNs+oQJeqvIAgACk9ko0Y0ZLCMCgRWGTb2zdZeAD7Kvw2l2bOzzAB1ocAQAA1OyUbvhvBhoht0bejWwVEqfAA+AX4a1RuQA/weMa+4SwBRiOcZ6jZQ4m8LjQSaOQAQ4QsbbGNUDzoX8AbofUapqnMhEtVaQsyIAARXZCNsIH6OcqvBAW7z8NelNwYtqHIAgAGYd8hVLc6IAVsTBU5/DK546hACVKjmIt8LMDR1JCLrOYhC8NHzxiwGA7noHjUrpXGgM6y2LEIbXDPIH6CsAAsC6MGerEIDCKlGJCLlqwnl6vGSIeMdXyVatHxMgMkh/vfaKKmYhDAkdhAcaaljuwVaj1xTE3uwHOKcAQAD7++QqoaT0AYaZgqwbjBwnrp0qwbrZ92TQIr8aUE4EaSybsaQtSUaRybRTewx/riKaSPnLQQbjKbEDYUlCaRKbZTX/onrkMqHcoAAFahFNHQHsyuGQlNiBXIIlTGo099UAAVGQ6m99KFAW+h4AQACE5Baa7+J0Q/oQabdUPeqJTYtrAAJF7IprrAarIfA+RAblHAES10B1W4d8Ni8HADtRHJvpVaVLiRPIpZNH9B6+4XKiAgAC892M3yjCv6j1JM2xm3/w1cjk3hcwACfxCyaShMqKQAPmaczeAwXoRwA0YQKb3sMWcDFYABH4gLNSOBfgoPA5NxqAgoS2FSNHACvwLZv6Q+RHK8iwEAAyvssmmcy3mbOaiOcgASODk3DmrExSAURy0zGaHt9WEyAAXD2hzdU1RzeZLexmbLyAIAAcPaHN0Ny8cWN38m0N0/plNwFNhN3RuBJIDmB5vJuQWs8EV5rPNYzN8Eaf1PNR5pQZJ5sPNWguq+iysMlnAEAAmftDm7dLF4c/jncH2zRADTiYK5w1NoJGLkKguJwAQAAAZNBa3ZuQQIeIsBAAGT7CFpXmJQyjwMkzwAKaPAtp80wt5DTcGTdXWAaPDwtGFqQoh1Ubq2FvBAaFrItn2AIt7OueB1FsWAgABJ99C21gzRCcAQAB4+whaoaHQl1gIABufYQtNdlhMqerwtIlpXg+wBcgYloEVqHzwA8FrwtSbHWAgAFB9hC1JsMBxX2D8m6oQAAg+9Bb8sLTdogHTc9LQ6BmbjBwGbp0rxbpzRWbjJrLLZAZubsZaabhzd+bvfrbLTklRbg5bmbt9pJbh5anLX/oGbkMrpblrcYjWKB5bg5aVbg3togIrcLLdbcjbvat1bjZaYrbp8egHrcwrUFbYrR94TbqlbDbiWyQrfwx4EYlbQfHbcsrebc/9IrcArQ7coGGTTwLRVbj9dTToLTVbpMvTTqrWpkmaQHd6rS1b2aSHd2ractmaf4quaVvgqrbJag7ig0XbhZa2achxmaTZbxrfZkmrUNbGad1S2rc1aAaRzT2rYUteratbfucTgXbuVaBWM3leYvgqmNesBAAMb70FstIpjCIExAMdR8AEAA+XvQW3CLwAc3gCK1KzfhTnabIWqFNUSC0XBARWQkaICAAIt3yFZ6ACyGnVFYgDbvPOQBkzJgrscm0R3sMdBAAGU7oNufIgeqvAeOUhtBQHi1qMVBtlaHShvsU4AgAAN9gG0tQCSqhwaIBgUSG1C0qBgC0sm3WSejY53GTUJ3WlE53JxX022/U53dxXM24K6K0gm3U2pznggVWlU28LD80rm2J3FBpx3TpXK0ia2i0oW1p3SWn821xas2qW2wGzm2y2sGl82761C0jVIi2tJXmkFZDRgy6x2BchUmi46BCyzBXopbMxjmjgAKDE203gnGJC6UpDk1K8CAASJ2iAHQADAA2B/TGuC0AOGAOfpwBAAHT7ztu6ynAFzRIAHDAsCqDtgvB+tpKTtyntrSAGJhGRxOFti/tu5A05qK5UQAq40doVmPQEAAK3v+25SBSYKCGK2dYCAAaP2c7fuAN1eCBdEcHaaQBnhCwZHaIAuGB+QV2BAAJJk/tpARHAHvQntrNRnAEAArvvO2ge7RUie4d20e5ea8fGN3Zu73rQtQD24O192oZmT2se3ClaOaj26e2vm2e1uwNKrRAJ2qD227yM6a9BXIe8bD2Le1N6He1UqhAD/NKe0X3Ve5jA4xCL3Ke473G5VQIG+4d28+0n3Kt6Cna+133S+6vK1kQP2s+3T3e+4FXRJxX23u1P2q+7/Kt+3AOx/4FiQB0d2pQYaDZlVRAJ/z/wasBLKuJm920qw0/MUWcq8gCAAPZ3UHZiBevleBj8lPa2CF5MiHaoh1gIAA+/d7t99UAAX2S920XCcwaID42O6HKgTmBzIJh0EENOqhMZPhRgvhxeGHrCAAQn3nbSUM/UWKBzsJ7bhHe7qogGI7g7VfKZVZr4WgFM4j4fKNMMjwgwvoAAXPaEdIsFrGHRugogAFqdoR0VQsUCqhQvUgWnoDGOnTCApEACYADZLIFU4hbItwBCOs7hhAVNk5zEKGOOpcDOOrNlKMCKHuOux1Zs6KEYoWx2eO/Nn3peKG+O4J0bw4IDhOv5GUAaJ0DS2QB0iIJ3McpRiiAZFBJOqaWqAQJ1f5fK0PsdQBxO7gSiAO0DgFKyFzmpSay0ZC3Nm4O1MadYCAAav3nbUxpdUIKBPbRdBhvIAB6Hfqd+4CUYgAFAdjp0JwXMztO5p28QOc3UoQABPxPU7E/nH4vwC7ETyGHTZaEcoP7jPpZneQQXYSfVmndHSsCruCP7oxwNoBCsEAIABqPfqdOzHS0Bzs9tdxEAARITO2xkyDaU2KXO3egLQQAC9e7c6EkBh8VZVEAsrIwBuQEUMXYus4PnS5MBAFBryACNpVILVZ1gIABRfcudIRMSdG5LSdTNP1A6mXNA4VyKdFkJJoj6CRd2YFfi6wChd2YEAIDJrhdFkJxdg4BRd6wARd+Lpveg4Axd4IBhdRtRdi+FBEcISQ4AgAFEdy50w4IZLSW5l3Mzf0w/Oyv5QOQADtu8y64kH0iQmYABSvcudT2CMKntpKE4JXWAgAEr90V1Su8ECyuiV09CcZDrAQABU+87aesKExRhA4LSkvAAu5jHysKOQA1Bp7aQmuaC8ABc6TXf3ojzn3hbLAgARXZa7JTIABovY1d8ySUYEYMtdpkkAAUXsuuwcC/whADOuy10cIdGBhw3wXggAIWQIQADlZCxBakEG7iaPrKTRXgAI3S67hoFY1yAJKATXbDpogB7bg7YrrLoItgnaG5QdjDm6NdXRA30BP5SKclL4AEW6XIf5BS3QW6c3mxMFoNW7c3XW72zcN5AAAs7Grpqo99XgATZBNdKpkKAYaBp41zsze/bvdalKyHAcJBDALsR0CLkLcwI0Q4A/UJNdvKp6AgABndjV3yy0arA/QABxexu7pINXhAAGP7e7qqBHKt61iwEAAnvvHun9hh+cgCAAAh2NXQFRyAAwYc3TOYn3Q+7WoJmxAAF37ztpPlR6udtwoA28iwEAA7Pv/upcB8tOmo9AQADCu/+7VEAHxQmMKw8Ehr8ogIABg3f/dwAloMQcTQ924TfAYYHjtipkQ9K8GlQMIFl4ErItiivDwSWg3gAgACId/93PW2IKRxZ233WwABfO87bxuNMZmhJ9L2hRwBaNZ7a2PY/AOPaMwQbMdAePcHb80HgB0ADY780H7qOAIABUndY9MOBJgvWPox5ADJC8nuaAauzSxemJ8gZIVggODAU9bFHaxiItRkhGKMUenvU9Sno6x0aiL2p4jhdUnsU9hnq09LHRM9xsjM99ns09BIueEnUmc9apFc9Bnvc9TWOAeOnrSddnr89jWKZES7SfEYEjU9bnrC9MvVbuDEkCdIXo09sXoV6D6zIkCLqS9FnvUxp7QS9RTsy9DnoJF0pB09tnv09yXumxC0kmkyEgq6vHtK9WXuxFr7QS9wXtq9BXoC9X7TReikmi9oXvK90WNv2p4jy9zXv89NgPgAOnoy9A3pS9xCiE2/Ek69ZXtGxrShWeZEia95npa9pWIqk5WKu6vnpm9fWNh683r6903rq9pIpU9Pnvy9g3q1UjUkGx1XtE9Y3u699xUfujXr29y3tLEt3rIkJXqW9J3pmxBHXS9antD0m3uU9HAEAAtjtfe8sw/eyz3kAP726eqT3fe/b0BemwrU9Z8RM9Mz2Q+h70dtHrpw+xL0w4RH1veocTWKWCRO9BH1A+qH0MYkFSo+/r2HKLr2zepRhg+xb0Y+8b31SbTqo+xJ0Q+/H1I+wJRqdVH0ve6n3Xe5kSmdVH2je0n3A+7L2qyYn2A+mL3Xe6Uhg+9n1M+zH11Ff2T0+4X1k+rb0edPJSo+qn2S+mn1+dJX3jiXH2M+kX2zei0SfdIX01evn0E+h9hg+3n0c+2b0ZY13o4+8H3o+1X3XekHptKZX1y+/n16Y4MTdKNn3O+430Ayf3oG+y71G+5n34dMH0k+830K+zrHVdWX2G+kP2uYvMSGqJ32R+u32ze3Hr1ya30S+nX2h+7Prh+zX38MR4BaAdACVcezKn2nP3YACAC2QxYAg6nP1aAGYCFccED26x4ClcCADVccECpNOv2VcEv2mNIgAt+iAC9cVxod+/LgsAKYBOcTgC2NEACPASrhgAdABWZLvqj+rQCYAJTJm60f3dcKYBWZOJqj+tICVcOSCVNJLlyo8ECpxXKiPAR9AZpNAD7+zMggwVOI6BCRGBYUSCAAFDIO/QFheoA26XWeMwfUUf67/ekAumMhab/Uf6m0KnFhWHCQ3QJPxyAIAAkPY79NILn4uZmADX/rvoxSPshEtnvMWZEAAIbsgBjkAz0ODSIBr/3hPC0WYaGHRMgNlkP+GKIS2aYxW4MyDShfVBIB6Yzsg8RqFxMgOPwQYR3QzOJP+OEj8vDgCugkf2ZROAbkAFgN/IzgDXjI/1JAezI8Bkf2qsGqgnI3MyAAYT2O/Q1croOXFnUSP7dUCXEO/aw49WYGdFXtLZFTIGxiKKRQxDX3EFA3JB5A0f6lkJ9g47d/75dLhFAADDkRAGxAUKFMDsEEsDNuGsDyKFsDLiWsD+oEcDeCmsD5oFcD77GsDdoE4e3DwsDSTAYeMHHIeM+j4enNFoedIlCDkBiYe/geIerDwlo7DxiDVDzgZPD0SDDD2+0Aj1SDaPj/05D0Y45dRVyo8VnlIAFSsKiJpYK9XBAVFB0CGghYASyCNSckCoo+NjqwKiK8gW6WtdFLoQARJTQA2IDda5AEAAPzsWBtMYflA4BXgQADPO/0Gi1ngBzA50HhDLE5zcNYGIg9MGd1ssN7A2MH/HDJhnAysGZg67h3AxsHFg14G1SJcYWXcJYbaIABPnf6DVYH4hptHWAagimD5wd/Ix8DWR8AD6DUwZ5ANIPriwrCj59pHWAgAG79/oMkUG9CAAWTILA3lArGoNpAAND7QIYrQo1R9OiwEAAC/tAhtewEMTexFB5UzgfNDRIh7EC6oE9GdB4UCpujgDZukeLUM3WBFASvjkAfEM4h8uE+QFcE2B8kNJWhABUhhwM0hmQhUhlwOMhkXJUhjwOshuuRUhu0CegKjVzIQADju0QAhHpCkiQg9ZSQhSEqQkKHC6SI8pQ5XSNwWnTdSKXTJHrBAlQ0ox86WgAlQ9KRlHrKGJHvFBy6RqHpQ1XTtHjqGNbYWhRHoxwhHgzgR/gMkFFdPFISJFQ0+BSbrUusBAABj7Uof+4U2FHKgbDLB6wEAAaPtShnoQa0QAAFZFKGvwNRgJ9eKAWIJ6Awww8ZAvOm6QADWab7IAAsYilDgvlogKvCKA8xEAAwDuphhOBI5BaCAAPr3cw4pSyzsWGWztATxKSqHOKZGTKw8igFKeWHezhih6w1OcfBKOcywy2HlKWqR6w56BW4vDCDQ8IhqzHFRAAJL7xYehM1fSiAILQTDPavIAgAGkdqUMarHyAzhqsMarJRhLhusM9qmQhLh/UA9h2aHwAJcPmgHcPcCJcM8hxoawshABzhg0O+S5IWzh5cPos1cMhQHsPos6Uhbh+cPos+KD7h18M4sjTDHh+cORsW+VwAQAAMDFKHXSD5RHQzRofQ+CBAAEj7wEY9gnoaUWimgGNiwF9+GoeuORYY1DHFCuQgAEo9oUMYR4+3wAWlXoR9OggabCPoRpQbFMQABJOzhGnLHUcoGJUcqIxBQaI8fqVyfRGy4IxHOjg+G+jkeS1vgeSWI6SYA4IPdmjvwxpyaxH5yT1a5jrxGjyTHQ6I+hHqI/OSy5eMcajsJG+I0ZSJrcxGZIwxG5I15cujhJGpjvuTBjjpHNIwXczyepGRIxeTZjl2HOI1OTAssuT4vDhGgrESGCmpTZLYrcbS6I8aNQ9bZgHoABoHaFD9lQ4AgAD29nyNkMS00FNJaaAADjJAo3iS8ADoUow9fsooz5HKEjDRoPe5GXGYRk5nuC9pmMsxmVZsxko1i8Mo2a6NmPFGsXk6K4AIAAqskKjePjmyZ9V5Qy2Ryj7LKJA/gvKjrNQBdHAEAAdTuNR1HWg5InjSpWqPzaHoDJPIF6BPOEn9RuekvaEMWZPHqMZi4xnDR5F4ZipmHjRhMMjR2S7swkp6NRjMU8w+aMDRhJ6+M6aPxPAHS7gktAbRuelE5KIA3THqM34IAWgEPACAAHGpGowS6P8FalHEXABAAL+kt0bJdd+EujcAEAA36SNRigW8oLbI9Rxi3vRrIhwAQADVpI1GTQdoKOAHaqNQz1BpjHxbmo2KBAAAK7QoYot8hBsWNvkAANPsoxo00/IPa2cAQAAJ+9jH+EiDAsYzDGKgMQ4ogLdayY5M0v2OsBAAO772Mb+QgAEg9xmNd6lmMahyTLkAQABcO0KHJMiBoeYxzGjlBT0DlL7Mh3F7MRY3HM6ZPcsUEBLGyrh4oWPDLGtlL7NNep4JFY0HM5Y4Ox6JOLGlY5LHw+gnM1Y9XMm5mtyxXGnMRYzXNxtrco85gmGlY+bH5tgrIYkGbGjY7jcQJFbHHY4XN7esqdTYzbGnY9wJZlA7GpXKGgh3NGhhXAvMCthuIu5iHGMWIt0nxBHGA41HG/xPZ1g43HH6iTUpJ5oLHR5vHGCunxJY43vM86uvMLQ1K595ofN7yMK4McX/kQcSXHC40DjMWBfNK47nHYcewwj5ogYow1XGG41NZUcXXHH5m3HAhN/NO45+SjcAXHLnCTifICAs+43+Tz0lAsx45BS/xBLgp47Tj+cOgYW40PGkAQvGlPkvHHnFws/gIYR149gsFCAk8fICQsp45vHj2FQsj43vGlozwtm47zHLnMfGRcuwsz44QtZLhphxCGvHr4485+cdCGUSIPH34xzjctFCgecVPGP43mdBcdAYd4x2hf46Li8AOotAExAmpcVAnF42/Hc3rAm8zgrjyxN/Hc3srijSOgnfFmri7FoAmTccA8dcfgnALobjiE4EdxSLJ8wE94LTNgEsx4w7jQlnQmClgysOGKAYwE/QnY1hwxhPmwmmE0Nd0lownfcZEsgbrkt+E0IxOyYEJiltgmWlvRw6E3HjOaDUsZE5Hj48aAnEE7TNFE+4J8OAon08T4ISOJons8YEJKOHQndlpycRBJImjE8bsfILMtDE0ctGBIqclGMssrE5/wBBMbtpSNstTE9YnOBNzCxlggn04wCt3E04nO1hpheBIYnMtiWgHlgcohkvltLY2EmtlBEn5ZCx4aUuEnAVuPN7OtEm3lrypo5gkmYk0kn8OrMpUk7itwVlkSDoxtpwk/njIntLJ3tCUmMVrJdoJFGhikzEnSk+k8ZfXUnEVnitcno77mk/kmUxVnGc0B0n3wKytyADrofE5StBVoKQjdHSIDlKytj2Ebo6w1spJk2LhbdCom5k4OwndIsnuVt6T4AG7pFk4qs4vGPGzVgZUo8tYZWE1smXDCuHgjMomhk3smNVu4YJPmAnLk7uG/DLsmjVmOxLVtgnrVj0AVrGAnnVr1ZePp8m7VlGdXVq8m/kxLRPVoCn3VoccfVqCnQfP6tsEyXiLHGPHy8ZzRI1vCnq8cLTIDFo5kUw3jUU/AZ9HBinW8VinDjlmsYUyimZrpY4MU5WtjEzsosnAcoK1mk5FTlEnknNSm+1krtVZFSmtlDSmG1p2tJJEk4VE+ymO1gJH/eqyne1iOsBYcMpBU3Wsp1j0BRnGAm51jYJF1simN1pYy11kSmFUx94t1sqndg6aA91uqmD1pKm0E9Km71mLGGPIynn1lEmjU2ym71nEmA5mamL1kM67Oj3drU++tbU5rG/lg6m21s+sLZIU5XU/OQV8S+4Z8dbGUNtviF8TnN5xH6nqU1Pj5YwHNYNmGmfUyrHoEFGm2U+GnnU9HN40wGnRFqvjuk3GmeU6JthvGK5efNSns07pz+esJt80xRsuNpani02ymC07GmFfP6nmNqWmBNukmK0zRt602Js9YxJseU6ptVUvCnv8QVt78d2n/8WKQlNlwmO0wOmP8TcmR02jqDNt4na08ZtJ03XJACWPHLNlWlF05gSwCSum8CVgSfIF5sjk0MmUCTWGEAIFt10+QSoCWFtx07unV0w+w4tkenGtiemCCa/Hz01wS/kNltsEzQTKCT8mVE6+mxSBVsX02wSZCHVtr025tOti1tp06e5f02Oxutj+nVCQGwA8iumoMztso8pNtYM1oToM8exE8khnRtihmxcKnl0MyttMM4OxM8jhnZtnhnjtnqmP0x4TzXuQAQ3mAmgdsf1TCQVtzCYRmPUHRmlGJ9tGM1YSZCADs2M54TvXiBmdXORmvXrDtSM7um/CUFlbI8JnoiWjtxMjumZ0xBAJM1jtZMounwiS5kz0zJnbMsFdEiYpm5M4rgKdounqdhwAlctRnedj5BGdrpnp2U2s2dqZmW2V2tBcpZmU4/ztbM9wJhdtgmjdnPqdlHyUDlC5n6NkWn3M1spPM+PaRepSUwE35nY0z5mpdgKUxdsbsIuoFmVE8Fmx2LMpQs+fsLdjHtrdmPGRibHtndu+mhk2lmPtVGdvdqlmliX7scsxLQg9vlnNiYVnNtaaBI9qVmks10TInfMShMzJnHicyTRXl24riVlnPicCTziVoouxHXsDlE1mDieWm2s41mOs2cS8SbfqXY0Nm+syNnvibSSk076hes1sp+s2SSck2CSVEznsqSaSTziUHHl9lNnGSdSTms78TLY/CTds0iSv9gdm0ScA8FZMdnFsySTcSSyTxs4nGdszdm9s5tmxs42nrs3vsXs3dnZsytn2Se1mPLJKTuSdEBBlWAmoDoDmiDpzQhSaVm5SVKSxSecnhswDnlSRDn0krgdoc3ySVSVNYSDmjnwc5QdAhNQdSsxGTGDgTnYyTYIwycTmkyZix+DuTmJDu4JRDtTmQyTkkpDvTn0yT0B5DmPGdDkO5sWgcorDsGnNDmAmec5dnrxFzmtlALmuUzYcZMwLmos8LnVDkS1fs+Lnuc9mS6Wuzm6yfasCycrmAjgtTPDtgmqyR94/DtrmVc4ccBWurn5Wt3jwjmPGmyW/1zc2OT7Vgkcrc2InLGe/07c72T0kj/0nc4OSABpQmVEwUdvtCAMx42uTGjs0cxMzJn/czOS0diuS/c4ZHpjleSIiV0cI82ZGlI5KJBI1fGhkyHmKjsFcJjnHnjyaHnwrnMcx4wscegEjMwEysdFjplmZMxscS88OmhkzscS88nmy8wcdTQMcdsEzDMaEOccm8xW9UPL8cDlLW9oTrznXjionu8928m1vbHO81soB89WmR85Ccu3o2my3l3m23m2nwTv3ngKV3ZMAUXml87fi6TtJmu82vnj2ISc889vn/ySpmt8x+Tx49Up4AXvnj89PHYLKycx41zCc07zCZM7fnC0/OIqYQcpH8+WmX81so383amOYSomv83NmP81KcaYYqcPU/fmDlBach3PacwC6Mzus90ywE50zBc1ghIC1sp4C2LmkCy6doCx7GQkGgWdThgX589gXYKTRTCKW3xOCmPGyKWmcbBKGdSCwRTsKcAniKZXmZM2QXczql9iKbXmwC9QXyKZ89iKZ7mhk4wXIzvRSGs2wXCC82GWKeJSqC0IXqwzYIeKWIWmKdJTMWO2dpCyJTJzsOd3BH2cFC1JSlC0pTDjqOc1C12dZC4EIZzqQWHOTZzChIYX1OeZyo8vpzTC9pzHOcZz4c2AWjC8ecLOYfnkCw4X+U3ZzuCwwXXCzza7OfenPC6BczuYYXPuTnMdKYEWVI0ow/zqEWXuc4WXTrdzB2OBdIi1XSYLmPHKRRwBmRWAmGRS5TS8wcoMizhc7C1soci3+IiLskWAqYSygqR4XsiyUWyWRpgaLskWgxX8ADo3xdsi3UWYC6GKhk36KecldnGi/kXmi9/nWizJn2i+9mui6Jcei/Pm8xf0XDLuXLki/lSCtplSpixVTdLjlS8i6Jdpi9KQTLnMWEqQsXqlBZd1i2lTNi8XKBC/kXaqY5dki6/KrMs/KTi8NTpMu1SLi4/LuqV/Kbi8taBqQ8WwaYAqx4x8rUPJw4DlB8qWi/18viz8qNY0Pni5p8WtlN8Xei78WQS/8WQHf71wS/FdISxA68ek8qhk/NTUNW8XsNeNc/8p1dN8yCW0S71cli2lccSxkcRrqiWNqTjTJrsSXdqYEJ9qW8WRvpTJ/NUiWpvlEndrl8XfNSymmSyCWpvmLm6SzJnPNY2muS8yXItSAW+SyCXWTWKB1TWAn3WoqbZQAjS3i4KbnEN9cZS5Kb3BP9cFS7KaprCDcVS4qaaEBDcZS/eaxY1jcvi9eao8iGn9S8KX7zeWmnzQaWzS70WLS6aWXzXNmbS2TcrS/PmHS2lchboZa3i65agnNZaPS/panLZixubj6WTLX6X0kgLdAy45a+blNZRbmGXPLYEJJbmGXwrVmQyreKXArdlabBPFb4y2lbaQwrc8S7rAUy+bd3BCbcMy6mWprJbciy/mXbbgcX4rv1bnbh6XxrVZlqabWX5rRESfbo2W5bQHdWy7AaQ7h2WRqRHc3i3zTebX2X1bU1TRaYOXpaSADGbaOX8U0plZaZOXlrYrTZyyrbKy/3ch7WLHG7kAYl7UaX7lmuWR4RuXAS23cVE3PbrS9uWO7kPbeS8eXlyxXdfYwnNzy+fdf7R/aFoGK5F7uuXwHXtKeut/bny3eWL7XbH97geX37V+Xq00+Wdyy+Xp84BWt7p+Xn7fPn3yyPDA6W/dj6mPGlnQUT/afBWYK6UXgHiHTkK+s6ihX/dWC9BXMK2bD4oPA8MKwnSIRSg94K5uXxkwHyakweXkk02GA+RF1qK79mVE74GyHmPHIgzQ8si1so2K1GcmHqxWAg3EHuHtEWBIFxXTQDw9eK7EGvCVEABHmPHU6RwBzQ2AmlQz5Bs6dJXDQ8/j5HspW5QzIRlHupXdQ8XTyi4+4VK1o8ly1gJm6Qc9pKx3TBSG3SzKz3S9nuXClGLY8rK5PSHHjIRnHg5WzcFPSRch49XK/XSnK2OxfHtJXFo7s6dlBtHH3AFWIxbD7+oyFWZowk8akytGhk5tGL40r6IqwU8oq7Jcos8FXkq7tH8ViAX0q9C9vZFnRtldEBAAKu7KidCk+Vdc8NgkKrKocfcJUjKrDt2cElVZmT6zwJktVbBTEtEqr24eqrzVZtQdVamslVYPDnVbyr3VbBTNCEqrPIYGr83kX5zTyDjaUfGrjT2QZ5FdxeMmaQZ/9MGzM1aarEz0mrrjK7WicbWruVYmrsDPezu1acZc1ZWrcWb4kR1YL1fdNMr2CeOegjNvI0ld+egpGueWJfWrcjPueQjJzLXTGUZCjMErTjO+r1Sm+eD1ckZ8uI+rvhbfUtjO8ZV4AOjF1eGY2jKMZ5SZhrENZReq1ZKrSNYsZ21dmeqNa8ZyNcwLi1fBr2NfRr2VYurpL23AFLz7jK8HomFIHIAgAC0dxBOx2c/hVoOZABomdMgOaLSAAJR3r47h8/MGtiQhRtjbvFtiu7BHol47h99sQR8xQER9B46YxyAAGCOY6Mrl/rLWwTNEAPRvmg5AdDq0NMrXYQCmh9YE9gStEAIZHLlCOY+cw/gIAB8Pd5jRtZA0ptY1DUOnIAnefHyNtaFDotbP00QCOxCYdFrh2Idr/E1grIAEAAsOQe1hQlmw4wDe1lUOYUO8N4AIOt1hkOs4snUBB17cOR11Cs+AIOsHhuOtVFsOtdhuiGHOcIgLQFvUah/WLRAaR0n0VTRoAJ6Cu2x1FRAQADee0QAnoIDyegBXW0AF8YalZf4vjBjwogGcgQAE3X7LmKBW6+3XJHV3XAsC3BX+aXRpanXW+67TACjYABsnaIATVErVtMEejCABJqw9a5FiwE3i+Ng+t5AEAALTuT12ExUAYev4YrgB0iH7LHsVJ2b1xPOZO/UAH1kXJ5OnesFOtUjiJUKav1EACAAejJJ6zDgXJtEA05l6Ac4vAA5lWgAD4nAAPKixB5ATGqzwIqZq7TDQYBiABQG3BpwGxng24F5Uf67MaLID0AQUvA2ivhAANkgWQlwKcA1bOCAr4kQACyEQlEHFA44Jvg2Bwbz8zTMQ2yXtEByXng2yXs3pKXjPoSa8uA+TjBxKXoxw1+AUF4AIAASnZAAAAF8gAA=='; diff --git a/src/emojis.json b/src/emojis.json deleted file mode 100644 index 0464a0e4bb560..0000000000000 --- a/src/emojis.json +++ /dev/null @@ -1 +0,0 @@ -{"100":"đŸ’¯","1234":"đŸ”ĸ","+1":"👍","+1_tone1":"👍đŸģ","+1_tone2":"👍đŸŧ","+1_tone3":"👍đŸŊ","+1_tone4":"👍🏾","+1_tone5":"👍đŸŋ","-1":"👎","-1_tone1":"👎đŸģ","-1_tone2":"👎đŸŧ","-1_tone3":"👎đŸŊ","-1_tone4":"👎🏾","-1_tone5":"👎đŸŋ","1st":"đŸĨ‡","1st_place_medal":"đŸĨ‡","2nd":"đŸĨˆ","2nd_place_medal":"đŸĨˆ","3rd":"đŸĨ‰","3rd_place_medal":"đŸĨ‰","8ball":"🎱","a":"🅰","a_blood":"🅰","ab":"🆎","ab_blood":"🆎","abacus":"🧮","abc":"🔤","abcd":"🔡","accept":"🉑","accordion":"đŸĒ—","adhesive_bandag":"🩹","adhesive_bandage":"🩹","admission_tickets":"🎟","adult":"🧑","adult_tone1":"🧑đŸģ","adult_tone2":"🧑đŸŧ","adult_tone3":"🧑đŸŊ","adult_tone4":"🧑🏾","adult_tone5":"🧑đŸŋ","aerial_tramway":"🚡","afghanistan":"đŸ‡ĻđŸ‡Ģ","airplane":"✈","airplane_arriving":"đŸ›Ŧ","airplane_departure":"đŸ›Ģ","aland_islands":"đŸ‡ĻđŸ‡Ŋ","alarm_clock":"⏰","albania":"đŸ‡Ļ🇱","alembi":"âš—ī¸","alembic":"⚗","algeria":"🇩đŸ‡ŋ","alie":"đŸ‘Ŋī¸","alien":"đŸ‘Ŋ","alien_monster":"👾","all_good":"🙆","all_good_tone1":"🙆đŸģ","all_good_tone2":"🙆đŸŧ","all_good_tone3":"🙆đŸŊ","all_good_tone4":"🙆🏾","all_good_tone5":"🙆đŸŋ","ambulanc":"đŸš‘ī¸","ambulance":"🚑","american_samoa":"đŸ‡Ļ🇸","amphora":"đŸē","anatomical_heart":"đŸĢ€","anchor":"⚓","andorra":"đŸ‡Ļ🇩","android":"📱","angel":"đŸ‘ŧ","angel_tone1":"đŸ‘ŧđŸģ","angel_tone2":"đŸ‘ŧđŸŧ","angel_tone3":"đŸ‘ŧđŸŊ","angel_tone4":"đŸ‘ŧ🏾","angel_tone5":"đŸ‘ŧđŸŋ","anger":"đŸ’ĸ","angola":"đŸ‡Ļ🇴","angry":"😠","angry_face":"😠","angry_imp":"đŸ‘ŋ","anguilla":"đŸ‡Ļ🇮","anguished":"😧","anguished_face":"😧","ant":"🐜","antarctica":"đŸ‡ĻđŸ‡ļ","antenna_bars":"đŸ“ļ","antigua_barbuda":"đŸ‡ĻđŸ‡Ŧ","anxious":"😰","anxious_face":"😰","apple":"🍎","aquarius":"♒","ar":"🎨","argentina":"đŸ‡Ļ🇷","aries":"♈","armenia":"đŸ‡Ļ🇲","arrow_backward":"◀","arrow_double_down":"âŦ","arrow_double_up":"âĢ","arrow_dow":"âŦ‡ī¸","arrow_down":"âŦ‡","arrow_down_small":"đŸ”Ŋ","arrow_forward":"â–ļ","arrow_heading_down":"â¤ĩ","arrow_heading_up":"⤴","arrow_left":"âŦ…","arrow_left_hook":"↩","arrow_lower_left":"↙","arrow_lower_right":"↘","arrow_right":"➡","arrow_right_hook":"â†Ē","arrow_u":"âŦ†ī¸","arrow_up":"âŦ†","arrow_up_down":"↕","arrow_up_small":"đŸ”ŧ","arrow_upper_left":"↖","arrow_upper_right":"↗","arrows_clockwise":"🔃","arrows_counterclockwise":"🔄","art":"🎨","articulated_lorry":"🚛","artificial_satellite":"🛰","artist":"🧑‍🎨","artist_tone1":"🧑đŸģ‍🎨","artist_tone2":"🧑đŸŧ‍🎨","artist_tone3":"🧑đŸŊ‍🎨","artist_tone4":"🧑🏾‍🎨","artist_tone5":"🧑đŸŋ‍🎨","aruba":"đŸ‡ĻđŸ‡ŧ","ascension_island":"đŸ‡Ļ🇨","asterisk":"*ī¸âƒŖ","astonished":"😲","astonished_face":"😲","astronaut":"🧑‍🚀","astronaut_tone1":"🧑đŸģ‍🚀","astronaut_tone2":"🧑đŸŧ‍🚀","astronaut_tone3":"🧑đŸŊ‍🚀","astronaut_tone4":"🧑🏾‍🚀","astronaut_tone5":"🧑đŸŋ‍🚀","athletic_shoe":"👟","atm":"🏧","atom":"⚛","atom_symbol":"⚛","australia":"đŸ‡ĻđŸ‡ē","austria":"đŸ‡Ļ🇹","auto_rickshaw":"đŸ›ē","avocado":"đŸĨ‘","axe":"đŸĒ“","azerbaijan":"đŸ‡ĻđŸ‡ŋ","b":"🅱","b_blood":"🅱","baby":"đŸ‘ļ","baby_bottle":"đŸŧ","baby_chick":"🐤","baby_symbol":"đŸšŧ","baby_tone1":"đŸ‘ļđŸģ","baby_tone2":"đŸ‘ļđŸŧ","baby_tone3":"đŸ‘ļđŸŊ","baby_tone4":"đŸ‘ļ🏾","baby_tone5":"đŸ‘ļđŸŋ","back":"🔙","backpack":"🎒","bacon":"đŸĨ“","badger":"đŸĻĄ","badminton":"🏸","bagel":"đŸĨ¯","baggage_claim":"🛄","baguette_bread":"đŸĨ–","bahamas":"🇧🇸","bahrain":"🇧🇭","balance_scale":"⚖","bald":"🧑‍đŸĻ˛","bald_man":"👨‍đŸĻ˛","bald_tone1":"🧑đŸģ‍đŸĻ˛","bald_tone2":"🧑đŸŧ‍đŸĻ˛","bald_tone3":"🧑đŸŊ‍đŸĻ˛","bald_tone4":"🧑🏾‍đŸĻ˛","bald_tone5":"🧑đŸŋ‍đŸĻ˛","bald_woman":"👩‍đŸĻ˛","ballet_shoes":"🩰","balloon":"🎈","ballot_box":"đŸ—ŗ","ballot_box_with_check":"☑","bamboo":"🎍","banana":"🍌","bandaid":"🩹","bangbang":"â€ŧ","bangladesh":"🇧🇩","banjo":"đŸĒ•","bank":"đŸĻ","bar_chart":"📊","barbados":"🇧🇧","barber":"💈","barber_pole":"💈","baseball":"⚾","basket":"đŸ§ē","basketball":"🏀","basketball_man":"â›šī¸â€â™‚ī¸","basketball_woman":"â›šī¸â€â™€ī¸","bat":"đŸĻ‡","bath":"🛀","bath_tone1":"🛀đŸģ","bath_tone2":"🛀đŸŧ","bath_tone3":"🛀đŸŊ","bath_tone4":"🛀🏾","bath_tone5":"🛀đŸŋ","bathroom":"đŸšģ","bathtub":"🛁","battery":"🔋","beach":"🏖","beach_umbrella":"🏖","beach_with_umbrella":"🏖","beaming_face":"😁","beans":"đŸĢ˜","bear":"đŸģ","bear_face":"đŸģ","bearded_person":"🧔","beating_heart":"💓","beaver":"đŸĻĢ","bed":"🛏","bee":"🐝","beer":"đŸē","beers":"đŸģ","beetle":"đŸĒ˛","beginner":"🔰","belarus":"🇧🇾","belgium":"🇧đŸ‡Ē","belize":"🇧đŸ‡ŋ","bell":"🔔","bell_pepper":"đŸĢ‘","bellhop":"🛎","bellhop_bell":"🛎","benin":"đŸ‡§đŸ‡¯","bent":"🍱","bento":"🍱","bento_box":"🍱","bermuda":"🇧🇲","beverage_box":"🧃","bhutan":"🇧🇹","bicycle":"🚲","bicyclist":"🚴","bicyclist_tone1":"🚴đŸģ","bicyclist_tone2":"🚴đŸŧ","bicyclist_tone3":"🚴đŸŊ","bicyclist_tone4":"🚴🏾","bicyclist_tone5":"🚴đŸŋ","bike":"🚲","biking":"🚴","biking_man":"đŸš´â€â™‚ī¸","biking_tone1":"🚴đŸģ","biking_tone2":"🚴đŸŧ","biking_tone3":"🚴đŸŊ","biking_tone4":"🚴🏾","biking_tone5":"🚴đŸŋ","biking_woman":"đŸš´â€â™€ī¸","bikini":"👙","billed_cap":"đŸ§ĸ","billiards":"🎱","biohazard":"â˜Ŗ","bird":"đŸĻ","bird_face":"đŸĻ","birthday":"🎂","birthday_cake":"🎂","bison":"đŸĻŦ","biting_lip":"đŸĢĻ","black_cat":"🐈‍âŦ›","black_circle":"âšĢ","black_flag":"🏴","black_heart":"🖤","black_joker":"🃏","black_large_square":"âŦ›","black_medium_small_square":"◾","black_medium_square":"â—ŧ","black_nib":"✒","black_small_square":"â–Ē","black_square_button":"🔲","blond_haired":"👱","blond_haired_man":"đŸ‘ąâ€â™‚ī¸","blond_haired_person":"👱","blond_haired_tone1":"👱đŸģ","blond_haired_tone2":"👱đŸŧ","blond_haired_tone3":"👱đŸŊ","blond_haired_tone4":"👱🏾","blond_haired_tone5":"👱đŸŋ","blond_haired_woman":"đŸ‘ąâ€â™€ī¸","blonde_woman":"đŸ‘ąâ€â™€ī¸","blossom":"đŸŒŧ","blowfish":"🐡","blowing_a_kiss":"😘","blue_book":"📘","blue_car":"🚙","blue_circle":"đŸ”ĩ","blue_heart":"💙","blue_square":"đŸŸĻ","blueberries":"đŸĢ","blush":"😊","boar":"🐗","boat":"â›ĩ","boba_drink":"🧋","bolivia":"🇧🇴","bomb":"đŸ’Ŗ","bone":"đŸĻ´","boo":"đŸ’Ĩ","book":"📖","bookmar":"🔖","bookmark":"🔖","bookmark_tabs":"📑","books":"📚","boom":"đŸ’Ĩ","boomerang":"đŸĒƒ","boot":"đŸ‘ĸ","bosnia_herzegovina":"🇧đŸ‡Ļ","botswana":"🇧đŸ‡ŧ","bouncing_ball_man":"â›šī¸â€â™‚ī¸","bouncing_ball_person":"⛹","bouncing_ball_woman":"â›šī¸â€â™€ī¸","bouquet":"💐","bouvet_island":"🇧đŸ‡ģ","bow":"🙇","bow_and_arrow":"🏹","bow_tone1":"🙇đŸģ","bow_tone2":"🙇đŸŧ","bow_tone3":"🙇đŸŊ","bow_tone4":"🙇🏾","bow_tone5":"🙇đŸŋ","bowing_man":"đŸ™‡â€â™‚ī¸","bowing_woman":"đŸ™‡â€â™€ī¸","bowl_with_spoon":"đŸĨŖ","bowling":"đŸŽŗ","boxing_glove":"đŸĨŠ","boy":"đŸ‘Ļ","boy_tone1":"đŸ‘ĻđŸģ","boy_tone2":"đŸ‘ĻđŸŧ","boy_tone3":"đŸ‘ĻđŸŊ","boy_tone4":"đŸ‘Ļ🏾","boy_tone5":"đŸ‘ĻđŸŋ","brain":"🧠","brazil":"🇧🇷","bread":"🍞","breast_feeding":"🤱","breast_feeding_tone1":"🤱đŸģ","breast_feeding_tone2":"🤱đŸŧ","breast_feeding_tone3":"🤱đŸŊ","breast_feeding_tone4":"🤱🏾","breast_feeding_tone5":"🤱đŸŋ","brick":"🧱","bricks":"🧱","bride_with_veil":"đŸ‘°â€â™€ī¸","bridge_at_night":"🌉","briefcase":"đŸ’ŧ","briefs":"🩲","bright_button":"🔆","british_indian_ocean_territory":"🇮🇴","british_virgin_islands":"đŸ‡ģđŸ‡Ŧ","broccoli":"đŸĨĻ","broken_heart":"💔","broom":"🧹","brown_circle":"🟤","brown_heart":"🤎","brown_square":"đŸŸĢ","brunei":"đŸ‡§đŸ‡ŗ","bu":"🐛","bubble_tea":"🧋","bubbles":"đŸĢ§","bucket":"đŸĒŖ","bug":"🐛","building_constructio":"đŸ—ī¸","building_construction":"🏗","bul":"💡","bulb":"💡","bulgaria":"🇧đŸ‡Ŧ","bullettrain_front":"🚅","bullettrain_side":"🚄","bullseye":"đŸŽ¯","burkina_faso":"🇧đŸ‡Ģ","burma":"🇲🇲","burrito":"đŸŒ¯","burundi":"🇧🇮","bus":"🚌","business_suit_levitating":"🕴","busstop":"🚏","bust_in_silhouette":"👤","busts_in_silhouett":"đŸ‘Ĩ","busts_in_silhouette":"đŸ‘Ĩ","butter":"🧈","butterfly":"đŸĻ‹","cactus":"đŸŒĩ","cake":"🍰","calendar":"📆","calendar_spiral":"🗓","call_me_hand":"🤙","call_me_hand_tone1":"🤙đŸģ","call_me_hand_tone2":"🤙đŸŧ","call_me_hand_tone3":"🤙đŸŊ","call_me_hand_tone4":"🤙🏾","call_me_hand_tone5":"🤙đŸŋ","calling":"📲","cambodia":"🇰🇭","camel":"đŸĢ","camera":"📷","camera_flas":"📸","camera_flash":"📸","camera_with_flash":"📸","cameroon":"🇨🇲","camping":"🏕","canada":"🇨đŸ‡Ļ","canary_islands":"🇮🇨","cancer":"♋","candle":"đŸ•¯","candy":"đŸŦ","canned_food":"đŸĨĢ","canoe":"đŸ›ļ","cape_verde":"🇨đŸ‡ģ","capital_abcd":"🔠","capricorn":"♑","car":"🚗","card_file_bo":"đŸ—ƒī¸","card_file_box":"🗃","card_index":"📇","card_index_dividers":"🗂","caribbean_netherlands":"🇧đŸ‡ļ","carousel_horse":"🎠","carp_streamer":"🎏","carpentry_saw":"đŸĒš","carrot":"đŸĨ•","cartwheeling":"🤸","cartwheeling_tone1":"🤸đŸģ","cartwheeling_tone2":"🤸đŸŧ","cartwheeling_tone3":"🤸đŸŊ","cartwheeling_tone4":"🤸🏾","cartwheeling_tone5":"🤸đŸŋ","castle":"🏰","cat":"🐱","cat2":"🐈","cat_face":"🐱","cayman_islands":"🇰🇾","cd":"đŸ’ŋ","censored":"đŸ¤Ŧ","central_african_republic":"🇨đŸ‡Ģ","ceuta_melilla":"đŸ‡ĒđŸ‡Ļ","chad":"🇹🇩","chains":"⛓","chair":"đŸĒ‘","champagne":"🍾","chart":"💹","chart_decreasing":"📉","chart_increasing":"📈","chart_with_downwards_trend":"📉","chart_with_upwards_tren":"📈","chart_with_upwards_trend":"📈","check_mark":"✔","check_mark_button":"✅","checkered_flag":"🏁","cheese":"🧀","cherries":"🍒","cherry_blossom":"🌸","chess_pawn":"♟","chestnut":"🌰","chicken":"🐔","chicken_face":"🐔","child":"🧒","child_tone1":"🧒đŸģ","child_tone2":"🧒đŸŧ","child_tone3":"🧒đŸŊ","child_tone4":"🧒🏾","child_tone5":"🧒đŸŋ","children_crossin":"🚸","children_crossing":"🚸","chile":"🇨🇱","china":"đŸ‡¨đŸ‡ŗ","chipmunk":"đŸŋ","chocolate_bar":"đŸĢ","chopsticks":"đŸĨĸ","christmas_island":"🇨đŸ‡Ŋ","christmas_tree":"🎄","church":"â›Ē","cigarette":"đŸšŦ","cinema":"đŸŽĻ","circus_tent":"đŸŽĒ","city_dusk":"🌆","city_sunrise":"🌇","city_sunset":"🌆","cityscape":"🏙","cl":"🆑","clamp":"🗜","clap":"👏","clap_tone1":"👏đŸģ","clap_tone2":"👏đŸŧ","clap_tone3":"👏đŸŊ","clap_tone4":"👏🏾","clap_tone5":"👏đŸŋ","clapper":"đŸŽŦ","clapping_hands":"👏","clapping_hands_tone1":"👏đŸģ","clapping_hands_tone2":"👏đŸŧ","clapping_hands_tone3":"👏đŸŊ","clapping_hands_tone4":"👏🏾","clapping_hands_tone5":"👏đŸŋ","classical_building":"🏛","climbing":"🧗","climbing_man":"đŸ§—â€â™‚ī¸","climbing_tone1":"🧗đŸģ","climbing_tone2":"🧗đŸŧ","climbing_tone3":"🧗đŸŊ","climbing_tone4":"🧗🏾","climbing_tone5":"🧗đŸŋ","climbing_woman":"đŸ§—â€â™€ī¸","clinking_glasses":"đŸĨ‚","clipboard":"📋","clipperton_island":"🇨đŸ‡ĩ","clock":"🕰","clock1":"🕐","clock10":"🕙","clock1030":"đŸ•Ĩ","clock11":"🕚","clock1130":"đŸ•Ļ","clock12":"🕛","clock1230":"🕧","clock130":"🕜","clock2":"🕑","clock230":"🕝","clock3":"🕒","clock330":"🕞","clock4":"🕓","clock430":"🕟","clock5":"🕔","clock530":"🕠","clock6":"🕕","clock630":"🕡","clock7":"🕖","clock730":"đŸ•ĸ","clock8":"🕗","clock830":"đŸ•Ŗ","clock9":"🕘","clock930":"🕤","clockwise":"🔃","closed_book":"📕","closed_lock_with_key":"🔐","closed_umbrella":"🌂","cloud":"☁","cloud_with_lightning":"🌩","cloud_with_lightning_and_rain":"⛈","cloud_with_rain":"🌧","cloud_with_snow":"🌨","cloudy":"đŸŒĨ","clown":"🤡","clown_fac":"🤡","clown_face":"🤡","clubs":"â™Ŗ","clutch_bag":"👝","cn":"đŸ‡¨đŸ‡ŗ","coat":"đŸ§Ĩ","cockroach":"đŸĒŗ","cocktail":"🍸","coconut":"đŸĨĨ","cocos_islands":"🇨🇨","coffee":"☕","coffi":"âš°ī¸","coffin":"⚰","coin":"đŸĒ™","cold":"đŸĨļ","cold_face":"đŸĨļ","cold_sweat":"😰","collision":"đŸ’Ĩ","colombia":"🇨🇴","comet":"☄","comoros":"🇰🇲","compass":"🧭","compression":"🗜","computer":"đŸ’ģ","computer_disk":"đŸ’Ŋ","computer_mouse":"🖱","confetti_ball":"🎊","confounded":"😖","confounded_face":"😖","confused":"😕","confused_face":"😕","congo_brazzaville":"🇨đŸ‡Ŧ","congo_kinshasa":"🇨🇩","congratulations":"㊗","constructio":"🚧","construction":"🚧","construction_site":"🏗","construction_worke":"👷","construction_worker":"👷","construction_worker_man":"đŸ‘ˇâ€â™‚ī¸","construction_worker_tone1":"👷đŸģ","construction_worker_tone2":"👷đŸŧ","construction_worker_tone3":"👷đŸŊ","construction_worker_tone4":"👷🏾","construction_worker_tone5":"👷đŸŋ","construction_worker_woman":"đŸ‘ˇâ€â™€ī¸","control_knobs":"🎛","controller":"🎮","convenience_store":"đŸĒ","cook":"đŸ§‘â€đŸŗ","cook_islands":"🇨🇰","cook_tone1":"🧑đŸģâ€đŸŗ","cook_tone2":"🧑đŸŧâ€đŸŗ","cook_tone3":"🧑đŸŊâ€đŸŗ","cook_tone4":"đŸ§‘đŸžâ€đŸŗ","cook_tone5":"🧑đŸŋâ€đŸŗ","cooked_rice":"🍚","cookie":"đŸĒ","cooking":"đŸŗ","cool":"🆒","cop":"👮","cop_tone1":"👮đŸģ","cop_tone2":"👮đŸŧ","cop_tone3":"👮đŸŊ","cop_tone4":"👮🏾","cop_tone5":"👮đŸŋ","copyright":"Š","coral":"đŸĒ¸","corn":"đŸŒŊ","costa_rica":"🇨🇷","cote_divoire":"🇨🇮","couch_and_lamp":"🛋","counterclockwise":"🔄","couple":"đŸ‘Ģ","couple_kiss":"💏","couple_kiss_tone1":"💏đŸģ","couple_kiss_tone1-2":"🧑đŸģâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŧ","couple_kiss_tone1-3":"🧑đŸģâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŊ","couple_kiss_tone1-4":"🧑đŸģâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸž","couple_kiss_tone1-5":"🧑đŸģâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŋ","couple_kiss_tone2":"💏đŸŧ","couple_kiss_tone2-1":"🧑đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸģ","couple_kiss_tone2-3":"🧑đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŊ","couple_kiss_tone2-4":"🧑đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸž","couple_kiss_tone2-5":"🧑đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŋ","couple_kiss_tone3":"💏đŸŊ","couple_kiss_tone3-1":"🧑đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸģ","couple_kiss_tone3-2":"🧑đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŧ","couple_kiss_tone3-4":"🧑đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸž","couple_kiss_tone3-5":"🧑đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŋ","couple_kiss_tone4":"💏🏾","couple_kiss_tone4-1":"đŸ§‘đŸžâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸģ","couple_kiss_tone4-2":"đŸ§‘đŸžâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŧ","couple_kiss_tone4-3":"đŸ§‘đŸžâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŊ","couple_kiss_tone4-5":"đŸ§‘đŸžâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŋ","couple_kiss_tone5":"💏đŸŋ","couple_kiss_tone5-1":"🧑đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸģ","couple_kiss_tone5-2":"🧑đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŧ","couple_kiss_tone5-3":"🧑đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŊ","couple_kiss_tone5-4":"🧑đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸž","couple_tone1":"đŸ‘ĢđŸģ","couple_tone1-2":"👩đŸģ‍🤝‍👨đŸŧ","couple_tone1-3":"👩đŸģ‍🤝‍👨đŸŊ","couple_tone1-4":"👩đŸģ‍🤝‍👨🏾","couple_tone1-5":"👩đŸģ‍🤝‍👨đŸŋ","couple_tone2":"đŸ‘ĢđŸŧ","couple_tone2-1":"👩đŸŧ‍🤝‍👨đŸģ","couple_tone2-3":"👩đŸŧ‍🤝‍👨đŸŊ","couple_tone2-4":"👩đŸŧ‍🤝‍👨🏾","couple_tone2-5":"👩đŸŧ‍🤝‍👨đŸŋ","couple_tone3":"đŸ‘ĢđŸŊ","couple_tone3-1":"👩đŸŊ‍🤝‍👨đŸģ","couple_tone3-2":"👩đŸŊ‍🤝‍👨đŸŧ","couple_tone3-4":"👩đŸŊ‍🤝‍👨🏾","couple_tone3-5":"👩đŸŊ‍🤝‍👨đŸŋ","couple_tone4":"đŸ‘Ģ🏾","couple_tone4-1":"👩🏾‍🤝‍👨đŸģ","couple_tone4-2":"👩🏾‍🤝‍👨đŸŧ","couple_tone4-3":"👩🏾‍🤝‍👨đŸŊ","couple_tone4-5":"👩🏾‍🤝‍👨đŸŋ","couple_tone5":"đŸ‘ĢđŸŋ","couple_tone5-1":"👩đŸŋ‍🤝‍👨đŸģ","couple_tone5-2":"👩đŸŋ‍🤝‍👨đŸŧ","couple_tone5-3":"👩đŸŋ‍🤝‍👨đŸŊ","couple_tone5-4":"👩đŸŋ‍🤝‍👨🏾","couple_with_heart":"💑","couple_with_heart_man_man":"đŸ‘¨â€â¤ī¸â€đŸ‘¨","couple_with_heart_mm":"đŸ‘¨â€â¤ī¸â€đŸ‘¨","couple_with_heart_mm_tone1":"👨đŸģâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_mm_tone1-2":"👨đŸģâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_mm_tone1-3":"👨đŸģâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_mm_tone1-4":"👨đŸģâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_mm_tone1-5":"👨đŸģâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_mm_tone2":"👨đŸŧâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_mm_tone2-1":"👨đŸŧâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_mm_tone2-3":"👨đŸŧâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_mm_tone2-4":"👨đŸŧâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_mm_tone2-5":"👨đŸŧâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_mm_tone3":"👨đŸŊâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_mm_tone3-1":"👨đŸŊâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_mm_tone3-2":"👨đŸŊâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_mm_tone3-4":"👨đŸŊâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_mm_tone3-5":"👨đŸŊâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_mm_tone4":"đŸ‘¨đŸžâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_mm_tone4-1":"đŸ‘¨đŸžâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_mm_tone4-2":"đŸ‘¨đŸžâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_mm_tone4-3":"đŸ‘¨đŸžâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_mm_tone4-5":"đŸ‘¨đŸžâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_mm_tone5":"👨đŸŋâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_mm_tone5-1":"👨đŸŋâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_mm_tone5-2":"👨đŸŋâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_mm_tone5-3":"👨đŸŋâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_mm_tone5-4":"👨đŸŋâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_mw":"đŸ‘Šâ€â¤ī¸â€đŸ‘¨","couple_with_heart_mw_tone1":"👩đŸģâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_mw_tone1-2":"👩đŸģâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_mw_tone1-3":"👩đŸģâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_mw_tone1-4":"👩đŸģâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_mw_tone1-5":"👩đŸģâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_mw_tone2":"👩đŸŧâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_mw_tone2-1":"👩đŸŧâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_mw_tone2-3":"👩đŸŧâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_mw_tone2-4":"👩đŸŧâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_mw_tone2-5":"👩đŸŧâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_mw_tone3":"👩đŸŊâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_mw_tone3-1":"👩đŸŊâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_mw_tone3-2":"👩đŸŊâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_mw_tone3-4":"👩đŸŊâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_mw_tone3-5":"👩đŸŊâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_mw_tone4":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_mw_tone4-1":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_mw_tone4-2":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_mw_tone4-3":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_mw_tone4-5":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_mw_tone5":"👩đŸŋâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_mw_tone5-1":"👩đŸŋâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_mw_tone5-2":"👩đŸŋâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_mw_tone5-3":"👩đŸŋâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_mw_tone5-4":"👩đŸŋâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_tone1":"💑đŸģ","couple_with_heart_tone1-2":"🧑đŸģâ€â¤ī¸â€đŸ§‘đŸŧ","couple_with_heart_tone1-3":"🧑đŸģâ€â¤ī¸â€đŸ§‘đŸŊ","couple_with_heart_tone1-4":"🧑đŸģâ€â¤ī¸â€đŸ§‘đŸž","couple_with_heart_tone1-5":"🧑đŸģâ€â¤ī¸â€đŸ§‘đŸŋ","couple_with_heart_tone2":"💑đŸŧ","couple_with_heart_tone2-1":"🧑đŸŧâ€â¤ī¸â€đŸ§‘đŸģ","couple_with_heart_tone2-3":"🧑đŸŧâ€â¤ī¸â€đŸ§‘đŸŊ","couple_with_heart_tone2-4":"🧑đŸŧâ€â¤ī¸â€đŸ§‘đŸž","couple_with_heart_tone2-5":"🧑đŸŧâ€â¤ī¸â€đŸ§‘đŸŋ","couple_with_heart_tone3":"💑đŸŊ","couple_with_heart_tone3-1":"🧑đŸŊâ€â¤ī¸â€đŸ§‘đŸģ","couple_with_heart_tone3-2":"🧑đŸŊâ€â¤ī¸â€đŸ§‘đŸŧ","couple_with_heart_tone3-4":"🧑đŸŊâ€â¤ī¸â€đŸ§‘đŸž","couple_with_heart_tone3-5":"🧑đŸŊâ€â¤ī¸â€đŸ§‘đŸŋ","couple_with_heart_tone4":"💑🏾","couple_with_heart_tone4-1":"đŸ§‘đŸžâ€â¤ī¸â€đŸ§‘đŸģ","couple_with_heart_tone4-2":"đŸ§‘đŸžâ€â¤ī¸â€đŸ§‘đŸŧ","couple_with_heart_tone4-3":"đŸ§‘đŸžâ€â¤ī¸â€đŸ§‘đŸŊ","couple_with_heart_tone4-5":"đŸ§‘đŸžâ€â¤ī¸â€đŸ§‘đŸŋ","couple_with_heart_tone5":"💑đŸŋ","couple_with_heart_tone5-1":"🧑đŸŋâ€â¤ī¸â€đŸ§‘đŸģ","couple_with_heart_tone5-2":"🧑đŸŋâ€â¤ī¸â€đŸ§‘đŸŧ","couple_with_heart_tone5-3":"🧑đŸŋâ€â¤ī¸â€đŸ§‘đŸŊ","couple_with_heart_tone5-4":"🧑đŸŋâ€â¤ī¸â€đŸ§‘đŸž","couple_with_heart_wm":"đŸ‘Šâ€â¤ī¸â€đŸ‘¨","couple_with_heart_wm_tone1":"👩đŸģâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_wm_tone1-2":"👩đŸģâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_wm_tone1-3":"👩đŸģâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_wm_tone1-4":"👩đŸģâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_wm_tone1-5":"👩đŸģâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_wm_tone2":"👩đŸŧâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_wm_tone2-1":"👩đŸŧâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_wm_tone2-3":"👩đŸŧâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_wm_tone2-4":"👩đŸŧâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_wm_tone2-5":"👩đŸŧâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_wm_tone3":"👩đŸŊâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_wm_tone3-1":"👩đŸŊâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_wm_tone3-2":"👩đŸŊâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_wm_tone3-4":"👩đŸŊâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_wm_tone3-5":"👩đŸŊâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_wm_tone4":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_wm_tone4-1":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_wm_tone4-2":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_wm_tone4-3":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_wm_tone4-5":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_wm_tone5":"👩đŸŋâ€â¤ī¸â€đŸ‘¨đŸŋ","couple_with_heart_wm_tone5-1":"👩đŸŋâ€â¤ī¸â€đŸ‘¨đŸģ","couple_with_heart_wm_tone5-2":"👩đŸŋâ€â¤ī¸â€đŸ‘¨đŸŧ","couple_with_heart_wm_tone5-3":"👩đŸŋâ€â¤ī¸â€đŸ‘¨đŸŊ","couple_with_heart_wm_tone5-4":"👩đŸŋâ€â¤ī¸â€đŸ‘¨đŸž","couple_with_heart_woman_man":"đŸ‘Šâ€â¤ī¸â€đŸ‘¨","couple_with_heart_woman_woman":"đŸ‘Šâ€â¤ī¸â€đŸ‘Š","couple_with_heart_ww":"đŸ‘Šâ€â¤ī¸â€đŸ‘Š","couple_with_heart_ww_tone1":"👩đŸģâ€â¤ī¸â€đŸ‘ŠđŸģ","couple_with_heart_ww_tone1-2":"👩đŸģâ€â¤ī¸â€đŸ‘ŠđŸŧ","couple_with_heart_ww_tone1-3":"👩đŸģâ€â¤ī¸â€đŸ‘ŠđŸŊ","couple_with_heart_ww_tone1-4":"👩đŸģâ€â¤ī¸â€đŸ‘ŠđŸž","couple_with_heart_ww_tone1-5":"👩đŸģâ€â¤ī¸â€đŸ‘ŠđŸŋ","couple_with_heart_ww_tone2":"👩đŸŧâ€â¤ī¸â€đŸ‘ŠđŸŧ","couple_with_heart_ww_tone2-1":"👩đŸŧâ€â¤ī¸â€đŸ‘ŠđŸģ","couple_with_heart_ww_tone2-3":"👩đŸŧâ€â¤ī¸â€đŸ‘ŠđŸŊ","couple_with_heart_ww_tone2-4":"👩đŸŧâ€â¤ī¸â€đŸ‘ŠđŸž","couple_with_heart_ww_tone2-5":"👩đŸŧâ€â¤ī¸â€đŸ‘ŠđŸŋ","couple_with_heart_ww_tone3":"👩đŸŊâ€â¤ī¸â€đŸ‘ŠđŸŊ","couple_with_heart_ww_tone3-1":"👩đŸŊâ€â¤ī¸â€đŸ‘ŠđŸģ","couple_with_heart_ww_tone3-2":"👩đŸŊâ€â¤ī¸â€đŸ‘ŠđŸŧ","couple_with_heart_ww_tone3-4":"👩đŸŊâ€â¤ī¸â€đŸ‘ŠđŸž","couple_with_heart_ww_tone3-5":"👩đŸŊâ€â¤ī¸â€đŸ‘ŠđŸŋ","couple_with_heart_ww_tone4":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘ŠđŸž","couple_with_heart_ww_tone4-1":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘ŠđŸģ","couple_with_heart_ww_tone4-2":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘ŠđŸŧ","couple_with_heart_ww_tone4-3":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘ŠđŸŊ","couple_with_heart_ww_tone4-5":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ‘ŠđŸŋ","couple_with_heart_ww_tone5":"👩đŸŋâ€â¤ī¸â€đŸ‘ŠđŸŋ","couple_with_heart_ww_tone5-1":"👩đŸŋâ€â¤ī¸â€đŸ‘ŠđŸģ","couple_with_heart_ww_tone5-2":"👩đŸŋâ€â¤ī¸â€đŸ‘ŠđŸŧ","couple_with_heart_ww_tone5-3":"👩đŸŋâ€â¤ī¸â€đŸ‘ŠđŸŊ","couple_with_heart_ww_tone5-4":"👩đŸŋâ€â¤ī¸â€đŸ‘ŠđŸž","couplekiss":"💏","couplekiss_man_man":"đŸ‘¨â€â¤ī¸â€đŸ’‹â€đŸ‘¨","couplekiss_man_woman":"đŸ‘Šâ€â¤ī¸â€đŸ’‹â€đŸ‘¨","couplekiss_tone1":"💏đŸģ","couplekiss_tone1-2":"🧑đŸģâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŧ","couplekiss_tone1-3":"🧑đŸģâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŊ","couplekiss_tone1-4":"🧑đŸģâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸž","couplekiss_tone1-5":"🧑đŸģâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŋ","couplekiss_tone2":"💏đŸŧ","couplekiss_tone2-1":"🧑đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸģ","couplekiss_tone2-3":"🧑đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŊ","couplekiss_tone2-4":"🧑đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸž","couplekiss_tone2-5":"🧑đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŋ","couplekiss_tone3":"💏đŸŊ","couplekiss_tone3-1":"🧑đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸģ","couplekiss_tone3-2":"🧑đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŧ","couplekiss_tone3-4":"🧑đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸž","couplekiss_tone3-5":"🧑đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŋ","couplekiss_tone4":"💏🏾","couplekiss_tone4-1":"đŸ§‘đŸžâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸģ","couplekiss_tone4-2":"đŸ§‘đŸžâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŧ","couplekiss_tone4-3":"đŸ§‘đŸžâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŊ","couplekiss_tone4-5":"đŸ§‘đŸžâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŋ","couplekiss_tone5":"💏đŸŋ","couplekiss_tone5-1":"🧑đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸģ","couplekiss_tone5-2":"🧑đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŧ","couplekiss_tone5-3":"🧑đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸŊ","couplekiss_tone5-4":"🧑đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ§‘đŸž","couplekiss_woman_woman":"đŸ‘Šâ€â¤ī¸â€đŸ’‹â€đŸ‘Š","cow":"🐮","cow2":"🐄","cow_face":"🐮","cowboy":"🤠","cowboy_face":"🤠","cowboy_hat_face":"🤠","crab":"đŸĻ€","crayon":"🖍","credit_card":"đŸ’ŗ","crescent_moon":"🌙","cricket":"đŸĻ—","cricket_game":"🏏","croatia":"🇭🇷","crocodile":"🐊","croissant":"đŸĨ","cross_mark":"❌","cross_mark_button":"❎","crossed_fingers":"🤞","crossed_flags":"🎌","crossed_swords":"⚔","crown":"👑","cruise_ship":"đŸ›ŗ","crutch":"đŸŠŧ","cry":"đŸ˜ĸ","crying_cat":"đŸ˜ŋ","crying_cat_face":"đŸ˜ŋ","crying_face":"đŸ˜ĸ","crystal_ball":"🔮","cuba":"🇨đŸ‡ē","cucumber":"đŸĨ’","cup_with_straw":"đŸĨ¤","cupcake":"🧁","cupid":"💘","curacao":"🇨đŸ‡ŧ","curling_stone":"đŸĨŒ","curly_hair":"đŸĻą","curly_haired":"🧑‍đŸĻą","curly_haired_man":"👨‍đŸĻą","curly_haired_tone1":"🧑đŸģ‍đŸĻą","curly_haired_tone2":"🧑đŸŧ‍đŸĻą","curly_haired_tone3":"🧑đŸŊ‍đŸĻą","curly_haired_tone4":"🧑🏾‍đŸĻą","curly_haired_tone5":"🧑đŸŋ‍đŸĻą","curly_haired_woman":"👩‍đŸĻą","curly_loop":"➰","currency_exchange":"💱","curry":"🍛","curry_rice":"🍛","cursing_face":"đŸ¤Ŧ","custard":"🍮","customs":"🛃","cut_of_meat":"đŸĨŠ","cyclone":"🌀","cyprus":"🇨🇾","czech_republic":"🇨đŸ‡ŋ","czechia":"🇨đŸ‡ŋ","dagger":"🗡","dancer":"💃","dancer_tone1":"💃đŸģ","dancer_tone2":"💃đŸŧ","dancer_tone3":"💃đŸŊ","dancer_tone4":"💃🏾","dancer_tone5":"💃đŸŋ","dancers":"đŸ‘¯","dancing_men":"đŸ‘¯â€â™‚ī¸","dancing_women":"đŸ‘¯â€â™€ī¸","dango":"🍡","dark_sunglasses":"đŸ•ļ","dart":"đŸŽ¯","dash":"💨","dashing_away":"💨","date":"📅","de":"🇩đŸ‡Ē","deaf_man":"đŸ§â€â™‚ī¸","deaf_man_tone1":"🧏đŸģâ€â™‚ī¸","deaf_man_tone2":"🧏đŸŧâ€â™‚ī¸","deaf_man_tone3":"🧏đŸŊâ€â™‚ī¸","deaf_man_tone4":"đŸ§đŸžâ€â™‚ī¸","deaf_man_tone5":"🧏đŸŋâ€â™‚ī¸","deaf_person":"🧏","deaf_person_tone1":"🧏đŸģ","deaf_person_tone2":"🧏đŸŧ","deaf_person_tone3":"🧏đŸŊ","deaf_person_tone4":"🧏🏾","deaf_person_tone5":"🧏đŸŋ","deaf_woman":"đŸ§â€â™€ī¸","deaf_woman_tone1":"🧏đŸģâ€â™€ī¸","deaf_woman_tone2":"🧏đŸŧâ€â™€ī¸","deaf_woman_tone3":"🧏đŸŊâ€â™€ī¸","deaf_woman_tone4":"đŸ§đŸžâ€â™€ī¸","deaf_woman_tone5":"🧏đŸŋâ€â™€ī¸","deciduous_tree":"đŸŒŗ","deer":"đŸĻŒ","delivery_truck":"🚚","denmark":"🇩🇰","department_store":"đŸŦ","derelict_house":"🏚","desert":"🏜","desert_island":"🏝","desktop_computer":"đŸ–Ĩ","detective":"đŸ•ĩ","detective_tone1":"đŸ•ĩđŸģ","detective_tone2":"đŸ•ĩđŸŧ","detective_tone3":"đŸ•ĩđŸŊ","detective_tone4":"đŸ•ĩ🏾","detective_tone5":"đŸ•ĩđŸŋ","diamond_shape_with_a_dot_inside":"💠","diamond_with_a_dot":"💠","diamonds":"â™Ļ","diego_garcia":"🇩đŸ‡Ŧ","dim_button":"🔅","direct_hit":"đŸŽ¯","disappointed":"😞","disappointed_face":"😞","disappointed_relieved":"đŸ˜Ĩ","disco":"đŸĒŠ","disco_ball":"đŸĒŠ","disguised":"đŸĨ¸","disguised_face":"đŸĨ¸","divide":"➗","diving_mask":"đŸ¤ŋ","division":"➗","diya_lamp":"đŸĒ”","dizz":"đŸ’Ģ","dizzy":"đŸ’Ģ","dizzy_eyes":"đŸ˜ĩ‍đŸ’Ģ","dizzy_face":"đŸ˜ĩ","djibouti":"đŸ‡ŠđŸ‡¯","dna":"đŸ§Ŧ","do_not_litter":"đŸš¯","dodo":"đŸĻ¤","dog":"đŸļ","dog2":"🐕","dog_face":"đŸļ","dollar":"đŸ’ĩ","dolls":"🎎","dolphin":"đŸŦ","dominica":"🇩🇲","dominican_republic":"🇩🇴","door":"đŸšĒ","dotted_line_face":"đŸĢĨ","double_curly_loop":"âžŋ","double_exclamation":"â€ŧ","double_helix":"đŸ§Ŧ","doughnut":"🍩","dove":"🕊","down":"đŸ”Ŋ","downcast_face":"😓","dragon":"🐉","dragon_face":"🐲","dress":"👗","dromedary_camel":"đŸĒ","drooling":"🤤","drooling_face":"🤤","drop_of_blood":"🩸","droplet":"💧","drum":"đŸĨ","duck":"đŸĻ†","dumpling":"đŸĨŸ","dvd":"📀","e-mail":"📧","eagle":"đŸĻ…","ear":"👂","ear_of_corn":"đŸŒŊ","ear_of_rice":"🌾","ear_tone1":"👂đŸģ","ear_tone2":"👂đŸŧ","ear_tone3":"👂đŸŊ","ear_tone4":"👂🏾","ear_tone5":"👂đŸŋ","ear_with_hearing_aid":"đŸĻģ","ear_with_hearing_aid_tone1":"đŸĻģđŸģ","ear_with_hearing_aid_tone2":"đŸĻģđŸŧ","ear_with_hearing_aid_tone3":"đŸĻģđŸŊ","ear_with_hearing_aid_tone4":"đŸĻģ🏾","ear_with_hearing_aid_tone5":"đŸĻģđŸŋ","earth_africa":"🌍","earth_americas":"🌎","earth_asia":"🌏","earth_europe":"🌍","ecuador":"đŸ‡Ē🇨","eg":"đŸĨš","egg":"đŸĨš","eggplant":"🍆","egypt":"đŸ‡ĒđŸ‡Ŧ","eight":"8ī¸âƒŖ","eight_pointed_black_star":"✴","eight_spoked_asterisk":"âœŗ","eject":"⏏","eject_button":"⏏","el_salvador":"🇸đŸ‡ģ","electric_plug":"🔌","elephant":"🐘","elevator":"🛗","elf":"🧝","elf_man":"đŸ§â€â™‚ī¸","elf_tone1":"🧝đŸģ","elf_tone2":"🧝đŸŧ","elf_tone3":"🧝đŸŊ","elf_tone4":"🧝🏾","elf_tone5":"🧝đŸŋ","elf_woman":"đŸ§â€â™€ī¸","email":"📧","empty_nest":"đŸĒš","end":"🔚","england":"đŸ´ķ §ķ ĸķ Ĩķ Žķ §ķ ŋ","envelope":"✉","envelope_with_arrow":"📩","equatorial_guinea":"đŸ‡ŦđŸ‡ļ","eritrea":"đŸ‡Ē🇷","es":"đŸ‡Ē🇸","estonia":"đŸ‡ĒđŸ‡Ē","eswatini":"🇸đŸ‡ŋ","ethiopia":"đŸ‡Ē🇹","eu":"đŸ‡ĒđŸ‡ē","euro":"đŸ’ļ","european_castle":"🏰","european_post_office":"🏤","european_union":"đŸ‡ĒđŸ‡ē","evergreen_tree":"🌲","ewe":"🐑","exclamation":"❗","exclamation_question":"⁉","exhale":"😮‍💨","exhaling":"😮‍💨","exploding_head":"đŸ¤¯","expressionless":"😑","expressionless_face":"😑","eye":"👁","eye_in_speech_bubble":"đŸ‘ī¸â€đŸ—¨ī¸","eye_speech_bubble":"đŸ‘ī¸â€đŸ—¨ī¸","eyeglasses":"👓","eyes":"👀","face_exhaling":"😮‍💨","face_holding_back_tears":"đŸĨš","face_in_clouds":"đŸ˜ļ‍đŸŒĢī¸","face_vomiting":"🤮","face_with_diagonal_mouth":"đŸĢ¤","face_with_hand_over_mouth":"🤭","face_with_head_bandage":"🤕","face_with_monocle":"🧐","face_with_open_eyes_hand_over_mouth":"đŸĢĸ","face_with_open_mouth":"😮","face_with_peeking_eye":"đŸĢŖ","face_with_raised_eyebrow":"🤨","face_with_spiral_eyes":"đŸ˜ĩ‍đŸ’Ģ","face_with_symbols_on_mouth":"đŸ¤Ŧ","face_with_thermometer":"🤒","face_with_tongue":"😛","facepalm":"đŸ¤Ļ","facepalm_tone1":"đŸ¤ĻđŸģ","facepalm_tone2":"đŸ¤ĻđŸŧ","facepalm_tone3":"đŸ¤ĻđŸŊ","facepalm_tone4":"đŸ¤Ļ🏾","facepalm_tone5":"đŸ¤ĻđŸŋ","facepunch":"👊","factory":"🏭","factory_worker":"🧑‍🏭","factory_worker_tone1":"🧑đŸģ‍🏭","factory_worker_tone2":"🧑đŸŧ‍🏭","factory_worker_tone3":"🧑đŸŊ‍🏭","factory_worker_tone4":"🧑🏾‍🏭","factory_worker_tone5":"🧑đŸŋ‍🏭","fairy":"🧚","fairy_man":"đŸ§šâ€â™‚ī¸","fairy_tone1":"🧚đŸģ","fairy_tone2":"🧚đŸŧ","fairy_tone3":"🧚đŸŊ","fairy_tone4":"🧚🏾","fairy_tone5":"🧚đŸŋ","fairy_woman":"đŸ§šâ€â™€ī¸","falafel":"🧆","falkland_islands":"đŸ‡Ģ🇰","fallen_leaf":"🍂","family":"đŸ‘Ē","family_man_boy":"👨‍đŸ‘Ļ","family_man_boy_boy":"👨‍đŸ‘Ļ‍đŸ‘Ļ","family_man_girl":"👨‍👧","family_man_girl_boy":"👨‍👧‍đŸ‘Ļ","family_man_girl_girl":"👨‍👧‍👧","family_man_man_boy":"👨‍👨‍đŸ‘Ļ","family_man_man_boy_boy":"👨‍👨‍đŸ‘Ļ‍đŸ‘Ļ","family_man_man_girl":"👨‍👨‍👧","family_man_man_girl_boy":"👨‍👨‍👧‍đŸ‘Ļ","family_man_man_girl_girl":"👨‍👨‍👧‍👧","family_man_woman_boy":"👨‍👩‍đŸ‘Ļ","family_man_woman_boy_boy":"👨‍👩‍đŸ‘Ļ‍đŸ‘Ļ","family_man_woman_girl":"👨‍👩‍👧","family_man_woman_girl_boy":"👨‍👩‍👧‍đŸ‘Ļ","family_man_woman_girl_girl":"👨‍👩‍👧‍👧","family_mb":"👨‍đŸ‘Ļ","family_mbb":"👨‍đŸ‘Ļ‍đŸ‘Ļ","family_mg":"👨‍👧","family_mgb":"👨‍👧‍đŸ‘Ļ","family_mgg":"👨‍👧‍👧","family_mmb":"👨‍👨‍đŸ‘Ļ","family_mmbb":"👨‍👨‍đŸ‘Ļ‍đŸ‘Ļ","family_mmg":"👨‍👨‍👧","family_mmgb":"👨‍👨‍👧‍đŸ‘Ļ","family_mmgg":"👨‍👨‍👧‍👧","family_mwb":"👨‍👩‍đŸ‘Ļ","family_mwbb":"👨‍👩‍đŸ‘Ļ‍đŸ‘Ļ","family_mwg":"👨‍👩‍👧","family_mwgb":"👨‍👩‍👧‍đŸ‘Ļ","family_mwgg":"👨‍👩‍👧‍👧","family_wb":"👩‍đŸ‘Ļ","family_wbb":"👩‍đŸ‘Ļ‍đŸ‘Ļ","family_wg":"👩‍👧","family_wgb":"👩‍👧‍đŸ‘Ļ","family_wgg":"👩‍👧‍👧","family_woman_boy":"👩‍đŸ‘Ļ","family_woman_boy_boy":"👩‍đŸ‘Ļ‍đŸ‘Ļ","family_woman_girl":"👩‍👧","family_woman_girl_boy":"👩‍👧‍đŸ‘Ļ","family_woman_girl_girl":"👩‍👧‍👧","family_woman_woman_boy":"👩‍👩‍đŸ‘Ļ","family_woman_woman_boy_boy":"👩‍👩‍đŸ‘Ļ‍đŸ‘Ļ","family_woman_woman_girl":"👩‍👩‍👧","family_woman_woman_girl_boy":"👩‍👩‍👧‍đŸ‘Ļ","family_woman_woman_girl_girl":"👩‍👩‍👧‍👧","family_wwb":"👩‍👩‍đŸ‘Ļ","family_wwbb":"👩‍👩‍đŸ‘Ļ‍đŸ‘Ļ","family_wwg":"👩‍👩‍👧","family_wwgb":"👩‍👩‍👧‍đŸ‘Ļ","family_wwgg":"👩‍👩‍👧‍👧","farmer":"🧑‍🌾","farmer_tone1":"🧑đŸģ‍🌾","farmer_tone2":"🧑đŸŧ‍🌾","farmer_tone3":"🧑đŸŊ‍🌾","farmer_tone4":"🧑🏾‍🌾","farmer_tone5":"🧑đŸŋ‍🌾","faroe_islands":"đŸ‡Ģ🇴","fast_down":"âŦ","fast_forward":"⏊","fast_reverse":"âĒ","fast_up":"âĢ","fax":"📠","fax_machine":"📠","fearful":"😨","fearful_face":"😨","feather":"đŸĒļ","feet":"🐾","female":"♀","female_detective":"đŸ•ĩī¸â€â™€ī¸","female_sign":"♀","fencer":"đŸ¤ē","fencing":"đŸ¤ē","ferris_wheel":"🎡","ferry":"⛴","field_hockey":"🏑","fiji":"đŸ‡ĢđŸ‡¯","file_cabinet":"🗄","file_folder":"📁","film_frames":"🎞","film_projector":"đŸ“Ŋ","film_strip":"🎞","fingers_crossed":"🤞","fingers_crossed_tone1":"🤞đŸģ","fingers_crossed_tone2":"🤞đŸŧ","fingers_crossed_tone3":"🤞đŸŊ","fingers_crossed_tone4":"🤞🏾","fingers_crossed_tone5":"🤞đŸŋ","finland":"đŸ‡Ģ🇮","fir":"đŸ”Ĩ","fire":"đŸ”Ĩ","fire_engine":"🚒","fire_extinguisher":"đŸ§¯","firecracker":"🧨","firefighter":"🧑‍🚒","firefighter_tone1":"🧑đŸģ‍🚒","firefighter_tone2":"🧑đŸŧ‍🚒","firefighter_tone3":"🧑đŸŊ‍🚒","firefighter_tone4":"🧑🏾‍🚒","firefighter_tone5":"🧑đŸŋ‍🚒","fireworks":"🎆","first_place_medal":"đŸĨ‡","first_quarter_moon":"🌓","first_quarter_moon_with_face":"🌛","fish":"🐟","fish_cake":"đŸĨ","fishing_pole":"đŸŽŖ","fishing_pole_and_fish":"đŸŽŖ","fist":"✊","fist_left":"🤛","fist_oncoming":"👊","fist_raised":"✊","fist_right":"🤜","fist_tone1":"✊đŸģ","fist_tone2":"✊đŸŧ","fist_tone3":"✊đŸŊ","fist_tone4":"✊🏾","fist_tone5":"✊đŸŋ","five":"5ī¸âƒŖ","flag_ac":"đŸ‡Ļ🇨","flag_ad":"đŸ‡Ļ🇩","flag_ae":"đŸ‡ĻđŸ‡Ē","flag_af":"đŸ‡ĻđŸ‡Ģ","flag_ag":"đŸ‡ĻđŸ‡Ŧ","flag_ai":"đŸ‡Ļ🇮","flag_al":"đŸ‡Ļ🇱","flag_am":"đŸ‡Ļ🇲","flag_ao":"đŸ‡Ļ🇴","flag_aq":"đŸ‡ĻđŸ‡ļ","flag_ar":"đŸ‡Ļ🇷","flag_as":"đŸ‡Ļ🇸","flag_at":"đŸ‡Ļ🇹","flag_au":"đŸ‡ĻđŸ‡ē","flag_aw":"đŸ‡ĻđŸ‡ŧ","flag_ax":"đŸ‡ĻđŸ‡Ŋ","flag_az":"đŸ‡ĻđŸ‡ŋ","flag_ba":"🇧đŸ‡Ļ","flag_bb":"🇧🇧","flag_bd":"🇧🇩","flag_be":"🇧đŸ‡Ē","flag_bf":"🇧đŸ‡Ģ","flag_bg":"🇧đŸ‡Ŧ","flag_bh":"🇧🇭","flag_bi":"🇧🇮","flag_bj":"đŸ‡§đŸ‡¯","flag_bl":"🇧🇱","flag_bm":"🇧🇲","flag_bn":"đŸ‡§đŸ‡ŗ","flag_bo":"🇧🇴","flag_bq":"🇧đŸ‡ļ","flag_br":"🇧🇷","flag_bs":"🇧🇸","flag_bt":"🇧🇹","flag_bv":"🇧đŸ‡ģ","flag_bw":"🇧đŸ‡ŧ","flag_by":"🇧🇾","flag_bz":"🇧đŸ‡ŋ","flag_ca":"🇨đŸ‡Ļ","flag_cc":"🇨🇨","flag_cd":"🇨🇩","flag_cf":"🇨đŸ‡Ģ","flag_cg":"🇨đŸ‡Ŧ","flag_ch":"🇨🇭","flag_ci":"🇨🇮","flag_ck":"🇨🇰","flag_cl":"🇨🇱","flag_cm":"🇨🇲","flag_cn":"đŸ‡¨đŸ‡ŗ","flag_co":"🇨🇴","flag_cp":"🇨đŸ‡ĩ","flag_cr":"🇨🇷","flag_cu":"🇨đŸ‡ē","flag_cv":"🇨đŸ‡ģ","flag_cw":"🇨đŸ‡ŧ","flag_cx":"🇨đŸ‡Ŋ","flag_cy":"🇨🇾","flag_cz":"🇨đŸ‡ŋ","flag_de":"🇩đŸ‡Ē","flag_dg":"🇩đŸ‡Ŧ","flag_dj":"đŸ‡ŠđŸ‡¯","flag_dk":"🇩🇰","flag_dm":"🇩🇲","flag_do":"🇩🇴","flag_dz":"🇩đŸ‡ŋ","flag_ea":"đŸ‡ĒđŸ‡Ļ","flag_ec":"đŸ‡Ē🇨","flag_ee":"đŸ‡ĒđŸ‡Ē","flag_eg":"đŸ‡ĒđŸ‡Ŧ","flag_eh":"đŸ‡Ē🇭","flag_er":"đŸ‡Ē🇷","flag_es":"đŸ‡Ē🇸","flag_et":"đŸ‡Ē🇹","flag_eu":"đŸ‡ĒđŸ‡ē","flag_fi":"đŸ‡Ģ🇮","flag_fj":"đŸ‡ĢđŸ‡¯","flag_fk":"đŸ‡Ģ🇰","flag_fm":"đŸ‡Ģ🇲","flag_fo":"đŸ‡Ģ🇴","flag_fr":"đŸ‡Ģ🇷","flag_ga":"đŸ‡ŦđŸ‡Ļ","flag_gb":"đŸ‡Ŧ🇧","flag_gbeng":"đŸ´ķ §ķ ĸķ Ĩķ Žķ §ķ ŋ","flag_gbsct":"đŸ´ķ §ķ ĸķ ŗķ Ŗķ ´ķ ŋ","flag_gbwls":"đŸ´ķ §ķ ĸķ ˇķ Ŧķ ŗķ ŋ","flag_gd":"đŸ‡Ŧ🇩","flag_ge":"đŸ‡ŦđŸ‡Ē","flag_gf":"đŸ‡ŦđŸ‡Ģ","flag_gg":"đŸ‡ŦđŸ‡Ŧ","flag_gh":"đŸ‡Ŧ🇭","flag_gi":"đŸ‡Ŧ🇮","flag_gl":"đŸ‡Ŧ🇱","flag_gm":"đŸ‡Ŧ🇲","flag_gn":"đŸ‡ŦđŸ‡ŗ","flag_gp":"đŸ‡ŦđŸ‡ĩ","flag_gq":"đŸ‡ŦđŸ‡ļ","flag_gr":"đŸ‡Ŧ🇷","flag_gs":"đŸ‡Ŧ🇸","flag_gt":"đŸ‡Ŧ🇹","flag_gu":"đŸ‡ŦđŸ‡ē","flag_gw":"đŸ‡ŦđŸ‡ŧ","flag_gy":"đŸ‡Ŧ🇾","flag_hk":"🇭🇰","flag_hm":"🇭🇲","flag_hn":"đŸ‡­đŸ‡ŗ","flag_hr":"🇭🇷","flag_ht":"🇭🇹","flag_hu":"🇭đŸ‡ē","flag_ic":"🇮🇨","flag_id":"🇮🇩","flag_ie":"🇮đŸ‡Ē","flag_il":"🇮🇱","flag_im":"🇮🇲","flag_in":"đŸ‡ŽđŸ‡ŗ","flag_io":"🇮🇴","flag_iq":"🇮đŸ‡ļ","flag_ir":"🇮🇷","flag_is":"🇮🇸","flag_it":"🇮🇹","flag_je":"đŸ‡¯đŸ‡Ē","flag_jm":"đŸ‡¯đŸ‡˛","flag_jo":"đŸ‡¯đŸ‡´","flag_jp":"đŸ‡¯đŸ‡ĩ","flag_ke":"🇰đŸ‡Ē","flag_kg":"🇰đŸ‡Ŧ","flag_kh":"🇰🇭","flag_ki":"🇰🇮","flag_km":"🇰🇲","flag_kn":"đŸ‡°đŸ‡ŗ","flag_kp":"🇰đŸ‡ĩ","flag_kr":"🇰🇷","flag_kw":"🇰đŸ‡ŧ","flag_ky":"🇰🇾","flag_kz":"🇰đŸ‡ŋ","flag_la":"🇱đŸ‡Ļ","flag_lb":"🇱🇧","flag_lc":"🇱🇨","flag_li":"🇱🇮","flag_lk":"🇱🇰","flag_lr":"🇱🇷","flag_ls":"🇱🇸","flag_lt":"🇱🇹","flag_lu":"🇱đŸ‡ē","flag_lv":"🇱đŸ‡ģ","flag_ly":"🇱🇾","flag_ma":"🇲đŸ‡Ļ","flag_mc":"🇲🇨","flag_md":"🇲🇩","flag_me":"🇲đŸ‡Ē","flag_mf":"🇲đŸ‡Ģ","flag_mg":"🇲đŸ‡Ŧ","flag_mh":"🇲🇭","flag_mk":"🇲🇰","flag_ml":"🇲🇱","flag_mm":"🇲🇲","flag_mn":"đŸ‡˛đŸ‡ŗ","flag_mo":"🇲🇴","flag_mp":"🇲đŸ‡ĩ","flag_mq":"🇲đŸ‡ļ","flag_mr":"🇲🇷","flag_ms":"🇲🇸","flag_mt":"🇲🇹","flag_mu":"🇲đŸ‡ē","flag_mv":"🇲đŸ‡ģ","flag_mw":"🇲đŸ‡ŧ","flag_mx":"🇲đŸ‡Ŋ","flag_my":"🇲🇾","flag_mz":"🇲đŸ‡ŋ","flag_na":"đŸ‡ŗđŸ‡Ļ","flag_nc":"đŸ‡ŗđŸ‡¨","flag_ne":"đŸ‡ŗđŸ‡Ē","flag_nf":"đŸ‡ŗđŸ‡Ģ","flag_ng":"đŸ‡ŗđŸ‡Ŧ","flag_ni":"đŸ‡ŗđŸ‡Ž","flag_nl":"đŸ‡ŗđŸ‡ą","flag_no":"đŸ‡ŗđŸ‡´","flag_np":"đŸ‡ŗđŸ‡ĩ","flag_nr":"đŸ‡ŗđŸ‡ˇ","flag_nu":"đŸ‡ŗđŸ‡ē","flag_nz":"đŸ‡ŗđŸ‡ŋ","flag_om":"🇴🇲","flag_pa":"đŸ‡ĩđŸ‡Ļ","flag_pe":"đŸ‡ĩđŸ‡Ē","flag_pf":"đŸ‡ĩđŸ‡Ģ","flag_pg":"đŸ‡ĩđŸ‡Ŧ","flag_ph":"đŸ‡ĩ🇭","flag_pk":"đŸ‡ĩ🇰","flag_pl":"đŸ‡ĩ🇱","flag_pm":"đŸ‡ĩ🇲","flag_pn":"đŸ‡ĩđŸ‡ŗ","flag_pr":"đŸ‡ĩ🇷","flag_ps":"đŸ‡ĩ🇸","flag_pt":"đŸ‡ĩ🇹","flag_pw":"đŸ‡ĩđŸ‡ŧ","flag_py":"đŸ‡ĩ🇾","flag_qa":"đŸ‡ļđŸ‡Ļ","flag_re":"🇷đŸ‡Ē","flag_ro":"🇷🇴","flag_rs":"🇷🇸","flag_ru":"🇷đŸ‡ē","flag_rw":"🇷đŸ‡ŧ","flag_sa":"🇸đŸ‡Ļ","flag_sb":"🇸🇧","flag_sc":"🇸🇨","flag_sd":"🇸🇩","flag_se":"🇸đŸ‡Ē","flag_sg":"🇸đŸ‡Ŧ","flag_sh":"🇸🇭","flag_si":"🇸🇮","flag_sj":"đŸ‡¸đŸ‡¯","flag_sk":"🇸🇰","flag_sl":"🇸🇱","flag_sm":"🇸🇲","flag_sn":"đŸ‡¸đŸ‡ŗ","flag_so":"🇸🇴","flag_sr":"🇸🇷","flag_ss":"🇸🇸","flag_st":"🇸🇹","flag_sv":"🇸đŸ‡ģ","flag_sx":"🇸đŸ‡Ŋ","flag_sy":"🇸🇾","flag_sz":"🇸đŸ‡ŋ","flag_ta":"🇹đŸ‡Ļ","flag_tc":"🇹🇨","flag_td":"🇹🇩","flag_tf":"🇹đŸ‡Ģ","flag_tg":"🇹đŸ‡Ŧ","flag_th":"🇹🇭","flag_tj":"đŸ‡šđŸ‡¯","flag_tk":"🇹🇰","flag_tl":"🇹🇱","flag_tm":"🇹🇲","flag_tn":"đŸ‡šđŸ‡ŗ","flag_to":"🇹🇴","flag_tr":"🇹🇷","flag_tt":"🇹🇹","flag_tv":"🇹đŸ‡ģ","flag_tw":"🇹đŸ‡ŧ","flag_tz":"🇹đŸ‡ŋ","flag_ua":"đŸ‡ēđŸ‡Ļ","flag_ug":"đŸ‡ēđŸ‡Ŧ","flag_um":"đŸ‡ē🇲","flag_un":"đŸ‡ēđŸ‡ŗ","flag_us":"đŸ‡ē🇸","flag_uy":"đŸ‡ē🇾","flag_uz":"đŸ‡ēđŸ‡ŋ","flag_va":"đŸ‡ģđŸ‡Ļ","flag_vc":"đŸ‡ģ🇨","flag_ve":"đŸ‡ģđŸ‡Ē","flag_vg":"đŸ‡ģđŸ‡Ŧ","flag_vi":"đŸ‡ģ🇮","flag_vn":"đŸ‡ģđŸ‡ŗ","flag_vu":"đŸ‡ģđŸ‡ē","flag_wf":"đŸ‡ŧđŸ‡Ģ","flag_ws":"đŸ‡ŧ🇸","flag_xk":"đŸ‡Ŋ🇰","flag_ye":"🇾đŸ‡Ē","flag_yt":"🇾🇹","flag_za":"đŸ‡ŋđŸ‡Ļ","flag_zm":"đŸ‡ŋ🇲","flag_zw":"đŸ‡ŋđŸ‡ŧ","flags":"🎏","flamingo":"đŸĻŠ","flashlight":"đŸ”Ļ","flat_shoe":"đŸĨŋ","flatbread":"đŸĢ“","fleur-de-lis":"⚜","fleur_de_lis":"⚜","flight_arrival":"đŸ›Ŧ","flight_departure":"đŸ›Ģ","flipper":"đŸŦ","floppy_disk":"💾","flower_playing_cards":"🎴","flushed":"đŸ˜ŗ","flushed_face":"đŸ˜ŗ","fly":"đŸĒ°","flying_disc":"đŸĨ","flying_saucer":"🛸","fog":"đŸŒĢ","foggy":"🌁","folded_hands":"🙏","folded_hands_tone1":"🙏đŸģ","folded_hands_tone2":"🙏đŸŧ","folded_hands_tone3":"🙏đŸŊ","folded_hands_tone4":"🙏🏾","folded_hands_tone5":"🙏đŸŋ","fondue":"đŸĢ•","foot":"đŸĻļ","foot_tone1":"đŸĻļđŸģ","foot_tone2":"đŸĻļđŸŧ","foot_tone3":"đŸĻļđŸŊ","foot_tone4":"đŸĻļ🏾","foot_tone5":"đŸĻļđŸŋ","football":"🏈","footprints":"đŸ‘Ŗ","fork_and_knife":"🍴","fork_knife_plate":"đŸŊ","fortune_cookie":"đŸĨ ","fountain":"⛲","fountain_pen":"🖋","four":"4ī¸âƒŖ","four_leaf_clover":"🍀","fox":"đŸĻŠ","fox_face":"đŸĻŠ","fr":"đŸ‡Ģ🇷","frame_with_picture":"đŸ–ŧ","framed_picture":"đŸ–ŧ","france":"đŸ‡Ģ🇷","free":"🆓","french_fries":"🍟","french_guiana":"đŸ‡ŦđŸ‡Ģ","french_polynesia":"đŸ‡ĩđŸ‡Ģ","french_southern_territories":"🇹đŸ‡Ģ","fried_egg":"đŸŗ","fried_shrimp":"🍤","fries":"🍟","frog":"🐸","frog_face":"🐸","frowning":"đŸ˜Ļ","frowning_face":"☚","frowning_man":"đŸ™â€â™‚ī¸","frowning_person":"🙍","frowning_woman":"đŸ™â€â™€ī¸","fu":"🖕","fuelpump":"â›Ŋ","full_moon":"🌕","full_moon_with_face":"🌝","funeral_urn":"⚱","gabon":"đŸ‡ŦđŸ‡Ļ","gambia":"đŸ‡Ŧ🇲","game_die":"🎲","garlic":"🧄","gasp":"đŸĢĸ","gb":"đŸ‡Ŧ🇧","gear":"⚙","gem":"💎","gemini":"♊","genie":"🧞","genie_man":"đŸ§žâ€â™‚ī¸","genie_woman":"đŸ§žâ€â™€ī¸","georgia":"đŸ‡ŦđŸ‡Ē","germany":"🇩đŸ‡Ē","ghana":"đŸ‡Ŧ🇭","ghost":"đŸ‘ģ","gibraltar":"đŸ‡Ŧ🇮","gift":"🎁","gift_heart":"💝","giraffe":"đŸĻ’","girl":"👧","girl_tone1":"👧đŸģ","girl_tone2":"👧đŸŧ","girl_tone3":"👧đŸŊ","girl_tone4":"👧🏾","girl_tone5":"👧đŸŋ","glass_of_milk":"đŸĨ›","glasses":"👓","globe_with_meridian":"🌐","globe_with_meridians":"🌐","gloves":"🧤","glowing_star":"🌟","goal_ne":"đŸĨ…","goal_net":"đŸĨ…","goat":"🐐","goblin":"đŸ‘ē","goggles":"đŸĨŊ","golf":"â›ŗ","golfer":"🏌","golfer_tone1":"🏌đŸģ","golfer_tone2":"🏌đŸŧ","golfer_tone3":"🏌đŸŊ","golfer_tone4":"🏌🏾","golfer_tone5":"🏌đŸŋ","golfing":"🏌","golfing_man":"đŸŒī¸â€â™‚ī¸","golfing_tone1":"🏌đŸģ","golfing_tone2":"🏌đŸŧ","golfing_tone3":"🏌đŸŊ","golfing_tone4":"🏌🏾","golfing_tone5":"🏌đŸŋ","golfing_woman":"đŸŒī¸â€â™€ī¸","gorilla":"đŸĻ","graduation_cap":"🎓","grapes":"🍇","greece":"đŸ‡Ŧ🇷","green_apple":"🍏","green_book":"📗","green_circle":"đŸŸĸ","green_hear":"💚","green_heart":"💚","green_salad":"đŸĨ—","green_square":"🟩","greenland":"đŸ‡Ŧ🇱","grenada":"đŸ‡Ŧ🇩","grey_exclamation":"❕","grey_question":"❔","grimacing":"đŸ˜Ŧ","grimacing_face":"đŸ˜Ŧ","grin":"😁","grinning":"😀","grinning_cat":"đŸ˜ē","grinning_cat_with_closed_eyes":"😸","grinning_face":"😀","grinning_face_with_big_eyes":"😃","grinning_face_with_closed_eyes":"😄","grinning_face_with_sweat":"😅","growing_heart":"💗","guadeloupe":"đŸ‡ŦđŸ‡ĩ","guam":"đŸ‡ŦđŸ‡ē","guard":"💂","guard_tone1":"💂đŸģ","guard_tone2":"💂đŸŧ","guard_tone3":"💂đŸŊ","guard_tone4":"💂🏾","guard_tone5":"💂đŸŋ","guardsman":"đŸ’‚â€â™‚ī¸","guardswoman":"đŸ’‚â€â™€ī¸","guatemala":"đŸ‡Ŧ🇹","guernsey":"đŸ‡ŦđŸ‡Ŧ","guide_dog":"đŸĻŽ","guinea":"đŸ‡ŦđŸ‡ŗ","guinea_bissau":"đŸ‡ŦđŸ‡ŧ","guitar":"🎸","gun":"đŸ”Ģ","guyana":"đŸ‡Ŧ🇾","haircut":"💇","haircut_man":"đŸ’‡â€â™‚ī¸","haircut_tone1":"💇đŸģ","haircut_tone2":"💇đŸŧ","haircut_tone3":"💇đŸŊ","haircut_tone4":"💇🏾","haircut_tone5":"💇đŸŋ","haircut_woman":"đŸ’‡â€â™€ī¸","haiti":"🇭🇹","halo":"😇","hamburger":"🍔","hamme":"🔨","hammer":"🔨","hammer_and_pick":"⚒","hammer_and_wrench":"🛠","hamsa":"đŸĒŦ","hamster":"🐹","hamster_face":"🐹","hand":"✋","hand_over_mouth":"🤭","hand_with_index_finger_and_thumb_crossed":"đŸĢ°","hand_with_index_finger_and_thumb_crossed_tone1":"đŸĢ°đŸģ","hand_with_index_finger_and_thumb_crossed_tone2":"đŸĢ°đŸŧ","hand_with_index_finger_and_thumb_crossed_tone3":"đŸĢ°đŸŊ","hand_with_index_finger_and_thumb_crossed_tone4":"đŸĢ°đŸž","hand_with_index_finger_and_thumb_crossed_tone5":"đŸĢ°đŸŋ","handbag":"👜","handball":"🤾","handball_person":"🤾","handball_tone1":"🤾đŸģ","handball_tone2":"🤾đŸŧ","handball_tone3":"🤾đŸŊ","handball_tone4":"🤾🏾","handball_tone5":"🤾đŸŋ","handicapped":"â™ŋ","handshake":"🤝","handshake_tone1":"🤝đŸģ","handshake_tone1-2":"đŸĢąđŸģ‍đŸĢ˛đŸŧ","handshake_tone1-3":"đŸĢąđŸģ‍đŸĢ˛đŸŊ","handshake_tone1-4":"đŸĢąđŸģ‍đŸĢ˛đŸž","handshake_tone1-5":"đŸĢąđŸģ‍đŸĢ˛đŸŋ","handshake_tone2":"🤝đŸŧ","handshake_tone2-1":"đŸĢąđŸŧ‍đŸĢ˛đŸģ","handshake_tone2-3":"đŸĢąđŸŧ‍đŸĢ˛đŸŊ","handshake_tone2-4":"đŸĢąđŸŧ‍đŸĢ˛đŸž","handshake_tone2-5":"đŸĢąđŸŧ‍đŸĢ˛đŸŋ","handshake_tone3":"🤝đŸŊ","handshake_tone3-1":"đŸĢąđŸŊ‍đŸĢ˛đŸģ","handshake_tone3-2":"đŸĢąđŸŊ‍đŸĢ˛đŸŧ","handshake_tone3-4":"đŸĢąđŸŊ‍đŸĢ˛đŸž","handshake_tone3-5":"đŸĢąđŸŊ‍đŸĢ˛đŸŋ","handshake_tone4":"🤝🏾","handshake_tone4-1":"đŸĢąđŸžâ€đŸĢ˛đŸģ","handshake_tone4-2":"đŸĢąđŸžâ€đŸĢ˛đŸŧ","handshake_tone4-3":"đŸĢąđŸžâ€đŸĢ˛đŸŊ","handshake_tone4-5":"đŸĢąđŸžâ€đŸĢ˛đŸŋ","handshake_tone5":"🤝đŸŋ","handshake_tone5-1":"đŸĢąđŸŋ‍đŸĢ˛đŸģ","handshake_tone5-2":"đŸĢąđŸŋ‍đŸĢ˛đŸŧ","handshake_tone5-3":"đŸĢąđŸŋ‍đŸĢ˛đŸŊ","handshake_tone5-4":"đŸĢąđŸŋ‍đŸĢ˛đŸž","hankey":"💩","hash":"#ī¸âƒŖ","hatched_chick":"đŸĨ","hatching_chick":"đŸŖ","headphones":"🎧","headstone":"đŸĒĻ","health_worker":"đŸ§‘â€âš•ī¸","health_worker_tone1":"🧑đŸģâ€âš•ī¸","health_worker_tone2":"🧑đŸŧâ€âš•ī¸","health_worker_tone3":"🧑đŸŊâ€âš•ī¸","health_worker_tone4":"đŸ§‘đŸžâ€âš•ī¸","health_worker_tone5":"🧑đŸŋâ€âš•ī¸","hear_no_evil":"🙉","heard_mcdonald_islands":"🇭🇲","hearing_aid":"đŸĻģ","hearing_aid_tone1":"đŸĻģđŸģ","hearing_aid_tone2":"đŸĻģđŸŧ","hearing_aid_tone3":"đŸĻģđŸŊ","hearing_aid_tone4":"đŸĻģ🏾","hearing_aid_tone5":"đŸĻģđŸŋ","heart":"❤","heart_decoration":"💟","heart_exclamation":"âŖ","heart_eyes":"😍","heart_eyes_cat":"đŸ˜ģ","heart_hands":"đŸĢļ","heart_hands_tone1":"đŸĢļđŸģ","heart_hands_tone2":"đŸĢļđŸŧ","heart_hands_tone3":"đŸĢļđŸŊ","heart_hands_tone4":"đŸĢļ🏾","heart_hands_tone5":"đŸĢļđŸŋ","heart_on_fire":"â¤ī¸â€đŸ”Ĩ","heart_with_arrow":"💘","heart_with_ribbon":"💝","heartbeat":"💓","heartpulse":"💗","hearts":"â™Ĩ","heavy_check_mark":"✔","heavy_division_sign":"➗","heavy_dollar_sign":"💲","heavy_equals_sign":"🟰","heavy_exclamation_mark":"❗","heavy_heart_exclamation":"âŖ","heavy_minus_sig":"➖","heavy_minus_sign":"➖","heavy_multiplication_x":"✖","heavy_plus_sig":"➕","heavy_plus_sign":"➕","hedgehog":"đŸĻ”","helicopter":"🚁","helmet_with_cross":"⛑","herb":"đŸŒŋ","hibiscus":"đŸŒē","high_brightness":"🔆","high_five":"✋","high_five_tone1":"✋đŸģ","high_five_tone2":"✋đŸŧ","high_five_tone3":"✋đŸŊ","high_five_tone4":"✋🏾","high_five_tone5":"✋đŸŋ","high_heel":"👠","high_voltage":"⚡","high_volume":"🔊","hiking_boot":"đŸĨž","hindu_temple":"🛕","hippo":"đŸĻ›","hippopotamus":"đŸĻ›","hocho":"đŸ”Ē","hockey":"🏒","hole":"đŸ•ŗ","hollow_red_circle":"⭕","homes":"🏘","honduras":"đŸ‡­đŸ‡ŗ","honey_pot":"đŸ¯","honeybee":"🐝","hong_kong":"🇭🇰","hook":"đŸĒ","hooray":"đŸĨŗ","horse":"🐴","horse_face":"🐴","horse_racing":"🏇","horse_racing_tone1":"🏇đŸģ","horse_racing_tone2":"🏇đŸŧ","horse_racing_tone3":"🏇đŸŊ","horse_racing_tone4":"🏇🏾","horse_racing_tone5":"🏇đŸŋ","hospital":"đŸĨ","hot":"đŸĨĩ","hot_face":"đŸĨĩ","hot_pepper":"đŸŒļ","hotdog":"🌭","hotel":"🏨","hotsprings":"♨","hourglass":"⌛","hourglass_flowing_sand":"âŗ","house":"🏠","house_abandoned":"🏚","house_with_garden":"🏡","houses":"🏘","hug":"🤗","hugging":"🤗","hugging_face":"🤗","hugs":"🤗","hungary":"🇭đŸ‡ē","hushed":"đŸ˜¯","hushed_face":"đŸ˜¯","hut":"🛖","ice":"🧊","ice_cream":"🍨","ice_cube":"🧊","ice_hockey":"🏒","ice_skate":"⛸","icecream":"đŸĻ","iceland":"🇮🇸","id":"🆔","id_card":"đŸĒĒ","ideograph_advantage":"🉐","imp":"đŸ‘ŋ","in_clouds":"đŸ˜ļ‍đŸŒĢī¸","inbox_tray":"đŸ“Ĩ","incoming_envelope":"📨","india":"đŸ‡ŽđŸ‡ŗ","indonesia":"🇮🇩","infinity":"♾","info":"ℹ","information_desk_person":"💁","information_source":"ℹ","innocent":"😇","interrobang":"⁉","iphon":"📱","iphone":"📱","iran":"🇮🇷","iraq":"🇮đŸ‡ļ","ireland":"🇮đŸ‡Ē","island":"🏝","isle_of_man":"🇮🇲","israel":"🇮🇱","it":"🇮🇹","italy":"🇮🇹","izakaya_lantern":"🏮","ja_acceptable":"🉑","ja_application":"🈸","ja_bargain":"🉐","ja_congratulations":"㊗","ja_discount":"🈹","ja_free_of_charge":"🈚","ja_here":"🈁","ja_monthly_amount":"🈷","ja_no_vacancy":"đŸˆĩ","ja_not_free_of_carge":"đŸˆļ","ja_open_for_business":"đŸˆē","ja_passing_grade":"🈴","ja_prohibited":"🈲","ja_reserved":"đŸˆ¯","ja_secret":"㊙","ja_service_charge":"🈂","ja_vacancy":"đŸˆŗ","jack_o_lantern":"🎃","jamaica":"đŸ‡¯đŸ‡˛","japan":"🗾","japan_map":"🗾","japanese_castle":"đŸ¯","japanese_goblin":"đŸ‘ē","japanese_ogre":"👹","jar":"đŸĢ™","jeans":"👖","jersey":"đŸ‡¯đŸ‡Ē","jigsaw":"🧩","jolly_roger":"đŸ´â€â˜ ī¸","jordan":"đŸ‡¯đŸ‡´","joy":"😂","joy_cat":"😹","joystick":"🕹","jp":"đŸ‡¯đŸ‡ĩ","judge":"đŸ§‘â€âš–ī¸","judge_tone1":"🧑đŸģâ€âš–ī¸","judge_tone2":"🧑đŸŧâ€âš–ī¸","judge_tone3":"🧑đŸŊâ€âš–ī¸","judge_tone4":"đŸ§‘đŸžâ€âš–ī¸","judge_tone5":"🧑đŸŋâ€âš–ī¸","juggler":"🤹","juggler_tone1":"🤹đŸģ","juggler_tone2":"🤹đŸŧ","juggler_tone3":"🤹đŸŊ","juggler_tone4":"🤹🏾","juggler_tone5":"🤹đŸŋ","juggling":"🤹","juggling_person":"🤹","juggling_tone1":"🤹đŸģ","juggling_tone2":"🤹đŸŧ","juggling_tone3":"🤹đŸŊ","juggling_tone4":"🤹🏾","juggling_tone5":"🤹đŸŋ","juice_box":"🧃","kaaba":"🕋","kangaroo":"đŸĻ˜","kazakhstan":"🇰đŸ‡ŋ","kenya":"🇰đŸ‡Ē","key":"🔑","keyboard":"⌨","keycap_ten":"🔟","kick_scooter":"🛴","kimono":"👘","kiribati":"🇰🇮","kiss":"💋","kiss_mm":"đŸ‘¨â€â¤ī¸â€đŸ’‹â€đŸ‘¨","kiss_mm_tone1":"👨đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_mm_tone1-2":"👨đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_mm_tone1-3":"👨đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_mm_tone1-4":"👨đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_mm_tone1-5":"👨đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_mm_tone2":"👨đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_mm_tone2-1":"👨đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_mm_tone2-3":"👨đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_mm_tone2-4":"👨đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_mm_tone2-5":"👨đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_mm_tone3":"👨đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_mm_tone3-1":"👨đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_mm_tone3-2":"👨đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_mm_tone3-4":"👨đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_mm_tone3-5":"👨đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_mm_tone4":"đŸ‘¨đŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_mm_tone4-1":"đŸ‘¨đŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_mm_tone4-2":"đŸ‘¨đŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_mm_tone4-3":"đŸ‘¨đŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_mm_tone4-5":"đŸ‘¨đŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_mm_tone5":"👨đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_mm_tone5-1":"👨đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_mm_tone5-2":"👨đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_mm_tone5-3":"👨đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_mm_tone5-4":"👨đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_mw":"đŸ‘Šâ€â¤ī¸â€đŸ’‹â€đŸ‘¨","kiss_mw_tone1":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_mw_tone1-2":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_mw_tone1-3":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_mw_tone1-4":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_mw_tone1-5":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_mw_tone2":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_mw_tone2-1":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_mw_tone2-3":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_mw_tone2-4":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_mw_tone2-5":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_mw_tone3":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_mw_tone3-1":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_mw_tone3-2":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_mw_tone3-4":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_mw_tone3-5":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_mw_tone4":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_mw_tone4-1":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_mw_tone4-2":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_mw_tone4-3":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_mw_tone4-5":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_mw_tone5":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_mw_tone5-1":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_mw_tone5-2":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_mw_tone5-3":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_mw_tone5-4":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_wm":"đŸ‘Šâ€â¤ī¸â€đŸ’‹â€đŸ‘¨","kiss_wm_tone1":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_wm_tone1-2":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_wm_tone1-3":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_wm_tone1-4":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_wm_tone1-5":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_wm_tone2":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_wm_tone2-1":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_wm_tone2-3":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_wm_tone2-4":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_wm_tone2-5":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_wm_tone3":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_wm_tone3-1":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_wm_tone3-2":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_wm_tone3-4":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_wm_tone3-5":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_wm_tone4":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_wm_tone4-1":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_wm_tone4-2":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_wm_tone4-3":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_wm_tone4-5":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_wm_tone5":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŋ","kiss_wm_tone5-1":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸģ","kiss_wm_tone5-2":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŧ","kiss_wm_tone5-3":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸŊ","kiss_wm_tone5-4":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘¨đŸž","kiss_ww":"đŸ‘Šâ€â¤ī¸â€đŸ’‹â€đŸ‘Š","kiss_ww_tone1":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸģ","kiss_ww_tone1-2":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŧ","kiss_ww_tone1-3":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŊ","kiss_ww_tone1-4":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸž","kiss_ww_tone1-5":"👩đŸģâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŋ","kiss_ww_tone2":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŧ","kiss_ww_tone2-1":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸģ","kiss_ww_tone2-3":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŊ","kiss_ww_tone2-4":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸž","kiss_ww_tone2-5":"👩đŸŧâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŋ","kiss_ww_tone3":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŊ","kiss_ww_tone3-1":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸģ","kiss_ww_tone3-2":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŧ","kiss_ww_tone3-4":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸž","kiss_ww_tone3-5":"👩đŸŊâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŋ","kiss_ww_tone4":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸž","kiss_ww_tone4-1":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸģ","kiss_ww_tone4-2":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŧ","kiss_ww_tone4-3":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŊ","kiss_ww_tone4-5":"đŸ‘ŠđŸžâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŋ","kiss_ww_tone5":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŋ","kiss_ww_tone5-1":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸģ","kiss_ww_tone5-2":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŧ","kiss_ww_tone5-3":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸŊ","kiss_ww_tone5-4":"👩đŸŋâ€â¤ī¸â€đŸ’‹â€đŸ‘ŠđŸž","kissing":"😗","kissing_cat":"đŸ˜Ŋ","kissing_closed_eyes":"😚","kissing_face":"😗","kissing_face_with_closed_eyes":"😚","kissing_face_with_smiling_eyes":"😙","kissing_heart":"😘","kissing_smiling_eyes":"😙","kite":"đŸĒ","kiwi":"đŸĨ","kiwi_fruit":"đŸĨ","kneeling":"🧎","kneeling_man":"đŸ§Žâ€â™‚ī¸","kneeling_person":"🧎","kneeling_tone1":"🧎đŸģ","kneeling_tone2":"🧎đŸŧ","kneeling_tone3":"🧎đŸŊ","kneeling_tone4":"🧎🏾","kneeling_tone5":"🧎đŸŋ","kneeling_woman":"đŸ§Žâ€â™€ī¸","knife":"đŸ”Ē","knocked_out":"đŸ˜ĩ","knot":"đŸĒĸ","koala":"🐨","koala_face":"🐨","koko":"🈁","kosovo":"đŸ‡Ŋ🇰","kr":"🇰🇷","kuwait":"🇰đŸ‡ŧ","kyrgyzstan":"🇰đŸ‡Ŧ","lab_coat":"đŸĨŧ","labe":"đŸˇī¸","label":"🏷","lacrosse":"đŸĨ","ladder":"đŸĒœ","lady_beetle":"🐞","lantern":"🏮","laos":"🇱đŸ‡Ļ","laptop":"đŸ’ģ","large_blue_circle":"đŸ”ĩ","large_blue_diamond":"🔷","large_orange_diamond":"đŸ”ļ","last_quarter_moon":"🌗","last_quarter_moon_with_face":"🌜","latin_cross":"✝","latvia":"🇱đŸ‡ģ","laughing":"😆","leafy_green":"đŸĨŦ","leaves":"🍃","lebanon":"🇱🇧","ledger":"📒","left_facing_fist":"🤛","left_facing_fist_tone1":"🤛đŸģ","left_facing_fist_tone2":"🤛đŸŧ","left_facing_fist_tone3":"🤛đŸŊ","left_facing_fist_tone4":"🤛🏾","left_facing_fist_tone5":"🤛đŸŋ","left_luggage":"🛅","left_right_arrow":"↔","left_speech_bubble":"🗨","leftwards_arrow_with_hook":"↩","leftwards_hand":"đŸĢ˛","leftwards_hand_tone1":"đŸĢ˛đŸģ","leftwards_hand_tone2":"đŸĢ˛đŸŧ","leftwards_hand_tone3":"đŸĢ˛đŸŊ","leftwards_hand_tone4":"đŸĢ˛đŸž","leftwards_hand_tone5":"đŸĢ˛đŸŋ","leg":"đŸĻĩ","leg_tone1":"đŸĻĩđŸģ","leg_tone2":"đŸĻĩđŸŧ","leg_tone3":"đŸĻĩđŸŊ","leg_tone4":"đŸĻĩ🏾","leg_tone5":"đŸĻĩđŸŋ","lemon":"🍋","leo":"♌","leopard":"🐆","lesotho":"🇱🇸","level_slider":"🎚","levitate":"🕴","levitate_tone1":"🕴đŸģ","levitate_tone2":"🕴đŸŧ","levitate_tone3":"🕴đŸŊ","levitate_tone4":"🕴🏾","levitate_tone5":"🕴đŸŋ","levitating":"🕴","levitating_tone1":"🕴đŸģ","levitating_tone2":"🕴đŸŧ","levitating_tone3":"🕴đŸŊ","levitating_tone4":"🕴🏾","levitating_tone5":"🕴đŸŋ","liberia":"🇱🇷","libra":"♎","libya":"🇱🇾","liechtenstein":"🇱🇮","lifebuoy":"🛟","light_bulb":"💡","light_rail":"🚈","lightning":"🌩","link":"🔗","lion":"đŸĻ","lion_face":"đŸĻ","lips":"👄","lipstic":"💄","lipstick":"💄","lithuania":"🇱🇹","litter_bin":"🚮","lizard":"đŸĻŽ","llama":"đŸĻ™","lmao":"😂","lobster":"đŸĻž","loc":"đŸ”’ī¸","lock":"🔒","lock_with_ink_pen":"🔏","locked":"🔒","locked_with_key":"🔐","locked_with_pen":"🔏","lol":"😆","lollipop":"🍭","long_drum":"đŸĒ˜","loop":"âžŋ","lotion_bottle":"🧴","lotus":"đŸĒˇ","lotus_position":"🧘","lotus_position_man":"đŸ§˜â€â™‚ī¸","lotus_position_woman":"đŸ§˜â€â™€ī¸","loud_soun":"🔊","loud_sound":"🔊","loudly_crying_face":"😭","loudspeaker":"đŸ“ĸ","love_hotel":"🏩","love_letter":"💌","love_you_gesture":"🤟","love_you_gesture_tone1":"🤟đŸģ","love_you_gesture_tone2":"🤟đŸŧ","love_you_gesture_tone3":"🤟đŸŊ","love_you_gesture_tone4":"🤟🏾","love_you_gesture_tone5":"🤟đŸŋ","low_battery":"đŸĒĢ","low_brightness":"🔅","low_volume":"🔈","luggage":"đŸ§ŗ","lungs":"đŸĢ","luxembourg":"🇱đŸ‡ē","lying":"đŸ¤Ĩ","lying_face":"đŸ¤Ĩ","m":"Ⓜ","ma":"đŸ”ī¸","macao":"🇲🇴","macau":"🇲🇴","macedonia":"🇲🇰","madagascar":"🇲đŸ‡Ŧ","mag":"🔍","mag_right":"🔎","mage":"🧙","mage_man":"đŸ§™â€â™‚ī¸","mage_tone1":"🧙đŸģ","mage_tone2":"🧙đŸŧ","mage_tone3":"🧙đŸŊ","mage_tone4":"🧙🏾","mage_tone5":"🧙đŸŋ","mage_woman":"đŸ§™â€â™€ī¸","magic_wand":"đŸĒ„","magnet":"🧲","mahjong":"🀄","mailbox":"đŸ“Ģ","mailbox_closed":"đŸ“Ē","mailbox_with_mail":"đŸ“Ŧ","mailbox_with_no_mail":"📭","malawi":"🇲đŸ‡ŧ","malaysia":"🇲🇾","maldives":"🇲đŸ‡ģ","male":"♂","male_detective":"đŸ•ĩī¸â€â™‚ī¸","male_sign":"♂","mali":"🇲🇱","malta":"🇲🇹","mammoth":"đŸĻŖ","man":"👨","man_artist":"👨‍🎨","man_artist_tone1":"👨đŸģ‍🎨","man_artist_tone2":"👨đŸŧ‍🎨","man_artist_tone3":"👨đŸŊ‍🎨","man_artist_tone4":"👨🏾‍🎨","man_artist_tone5":"👨đŸŋ‍🎨","man_astronaut":"👨‍🚀","man_astronaut_tone1":"👨đŸģ‍🚀","man_astronaut_tone2":"👨đŸŧ‍🚀","man_astronaut_tone3":"👨đŸŊ‍🚀","man_astronaut_tone4":"👨🏾‍🚀","man_astronaut_tone5":"👨đŸŋ‍🚀","man_bald":"👨‍đŸĻ˛","man_bald_tone1":"👨đŸģ‍đŸĻ˛","man_bald_tone2":"👨đŸŧ‍đŸĻ˛","man_bald_tone3":"👨đŸŊ‍đŸĻ˛","man_bald_tone4":"👨🏾‍đŸĻ˛","man_bald_tone5":"👨đŸŋ‍đŸĻ˛","man_beard":"đŸ§”â€â™‚ī¸","man_bearded":"đŸ§”â€â™‚ī¸","man_bearded_tone1":"🧔đŸģâ€â™‚ī¸","man_bearded_tone2":"🧔đŸŧâ€â™‚ī¸","man_bearded_tone3":"🧔đŸŊâ€â™‚ī¸","man_bearded_tone4":"đŸ§”đŸžâ€â™‚ī¸","man_bearded_tone5":"🧔đŸŋâ€â™‚ī¸","man_biking":"đŸš´â€â™‚ī¸","man_biking_tone1":"🚴đŸģâ€â™‚ī¸","man_biking_tone2":"🚴đŸŧâ€â™‚ī¸","man_biking_tone3":"🚴đŸŊâ€â™‚ī¸","man_biking_tone4":"đŸš´đŸžâ€â™‚ī¸","man_biking_tone5":"🚴đŸŋâ€â™‚ī¸","man_blond_haired":"đŸ‘ąâ€â™‚ī¸","man_blond_haired_tone1":"👱đŸģâ€â™‚ī¸","man_blond_haired_tone2":"👱đŸŧâ€â™‚ī¸","man_blond_haired_tone3":"👱đŸŊâ€â™‚ī¸","man_blond_haired_tone4":"đŸ‘ąđŸžâ€â™‚ī¸","man_blond_haired_tone5":"👱đŸŋâ€â™‚ī¸","man_bouncing_ball":"â›šī¸â€â™‚ī¸","man_bouncing_ball_tone1":"⛹đŸģâ€â™‚ī¸","man_bouncing_ball_tone2":"⛹đŸŧâ€â™‚ī¸","man_bouncing_ball_tone3":"⛹đŸŊâ€â™‚ī¸","man_bouncing_ball_tone4":"â›šđŸžâ€â™‚ī¸","man_bouncing_ball_tone5":"⛹đŸŋâ€â™‚ī¸","man_bowing":"đŸ™‡â€â™‚ī¸","man_bowing_tone1":"🙇đŸģâ€â™‚ī¸","man_bowing_tone2":"🙇đŸŧâ€â™‚ī¸","man_bowing_tone3":"🙇đŸŊâ€â™‚ī¸","man_bowing_tone4":"đŸ™‡đŸžâ€â™‚ī¸","man_bowing_tone5":"🙇đŸŋâ€â™‚ī¸","man_cartwheeling":"đŸ¤¸â€â™‚ī¸","man_cartwheeling_tone1":"🤸đŸģâ€â™‚ī¸","man_cartwheeling_tone2":"🤸đŸŧâ€â™‚ī¸","man_cartwheeling_tone3":"🤸đŸŊâ€â™‚ī¸","man_cartwheeling_tone4":"đŸ¤¸đŸžâ€â™‚ī¸","man_cartwheeling_tone5":"🤸đŸŋâ€â™‚ī¸","man_climbing":"đŸ§—â€â™‚ī¸","man_climbing_tone1":"🧗đŸģâ€â™‚ī¸","man_climbing_tone2":"🧗đŸŧâ€â™‚ī¸","man_climbing_tone3":"🧗đŸŊâ€â™‚ī¸","man_climbing_tone4":"đŸ§—đŸžâ€â™‚ī¸","man_climbing_tone5":"🧗đŸŋâ€â™‚ī¸","man_construction_worker":"đŸ‘ˇâ€â™‚ī¸","man_construction_worker_tone1":"👷đŸģâ€â™‚ī¸","man_construction_worker_tone2":"👷đŸŧâ€â™‚ī¸","man_construction_worker_tone3":"👷đŸŊâ€â™‚ī¸","man_construction_worker_tone4":"đŸ‘ˇđŸžâ€â™‚ī¸","man_construction_worker_tone5":"👷đŸŋâ€â™‚ī¸","man_cook":"đŸ‘¨â€đŸŗ","man_cook_tone1":"👨đŸģâ€đŸŗ","man_cook_tone2":"👨đŸŧâ€đŸŗ","man_cook_tone3":"👨đŸŊâ€đŸŗ","man_cook_tone4":"đŸ‘¨đŸžâ€đŸŗ","man_cook_tone5":"👨đŸŋâ€đŸŗ","man_curly_haired":"👨‍đŸĻą","man_curly_haired_tone1":"👨đŸģ‍đŸĻą","man_curly_haired_tone2":"👨đŸŧ‍đŸĻą","man_curly_haired_tone3":"👨đŸŊ‍đŸĻą","man_curly_haired_tone4":"👨🏾‍đŸĻą","man_curly_haired_tone5":"👨đŸŋ‍đŸĻą","man_dancing":"đŸ•ē","man_dancing_tone1":"đŸ•ēđŸģ","man_dancing_tone2":"đŸ•ēđŸŧ","man_dancing_tone3":"đŸ•ēđŸŊ","man_dancing_tone4":"đŸ•ē🏾","man_dancing_tone5":"đŸ•ēđŸŋ","man_detective":"đŸ•ĩī¸â€â™‚ī¸","man_detective_tone1":"đŸ•ĩđŸģâ€â™‚ī¸","man_detective_tone2":"đŸ•ĩđŸŧâ€â™‚ī¸","man_detective_tone3":"đŸ•ĩđŸŊâ€â™‚ī¸","man_detective_tone4":"đŸ•ĩđŸžâ€â™‚ī¸","man_detective_tone5":"đŸ•ĩđŸŋâ€â™‚ī¸","man_elf":"đŸ§â€â™‚ī¸","man_elf_tone1":"🧝đŸģâ€â™‚ī¸","man_elf_tone2":"🧝đŸŧâ€â™‚ī¸","man_elf_tone3":"🧝đŸŊâ€â™‚ī¸","man_elf_tone4":"đŸ§đŸžâ€â™‚ī¸","man_elf_tone5":"🧝đŸŋâ€â™‚ī¸","man_facepalming":"đŸ¤Ļâ€â™‚ī¸","man_facepalming_tone1":"đŸ¤ĻđŸģâ€â™‚ī¸","man_facepalming_tone2":"đŸ¤ĻđŸŧâ€â™‚ī¸","man_facepalming_tone3":"đŸ¤ĻđŸŊâ€â™‚ī¸","man_facepalming_tone4":"đŸ¤ĻđŸžâ€â™‚ī¸","man_facepalming_tone5":"đŸ¤ĻđŸŋâ€â™‚ī¸","man_factory_worker":"👨‍🏭","man_factory_worker_tone1":"👨đŸģ‍🏭","man_factory_worker_tone2":"👨đŸŧ‍🏭","man_factory_worker_tone3":"👨đŸŊ‍🏭","man_factory_worker_tone4":"👨🏾‍🏭","man_factory_worker_tone5":"👨đŸŋ‍🏭","man_fairy":"đŸ§šâ€â™‚ī¸","man_fairy_tone1":"🧚đŸģâ€â™‚ī¸","man_fairy_tone2":"🧚đŸŧâ€â™‚ī¸","man_fairy_tone3":"🧚đŸŊâ€â™‚ī¸","man_fairy_tone4":"đŸ§šđŸžâ€â™‚ī¸","man_fairy_tone5":"🧚đŸŋâ€â™‚ī¸","man_farmer":"👨‍🌾","man_farmer_tone1":"👨đŸģ‍🌾","man_farmer_tone2":"👨đŸŧ‍🌾","man_farmer_tone3":"👨đŸŊ‍🌾","man_farmer_tone4":"👨🏾‍🌾","man_farmer_tone5":"👨đŸŋ‍🌾","man_feeding_baby":"👨‍đŸŧ","man_feeding_baby_tone1":"👨đŸģ‍đŸŧ","man_feeding_baby_tone2":"👨đŸŧ‍đŸŧ","man_feeding_baby_tone3":"👨đŸŊ‍đŸŧ","man_feeding_baby_tone4":"👨🏾‍đŸŧ","man_feeding_baby_tone5":"👨đŸŋ‍đŸŧ","man_firefighter":"👨‍🚒","man_firefighter_tone1":"👨đŸģ‍🚒","man_firefighter_tone2":"👨đŸŧ‍🚒","man_firefighter_tone3":"👨đŸŊ‍🚒","man_firefighter_tone4":"👨🏾‍🚒","man_firefighter_tone5":"👨đŸŋ‍🚒","man_frowning":"đŸ™â€â™‚ī¸","man_frowning_tone1":"🙍đŸģâ€â™‚ī¸","man_frowning_tone2":"🙍đŸŧâ€â™‚ī¸","man_frowning_tone3":"🙍đŸŊâ€â™‚ī¸","man_frowning_tone4":"đŸ™đŸžâ€â™‚ī¸","man_frowning_tone5":"🙍đŸŋâ€â™‚ī¸","man_genie":"đŸ§žâ€â™‚ī¸","man_gesturing_no":"đŸ™…â€â™‚ī¸","man_gesturing_no_tone1":"🙅đŸģâ€â™‚ī¸","man_gesturing_no_tone2":"🙅đŸŧâ€â™‚ī¸","man_gesturing_no_tone3":"🙅đŸŊâ€â™‚ī¸","man_gesturing_no_tone4":"đŸ™…đŸžâ€â™‚ī¸","man_gesturing_no_tone5":"🙅đŸŋâ€â™‚ī¸","man_gesturing_ok":"đŸ™†â€â™‚ī¸","man_gesturing_ok_tone1":"🙆đŸģâ€â™‚ī¸","man_gesturing_ok_tone2":"🙆đŸŧâ€â™‚ī¸","man_gesturing_ok_tone3":"🙆đŸŊâ€â™‚ī¸","man_gesturing_ok_tone4":"đŸ™†đŸžâ€â™‚ī¸","man_gesturing_ok_tone5":"🙆đŸŋâ€â™‚ī¸","man_getting_haircut":"đŸ’‡â€â™‚ī¸","man_getting_haircut_tone1":"💇đŸģâ€â™‚ī¸","man_getting_haircut_tone2":"💇đŸŧâ€â™‚ī¸","man_getting_haircut_tone3":"💇đŸŊâ€â™‚ī¸","man_getting_haircut_tone4":"đŸ’‡đŸžâ€â™‚ī¸","man_getting_haircut_tone5":"💇đŸŋâ€â™‚ī¸","man_getting_massage":"đŸ’†â€â™‚ī¸","man_getting_massage_tone1":"💆đŸģâ€â™‚ī¸","man_getting_massage_tone2":"💆đŸŧâ€â™‚ī¸","man_getting_massage_tone3":"💆đŸŊâ€â™‚ī¸","man_getting_massage_tone4":"đŸ’†đŸžâ€â™‚ī¸","man_getting_massage_tone5":"💆đŸŋâ€â™‚ī¸","man_golfing":"đŸŒī¸â€â™‚ī¸","man_golfing_tone1":"🏌đŸģâ€â™‚ī¸","man_golfing_tone2":"🏌đŸŧâ€â™‚ī¸","man_golfing_tone3":"🏌đŸŊâ€â™‚ī¸","man_golfing_tone4":"đŸŒđŸžâ€â™‚ī¸","man_golfing_tone5":"🏌đŸŋâ€â™‚ī¸","man_guard":"đŸ’‚â€â™‚ī¸","man_guard_tone1":"💂đŸģâ€â™‚ī¸","man_guard_tone2":"💂đŸŧâ€â™‚ī¸","man_guard_tone3":"💂đŸŊâ€â™‚ī¸","man_guard_tone4":"đŸ’‚đŸžâ€â™‚ī¸","man_guard_tone5":"💂đŸŋâ€â™‚ī¸","man_health_worker":"đŸ‘¨â€âš•ī¸","man_health_worker_tone1":"👨đŸģâ€âš•ī¸","man_health_worker_tone2":"👨đŸŧâ€âš•ī¸","man_health_worker_tone3":"👨đŸŊâ€âš•ī¸","man_health_worker_tone4":"đŸ‘¨đŸžâ€âš•ī¸","man_health_worker_tone5":"👨đŸŋâ€âš•ī¸","man_in_lotus_position":"đŸ§˜â€â™‚ī¸","man_in_lotus_position_tone1":"🧘đŸģâ€â™‚ī¸","man_in_lotus_position_tone2":"🧘đŸŧâ€â™‚ī¸","man_in_lotus_position_tone3":"🧘đŸŊâ€â™‚ī¸","man_in_lotus_position_tone4":"đŸ§˜đŸžâ€â™‚ī¸","man_in_lotus_position_tone5":"🧘đŸŋâ€â™‚ī¸","man_in_manual_wheelchair":"👨‍đŸĻŊ","man_in_manual_wheelchair_tone1":"👨đŸģ‍đŸĻŊ","man_in_manual_wheelchair_tone2":"👨đŸŧ‍đŸĻŊ","man_in_manual_wheelchair_tone3":"👨đŸŊ‍đŸĻŊ","man_in_manual_wheelchair_tone4":"👨🏾‍đŸĻŊ","man_in_manual_wheelchair_tone5":"👨đŸŋ‍đŸĻŊ","man_in_motorized_wheelchair":"👨‍đŸĻŧ","man_in_motorized_wheelchair_tone1":"👨đŸģ‍đŸĻŧ","man_in_motorized_wheelchair_tone2":"👨đŸŧ‍đŸĻŧ","man_in_motorized_wheelchair_tone3":"👨đŸŊ‍đŸĻŧ","man_in_motorized_wheelchair_tone4":"👨🏾‍đŸĻŧ","man_in_motorized_wheelchair_tone5":"👨đŸŋ‍đŸĻŧ","man_in_steamy_room":"đŸ§–â€â™‚ī¸","man_in_steamy_room_tone1":"🧖đŸģâ€â™‚ī¸","man_in_steamy_room_tone2":"🧖đŸŧâ€â™‚ī¸","man_in_steamy_room_tone3":"🧖đŸŊâ€â™‚ī¸","man_in_steamy_room_tone4":"đŸ§–đŸžâ€â™‚ī¸","man_in_steamy_room_tone5":"🧖đŸŋâ€â™‚ī¸","man_in_tuxedo":"đŸ¤ĩâ€â™‚ī¸","man_in_tuxedo_tone1":"đŸ¤ĩđŸģâ€â™‚ī¸","man_in_tuxedo_tone2":"đŸ¤ĩđŸŧâ€â™‚ī¸","man_in_tuxedo_tone3":"đŸ¤ĩđŸŊâ€â™‚ī¸","man_in_tuxedo_tone4":"đŸ¤ĩđŸžâ€â™‚ī¸","man_in_tuxedo_tone5":"đŸ¤ĩđŸŋâ€â™‚ī¸","man_judge":"đŸ‘¨â€âš–ī¸","man_judge_tone1":"👨đŸģâ€âš–ī¸","man_judge_tone2":"👨đŸŧâ€âš–ī¸","man_judge_tone3":"👨đŸŊâ€âš–ī¸","man_judge_tone4":"đŸ‘¨đŸžâ€âš–ī¸","man_judge_tone5":"👨đŸŋâ€âš–ī¸","man_juggling":"đŸ¤šâ€â™‚ī¸","man_juggling_tone1":"🤹đŸģâ€â™‚ī¸","man_juggling_tone2":"🤹đŸŧâ€â™‚ī¸","man_juggling_tone3":"🤹đŸŊâ€â™‚ī¸","man_juggling_tone4":"đŸ¤šđŸžâ€â™‚ī¸","man_juggling_tone5":"🤹đŸŋâ€â™‚ī¸","man_kneeling":"đŸ§Žâ€â™‚ī¸","man_kneeling_tone1":"🧎đŸģâ€â™‚ī¸","man_kneeling_tone2":"🧎đŸŧâ€â™‚ī¸","man_kneeling_tone3":"🧎đŸŊâ€â™‚ī¸","man_kneeling_tone4":"đŸ§ŽđŸžâ€â™‚ī¸","man_kneeling_tone5":"🧎đŸŋâ€â™‚ī¸","man_lifting_weights":"đŸ‹ī¸â€â™‚ī¸","man_lifting_weights_tone1":"🏋đŸģâ€â™‚ī¸","man_lifting_weights_tone2":"🏋đŸŧâ€â™‚ī¸","man_lifting_weights_tone3":"🏋đŸŊâ€â™‚ī¸","man_lifting_weights_tone4":"đŸ‹đŸžâ€â™‚ī¸","man_lifting_weights_tone5":"🏋đŸŋâ€â™‚ī¸","man_mage":"đŸ§™â€â™‚ī¸","man_mage_tone1":"🧙đŸģâ€â™‚ī¸","man_mage_tone2":"🧙đŸŧâ€â™‚ī¸","man_mage_tone3":"🧙đŸŊâ€â™‚ī¸","man_mage_tone4":"đŸ§™đŸžâ€â™‚ī¸","man_mage_tone5":"🧙đŸŋâ€â™‚ī¸","man_mechanic":"👨‍🔧","man_mechanic_tone1":"👨đŸģ‍🔧","man_mechanic_tone2":"👨đŸŧ‍🔧","man_mechanic_tone3":"👨đŸŊ‍🔧","man_mechanic_tone4":"👨🏾‍🔧","man_mechanic_tone5":"👨đŸŋ‍🔧","man_mountain_biking":"đŸšĩâ€â™‚ī¸","man_mountain_biking_tone1":"đŸšĩđŸģâ€â™‚ī¸","man_mountain_biking_tone2":"đŸšĩđŸŧâ€â™‚ī¸","man_mountain_biking_tone3":"đŸšĩđŸŊâ€â™‚ī¸","man_mountain_biking_tone4":"đŸšĩđŸžâ€â™‚ī¸","man_mountain_biking_tone5":"đŸšĩđŸŋâ€â™‚ī¸","man_office_worker":"👨‍đŸ’ŧ","man_office_worker_tone1":"👨đŸģ‍đŸ’ŧ","man_office_worker_tone2":"👨đŸŧ‍đŸ’ŧ","man_office_worker_tone3":"👨đŸŊ‍đŸ’ŧ","man_office_worker_tone4":"👨🏾‍đŸ’ŧ","man_office_worker_tone5":"👨đŸŋ‍đŸ’ŧ","man_pilot":"đŸ‘¨â€âœˆī¸","man_pilot_tone1":"👨đŸģâ€âœˆī¸","man_pilot_tone2":"👨đŸŧâ€âœˆī¸","man_pilot_tone3":"👨đŸŊâ€âœˆī¸","man_pilot_tone4":"đŸ‘¨đŸžâ€âœˆī¸","man_pilot_tone5":"👨đŸŋâ€âœˆī¸","man_playing_handball":"đŸ¤žâ€â™‚ī¸","man_playing_handball_tone1":"🤾đŸģâ€â™‚ī¸","man_playing_handball_tone2":"🤾đŸŧâ€â™‚ī¸","man_playing_handball_tone3":"🤾đŸŊâ€â™‚ī¸","man_playing_handball_tone4":"đŸ¤žđŸžâ€â™‚ī¸","man_playing_handball_tone5":"🤾đŸŋâ€â™‚ī¸","man_playing_water_polo":"đŸ¤Ŋâ€â™‚ī¸","man_playing_water_polo_tone1":"đŸ¤ŊđŸģâ€â™‚ī¸","man_playing_water_polo_tone2":"đŸ¤ŊđŸŧâ€â™‚ī¸","man_playing_water_polo_tone3":"đŸ¤ŊđŸŊâ€â™‚ī¸","man_playing_water_polo_tone4":"đŸ¤ŊđŸžâ€â™‚ī¸","man_playing_water_polo_tone5":"đŸ¤ŊđŸŋâ€â™‚ī¸","man_police_officer":"đŸ‘Žâ€â™‚ī¸","man_police_officer_tone1":"👮đŸģâ€â™‚ī¸","man_police_officer_tone2":"👮đŸŧâ€â™‚ī¸","man_police_officer_tone3":"👮đŸŊâ€â™‚ī¸","man_police_officer_tone4":"đŸ‘ŽđŸžâ€â™‚ī¸","man_police_officer_tone5":"👮đŸŋâ€â™‚ī¸","man_pouting":"đŸ™Žâ€â™‚ī¸","man_pouting_tone1":"🙎đŸģâ€â™‚ī¸","man_pouting_tone2":"🙎đŸŧâ€â™‚ī¸","man_pouting_tone3":"🙎đŸŊâ€â™‚ī¸","man_pouting_tone4":"đŸ™ŽđŸžâ€â™‚ī¸","man_pouting_tone5":"🙎đŸŋâ€â™‚ī¸","man_raising_hand":"đŸ™‹â€â™‚ī¸","man_raising_hand_tone1":"🙋đŸģâ€â™‚ī¸","man_raising_hand_tone2":"🙋đŸŧâ€â™‚ī¸","man_raising_hand_tone3":"🙋đŸŊâ€â™‚ī¸","man_raising_hand_tone4":"đŸ™‹đŸžâ€â™‚ī¸","man_raising_hand_tone5":"🙋đŸŋâ€â™‚ī¸","man_red_haired":"👨‍đŸĻ°","man_red_haired_tone1":"👨đŸģ‍đŸĻ°","man_red_haired_tone2":"👨đŸŧ‍đŸĻ°","man_red_haired_tone3":"👨đŸŊ‍đŸĻ°","man_red_haired_tone4":"👨🏾‍đŸĻ°","man_red_haired_tone5":"👨đŸŋ‍đŸĻ°","man_rowing_boat":"đŸšŖâ€â™‚ī¸","man_rowing_boat_tone1":"đŸšŖđŸģâ€â™‚ī¸","man_rowing_boat_tone2":"đŸšŖđŸŧâ€â™‚ī¸","man_rowing_boat_tone3":"đŸšŖđŸŊâ€â™‚ī¸","man_rowing_boat_tone4":"đŸšŖđŸžâ€â™‚ī¸","man_rowing_boat_tone5":"đŸšŖđŸŋâ€â™‚ī¸","man_running":"đŸƒâ€â™‚ī¸","man_running_tone1":"🏃đŸģâ€â™‚ī¸","man_running_tone2":"🏃đŸŧâ€â™‚ī¸","man_running_tone3":"🏃đŸŊâ€â™‚ī¸","man_running_tone4":"đŸƒđŸžâ€â™‚ī¸","man_running_tone5":"🏃đŸŋâ€â™‚ī¸","man_scientist":"👨‍đŸ”Ŧ","man_scientist_tone1":"👨đŸģ‍đŸ”Ŧ","man_scientist_tone2":"👨đŸŧ‍đŸ”Ŧ","man_scientist_tone3":"👨đŸŊ‍đŸ”Ŧ","man_scientist_tone4":"👨🏾‍đŸ”Ŧ","man_scientist_tone5":"👨đŸŋ‍đŸ”Ŧ","man_shrugging":"đŸ¤ˇâ€â™‚ī¸","man_shrugging_tone1":"🤷đŸģâ€â™‚ī¸","man_shrugging_tone2":"🤷đŸŧâ€â™‚ī¸","man_shrugging_tone3":"🤷đŸŊâ€â™‚ī¸","man_shrugging_tone4":"đŸ¤ˇđŸžâ€â™‚ī¸","man_shrugging_tone5":"🤷đŸŋâ€â™‚ī¸","man_singer":"👨‍🎤","man_singer_tone1":"👨đŸģ‍🎤","man_singer_tone2":"👨đŸŧ‍🎤","man_singer_tone3":"👨đŸŊ‍🎤","man_singer_tone4":"👨🏾‍🎤","man_singer_tone5":"👨đŸŋ‍🎤","man_standing":"đŸ§â€â™‚ī¸","man_standing_tone1":"🧍đŸģâ€â™‚ī¸","man_standing_tone2":"🧍đŸŧâ€â™‚ī¸","man_standing_tone3":"🧍đŸŊâ€â™‚ī¸","man_standing_tone4":"đŸ§đŸžâ€â™‚ī¸","man_standing_tone5":"🧍đŸŋâ€â™‚ī¸","man_student":"👨‍🎓","man_student_tone1":"👨đŸģ‍🎓","man_student_tone2":"👨đŸŧ‍🎓","man_student_tone3":"👨đŸŊ‍🎓","man_student_tone4":"👨🏾‍🎓","man_student_tone5":"👨đŸŋ‍🎓","man_superhero":"đŸĻ¸â€â™‚ī¸","man_superhero_tone1":"đŸĻ¸đŸģâ€â™‚ī¸","man_superhero_tone2":"đŸĻ¸đŸŧâ€â™‚ī¸","man_superhero_tone3":"đŸĻ¸đŸŊâ€â™‚ī¸","man_superhero_tone4":"đŸĻ¸đŸžâ€â™‚ī¸","man_superhero_tone5":"đŸĻ¸đŸŋâ€â™‚ī¸","man_supervillain":"đŸĻšâ€â™‚ī¸","man_supervillain_tone1":"đŸĻšđŸģâ€â™‚ī¸","man_supervillain_tone2":"đŸĻšđŸŧâ€â™‚ī¸","man_supervillain_tone3":"đŸĻšđŸŊâ€â™‚ī¸","man_supervillain_tone4":"đŸĻšđŸžâ€â™‚ī¸","man_supervillain_tone5":"đŸĻšđŸŋâ€â™‚ī¸","man_surfing":"đŸ„â€â™‚ī¸","man_surfing_tone1":"🏄đŸģâ€â™‚ī¸","man_surfing_tone2":"🏄đŸŧâ€â™‚ī¸","man_surfing_tone3":"🏄đŸŊâ€â™‚ī¸","man_surfing_tone4":"đŸ„đŸžâ€â™‚ī¸","man_surfing_tone5":"🏄đŸŋâ€â™‚ī¸","man_swimming":"đŸŠâ€â™‚ī¸","man_swimming_tone1":"🏊đŸģâ€â™‚ī¸","man_swimming_tone2":"🏊đŸŧâ€â™‚ī¸","man_swimming_tone3":"🏊đŸŊâ€â™‚ī¸","man_swimming_tone4":"đŸŠđŸžâ€â™‚ī¸","man_swimming_tone5":"🏊đŸŋâ€â™‚ī¸","man_teacher":"👨‍đŸĢ","man_teacher_tone1":"👨đŸģ‍đŸĢ","man_teacher_tone2":"👨đŸŧ‍đŸĢ","man_teacher_tone3":"👨đŸŊ‍đŸĢ","man_teacher_tone4":"👨🏾‍đŸĢ","man_teacher_tone5":"👨đŸŋ‍đŸĢ","man_technologist":"👨‍đŸ’ģ","man_technologist_tone1":"👨đŸģ‍đŸ’ģ","man_technologist_tone2":"👨đŸŧ‍đŸ’ģ","man_technologist_tone3":"👨đŸŊ‍đŸ’ģ","man_technologist_tone4":"👨🏾‍đŸ’ģ","man_technologist_tone5":"👨đŸŋ‍đŸ’ģ","man_tipping_hand":"đŸ’â€â™‚ī¸","man_tipping_hand_tone1":"💁đŸģâ€â™‚ī¸","man_tipping_hand_tone2":"💁đŸŧâ€â™‚ī¸","man_tipping_hand_tone3":"💁đŸŊâ€â™‚ī¸","man_tipping_hand_tone4":"đŸ’đŸžâ€â™‚ī¸","man_tipping_hand_tone5":"💁đŸŋâ€â™‚ī¸","man_tone1":"👨đŸģ","man_tone2":"👨đŸŧ","man_tone3":"👨đŸŊ","man_tone4":"👨🏾","man_tone5":"👨đŸŋ","man_vampire":"đŸ§›â€â™‚ī¸","man_vampire_tone1":"🧛đŸģâ€â™‚ī¸","man_vampire_tone2":"🧛đŸŧâ€â™‚ī¸","man_vampire_tone3":"🧛đŸŊâ€â™‚ī¸","man_vampire_tone4":"đŸ§›đŸžâ€â™‚ī¸","man_vampire_tone5":"🧛đŸŋâ€â™‚ī¸","man_walking":"đŸšļâ€â™‚ī¸","man_walking_tone1":"đŸšļđŸģâ€â™‚ī¸","man_walking_tone2":"đŸšļđŸŧâ€â™‚ī¸","man_walking_tone3":"đŸšļđŸŊâ€â™‚ī¸","man_walking_tone4":"đŸšļđŸžâ€â™‚ī¸","man_walking_tone5":"đŸšļđŸŋâ€â™‚ī¸","man_wearing_turban":"đŸ‘ŗâ€â™‚ī¸","man_wearing_turban_tone1":"đŸ‘ŗđŸģâ€â™‚ī¸","man_wearing_turban_tone2":"đŸ‘ŗđŸŧâ€â™‚ī¸","man_wearing_turban_tone3":"đŸ‘ŗđŸŊâ€â™‚ī¸","man_wearing_turban_tone4":"đŸ‘ŗđŸžâ€â™‚ī¸","man_wearing_turban_tone5":"đŸ‘ŗđŸŋâ€â™‚ī¸","man_white_haired":"👨‍đŸĻŗ","man_white_haired_tone1":"👨đŸģ‍đŸĻŗ","man_white_haired_tone2":"👨đŸŧ‍đŸĻŗ","man_white_haired_tone3":"👨đŸŊ‍đŸĻŗ","man_white_haired_tone4":"👨🏾‍đŸĻŗ","man_white_haired_tone5":"👨đŸŋ‍đŸĻŗ","man_with_gua_pi_mao":"👲","man_with_probing_cane":"👨‍đŸĻ¯","man_with_probing_cane_tone1":"👨đŸģ‍đŸĻ¯","man_with_probing_cane_tone2":"👨đŸŧ‍đŸĻ¯","man_with_probing_cane_tone3":"👨đŸŊ‍đŸĻ¯","man_with_probing_cane_tone4":"👨🏾‍đŸĻ¯","man_with_probing_cane_tone5":"👨đŸŋ‍đŸĻ¯","man_with_turban":"đŸ‘ŗâ€â™‚ī¸","man_with_veil":"đŸ‘°â€â™‚ī¸","man_with_veil_tone1":"👰đŸģâ€â™‚ī¸","man_with_veil_tone2":"👰đŸŧâ€â™‚ī¸","man_with_veil_tone3":"👰đŸŊâ€â™‚ī¸","man_with_veil_tone4":"đŸ‘°đŸžâ€â™‚ī¸","man_with_veil_tone5":"👰đŸŋâ€â™‚ī¸","man_with_white_cane":"👨‍đŸĻ¯","man_with_white_cane_tone1":"👨đŸģ‍đŸĻ¯","man_with_white_cane_tone2":"👨đŸŧ‍đŸĻ¯","man_with_white_cane_tone3":"👨đŸŊ‍đŸĻ¯","man_with_white_cane_tone4":"👨🏾‍đŸĻ¯","man_with_white_cane_tone5":"👨đŸŋ‍đŸĻ¯","man_zombie":"đŸ§Ÿâ€â™‚ī¸","mandarin":"🍊","mango":"đŸĨ­","mans_shoe":"👞","mantelpiece_clock":"🕰","manual_wheelchair":"đŸĻŊ","maple_leaf":"🍁","marshall_islands":"🇲🇭","martial_arts_uniform":"đŸĨ‹","martinique":"🇲đŸ‡ļ","mask":"😷","massage":"💆","massage_man":"đŸ’†â€â™‚ī¸","massage_tone1":"💆đŸģ","massage_tone2":"💆đŸŧ","massage_tone3":"💆đŸŊ","massage_tone4":"💆🏾","massage_tone5":"💆đŸŋ","massage_woman":"đŸ’†â€â™€ī¸","mate":"🧉","mauritania":"🇲🇷","mauritius":"🇲đŸ‡ē","mayotte":"🇾🇹","meat_on_bone":"🍖","mechanic":"🧑‍🔧","mechanic_tone1":"🧑đŸģ‍🔧","mechanic_tone2":"🧑đŸŧ‍🔧","mechanic_tone3":"🧑đŸŊ‍🔧","mechanic_tone4":"🧑🏾‍🔧","mechanic_tone5":"🧑đŸŋ‍🔧","mechanical_arm":"đŸĻž","mechanical_leg":"đŸĻŋ","medal_military":"🎖","medal_sports":"🏅","medical":"⚕","medical_mask":"😷","medical_symbol":"⚕","medium_volumne":"🔉","mega":"đŸ“Ŗ","megaphone":"đŸ“Ŗ","melon":"🍈","melt":"đŸĢ ","melting_face":"đŸĢ ","mem":"📝","memo":"📝","men_with_bunny_ears_partying":"đŸ‘¯â€â™‚ī¸","men_wrestling":"đŸ¤ŧâ€â™‚ī¸","mending_heart":"â¤ī¸â€đŸŠš","menorah":"🕎","mens":"🚹","mermaid":"đŸ§œâ€â™€ī¸","mermaid_tone1":"🧜đŸģâ€â™€ī¸","mermaid_tone2":"🧜đŸŧâ€â™€ī¸","mermaid_tone3":"🧜đŸŊâ€â™€ī¸","mermaid_tone4":"đŸ§œđŸžâ€â™€ī¸","mermaid_tone5":"🧜đŸŋâ€â™€ī¸","merman":"đŸ§œâ€â™‚ī¸","merman_tone1":"🧜đŸģâ€â™‚ī¸","merman_tone2":"🧜đŸŧâ€â™‚ī¸","merman_tone3":"🧜đŸŊâ€â™‚ī¸","merman_tone4":"đŸ§œđŸžâ€â™‚ī¸","merman_tone5":"🧜đŸŋâ€â™‚ī¸","merperson":"🧜","merperson_tone1":"🧜đŸģ","merperson_tone2":"🧜đŸŧ","merperson_tone3":"🧜đŸŊ","merperson_tone4":"🧜🏾","merperson_tone5":"🧜đŸŋ","metal":"🤘","metal_tone1":"🤘đŸģ","metal_tone2":"🤘đŸŧ","metal_tone3":"🤘đŸŊ","metal_tone4":"🤘🏾","metal_tone5":"🤘đŸŋ","metro":"🚇","mexico":"🇲đŸ‡Ŋ","microbe":"đŸĻ ","micronesia":"đŸ‡Ģ🇲","microphone":"🎤","microscope":"đŸ”Ŧ","middle_finger":"🖕","middle_finger_tone1":"🖕đŸģ","middle_finger_tone2":"🖕đŸŧ","middle_finger_tone3":"🖕đŸŊ","middle_finger_tone4":"🖕🏾","middle_finger_tone5":"🖕đŸŋ","military_helmet":"đŸĒ–","military_medal":"🎖","milk":"đŸĨ›","milk_glass":"đŸĨ›","milky_way":"🌌","minibus":"🚐","minidisc":"đŸ’Ŋ","minus":"➖","mirror":"đŸĒž","mirror_ball":"đŸĒŠ","moai":"đŸ—ŋ","mobile_phone":"📱","mobile_phone_arrow":"📲","mobile_phone_off":"📴","moldova":"🇲🇩","monaco":"🇲🇨","money_mouth_face":"🤑","money_with_wings":"💸","moneybag":"💰","mongolia":"đŸ‡˛đŸ‡ŗ","monkey":"🐒","monkey_face":"đŸĩ","monocle_fac":"🧐","monocle_face":"🧐","monorail":"🚝","montenegro":"🇲đŸ‡Ē","montserrat":"🇲🇸","moon":"🌔","moon_cake":"đŸĨŽ","moon_ceremony":"🎑","morocco":"🇲đŸ‡Ļ","mortar_board":"🎓","mosque":"🕌","mosquito":"đŸĻŸ","motor_boat":"đŸ›Ĩ","motor_scooter":"đŸ›ĩ","motorboat":"đŸ›Ĩ","motorcycle":"🏍","motorized_wheelchair":"đŸĻŧ","motorway":"đŸ›Ŗ","mount_fuji":"đŸ—ģ","mountain":"⛰","mountain_bicyclist":"đŸšĩ","mountain_bicyclist_tone1":"đŸšĩđŸģ","mountain_bicyclist_tone2":"đŸšĩđŸŧ","mountain_bicyclist_tone3":"đŸšĩđŸŊ","mountain_bicyclist_tone4":"đŸšĩ🏾","mountain_bicyclist_tone5":"đŸšĩđŸŋ","mountain_biking":"đŸšĩ","mountain_biking_man":"đŸšĩâ€â™‚ī¸","mountain_biking_tone1":"đŸšĩđŸģ","mountain_biking_tone2":"đŸšĩđŸŧ","mountain_biking_tone3":"đŸšĩđŸŊ","mountain_biking_tone4":"đŸšĩ🏾","mountain_biking_tone5":"đŸšĩđŸŋ","mountain_biking_woman":"đŸšĩâ€â™€ī¸","mountain_cableway":"🚠","mountain_railway":"🚞","mountain_snow":"🏔","mouse":"🐭","mouse2":"🐁","mouse_face":"🐭","mouse_trap":"đŸĒ¤","mouth":"👄","movie_camera":"đŸŽĨ","moyai":"đŸ—ŋ","mozambique":"🇲đŸ‡ŋ","mrs_claus":"đŸ¤ļ","mrs_claus_tone1":"đŸ¤ļđŸģ","mrs_claus_tone2":"đŸ¤ļđŸŧ","mrs_claus_tone3":"đŸ¤ļđŸŊ","mrs_claus_tone4":"đŸ¤ļ🏾","mrs_claus_tone5":"đŸ¤ļđŸŋ","multiplication":"✖","multiply":"✖","muscle":"đŸ’Ē","muscle_tone1":"đŸ’ĒđŸģ","muscle_tone2":"đŸ’ĒđŸŧ","muscle_tone3":"đŸ’ĒđŸŊ","muscle_tone4":"đŸ’Ē🏾","muscle_tone5":"đŸ’ĒđŸŋ","mushroom":"🍄","musical_keyboard":"🎹","musical_note":"đŸŽĩ","musical_notes":"đŸŽļ","musical_score":"đŸŽŧ","mut":"🔇","mute":"🔇","mx_claus":"🧑‍🎄","mx_claus_tone1":"🧑đŸģ‍🎄","mx_claus_tone2":"🧑đŸŧ‍🎄","mx_claus_tone3":"🧑đŸŊ‍🎄","mx_claus_tone4":"🧑🏾‍🎄","mx_claus_tone5":"🧑đŸŋ‍🎄","myanmar":"🇲🇲","nail_care":"💅","nail_care_tone1":"💅đŸģ","nail_care_tone2":"💅đŸŧ","nail_care_tone3":"💅đŸŊ","nail_care_tone4":"💅🏾","nail_care_tone5":"💅đŸŋ","nail_polish":"💅","nail_polish_tone1":"💅đŸģ","nail_polish_tone2":"💅đŸŧ","nail_polish_tone3":"💅đŸŊ","nail_polish_tone4":"💅🏾","nail_polish_tone5":"💅đŸŋ","name_badge":"📛","namibia":"đŸ‡ŗđŸ‡Ļ","national_park":"🏞","nauru":"đŸ‡ŗđŸ‡ˇ","nauseated":"đŸ¤ĸ","nauseated_face":"đŸ¤ĸ","nazar_amulet":"đŸ§ŋ","neckti":"👔","necktie":"👔","negative_squared_cross_mark":"❎","nepal":"đŸ‡ŗđŸ‡ĩ","nerd":"🤓","nerd_face":"🤓","nest":"đŸĒš","nest_with_eggs":"đŸĒē","nesting_dolls":"đŸĒ†","netherlands":"đŸ‡ŗđŸ‡ą","neutral":"😐","neutral_face":"😐","new":"🆕","new_caledonia":"đŸ‡ŗđŸ‡¨","new_moon":"🌑","new_moon_with_face":"🌚","new_zealand":"đŸ‡ŗđŸ‡ŋ","newspaper":"📰","newspaper_roll":"🗞","next_track":"⏭","next_track_button":"⏭","ng":"🆖","ng_man":"đŸ™…â€â™‚ī¸","ng_woman":"đŸ™…â€â™€ī¸","nicaragua":"đŸ‡ŗđŸ‡Ž","niger":"đŸ‡ŗđŸ‡Ē","nigeria":"đŸ‡ŗđŸ‡Ŧ","night_with_stars":"🌃","nine":"9ī¸âƒŖ","ninja":"đŸĨˇ","ninja_tone1":"đŸĨˇđŸģ","ninja_tone2":"đŸĨˇđŸŧ","ninja_tone3":"đŸĨˇđŸŊ","ninja_tone4":"đŸĨˇđŸž","ninja_tone5":"đŸĨˇđŸŋ","niue":"đŸ‡ŗđŸ‡ē","no":"👎","no_bell":"🔕","no_bicycles":"đŸšŗ","no_entry":"⛔","no_entry_sign":"đŸšĢ","no_good":"🙅","no_good_man":"đŸ™…â€â™‚ī¸","no_good_tone1":"🙅đŸģ","no_good_tone2":"🙅đŸŧ","no_good_tone3":"🙅đŸŊ","no_good_tone4":"🙅🏾","no_good_tone5":"🙅đŸŋ","no_good_woman":"đŸ™…â€â™€ī¸","no_hair":"đŸĻ˛","no_littering":"đŸš¯","no_mobile_phones":"đŸ“ĩ","no_mouth":"đŸ˜ļ","no_one_under_18":"🔞","no_pedestrians":"🚷","no_smoking":"🚭","no_sound":"🔇","no_tone1":"👎đŸģ","no_tone2":"👎đŸŧ","no_tone3":"👎đŸŊ","no_tone4":"👎🏾","no_tone5":"👎đŸŋ","non-potable_water":"🚱","norfolk_island":"đŸ‡ŗđŸ‡Ģ","north_korea":"🇰đŸ‡ĩ","northern_mariana_islands":"🇲đŸ‡ĩ","norway":"đŸ‡ŗđŸ‡´","nose":"👃","nose_steam":"😤","nose_tone1":"👃đŸģ","nose_tone2":"👃đŸŧ","nose_tone3":"👃đŸŊ","nose_tone4":"👃🏾","nose_tone5":"👃đŸŋ","notebook":"📓","notebook_with_decorative_cover":"📔","notepad_spiral":"🗒","notes":"đŸŽļ","number_sign":"#ī¸âƒŖ","nut_and_bolt":"🔩","o":"⭕","o2":"🅾","o_blood":"🅾","ocean":"🌊","octagonal_sign":"🛑","octopus":"🐙","oden":"đŸĸ","office":"đŸĸ","office_worker":"🧑‍đŸ’ŧ","office_worker_tone1":"🧑đŸģ‍đŸ’ŧ","office_worker_tone2":"🧑đŸŧ‍đŸ’ŧ","office_worker_tone3":"🧑đŸŊ‍đŸ’ŧ","office_worker_tone4":"🧑🏾‍đŸ’ŧ","office_worker_tone5":"🧑đŸŋ‍đŸ’ŧ","ogre":"👹","oil_drum":"đŸ›ĸ","ok":"🆗","ok_hand":"👌","ok_hand_tone1":"👌đŸģ","ok_hand_tone2":"👌đŸŧ","ok_hand_tone3":"👌đŸŊ","ok_hand_tone4":"👌🏾","ok_hand_tone5":"👌đŸŋ","ok_man":"đŸ™†â€â™‚ī¸","ok_person":"🙆","ok_woman":"đŸ™†â€â™€ī¸","old_key":"🗝","older_adult":"🧓","older_adult_tone1":"🧓đŸģ","older_adult_tone2":"🧓đŸŧ","older_adult_tone3":"🧓đŸŊ","older_adult_tone4":"🧓🏾","older_adult_tone5":"🧓đŸŋ","older_man":"👴","older_man_tone1":"👴đŸģ","older_man_tone2":"👴đŸŧ","older_man_tone3":"👴đŸŊ","older_man_tone4":"👴🏾","older_man_tone5":"👴đŸŋ","older_woman":"đŸ‘ĩ","older_woman_tone1":"đŸ‘ĩđŸģ","older_woman_tone2":"đŸ‘ĩđŸŧ","older_woman_tone3":"đŸ‘ĩđŸŊ","older_woman_tone4":"đŸ‘ĩ🏾","older_woman_tone5":"đŸ‘ĩđŸŋ","olive":"đŸĢ’","om":"🕉","oman":"🇴🇲","on":"🔛","oncoming_automobile":"🚘","oncoming_bus":"🚍","oncoming_police_car":"🚔","oncoming_taxi":"🚖","one":"1ī¸âƒŖ","one_piece_swimsuit":"🩱","onion":"🧅","open_book":"📖","open_file_folder":"📂","open_hands":"👐","open_hands_tone1":"👐đŸģ","open_hands_tone2":"👐đŸŧ","open_hands_tone3":"👐đŸŊ","open_hands_tone4":"👐🏾","open_hands_tone5":"👐đŸŋ","open_mouth":"😮","open_umbrella":"☂","ophiuchus":"⛎","optical_disk":"đŸ’ŋ","orange":"🍊","orange_book":"📙","orange_circle":"🟠","orange_heart":"🧡","orange_square":"🟧","orangutan":"đŸĻ§","orthodox_cross":"â˜Ļ","otter":"đŸĻĻ","outbox_tray":"📤","owl":"đŸĻ‰","ox":"🐂","oyster":"đŸĻĒ","packag":"đŸ“Ļī¸","package":"đŸ“Ļ","page_facing_u":"📄","page_facing_up":"📄","page_with_curl":"📃","pager":"📟","paintbrush":"🖌","pakistan":"đŸ‡ĩ🇰","palau":"đŸ‡ĩđŸ‡ŧ","palestinian_territories":"đŸ‡ĩ🇸","palette":"🎨","palm_down":"đŸĢŗ","palm_down_tone1":"đŸĢŗđŸģ","palm_down_tone2":"đŸĢŗđŸŧ","palm_down_tone3":"đŸĢŗđŸŊ","palm_down_tone4":"đŸĢŗđŸž","palm_down_tone5":"đŸĢŗđŸŋ","palm_tree":"🌴","palm_up":"đŸĢ´","palm_up_tone1":"đŸĢ´đŸģ","palm_up_tone2":"đŸĢ´đŸŧ","palm_up_tone3":"đŸĢ´đŸŊ","palm_up_tone4":"đŸĢ´đŸž","palm_up_tone5":"đŸĢ´đŸŋ","palms_up_together":"🤲","palms_up_together_tone1":"🤲đŸģ","palms_up_together_tone2":"🤲đŸŧ","palms_up_together_tone3":"🤲đŸŊ","palms_up_together_tone4":"🤲🏾","palms_up_together_tone5":"🤲đŸŋ","panama":"đŸ‡ĩđŸ‡Ļ","pancakes":"đŸĨž","panda":"đŸŧ","panda_face":"đŸŧ","paperclip":"📎","paperclips":"🖇","papua_new_guinea":"đŸ‡ĩđŸ‡Ŧ","parachute":"đŸĒ‚","paraguay":"đŸ‡ĩ🇾","parasol_on_ground":"⛱","parking":"đŸ…ŋ","parrot":"đŸĻœ","part_alternation_mark":"ã€Ŋ","partly_sunny":"⛅","party":"🎉","party_popper":"🎉","partying":"đŸĨŗ","partying_face":"đŸĨŗ","passenger_ship":"đŸ›ŗ","passport_contro":"🛂","passport_control":"🛂","pause":"⏸","pause_button":"⏸","paw_prints":"🐾","peace":"☎","peace_symbol":"☎","peach":"🍑","peacock":"đŸĻš","peanuts":"đŸĨœ","pear":"🍐","peek":"đŸĢŖ","pen":"🖊","pencil":"📝","pencil2":"✏","penguin":"🐧","penguin_face":"🐧","pensive":"😔","pensive_face":"😔","people_holding_hands":"🧑‍🤝‍🧑","people_holding_hands_tone1":"🧑đŸģ‍🤝‍🧑đŸģ","people_holding_hands_tone1-2":"🧑đŸģ‍🤝‍🧑đŸŧ","people_holding_hands_tone1-3":"🧑đŸģ‍🤝‍🧑đŸŊ","people_holding_hands_tone1-4":"🧑đŸģ‍🤝‍🧑🏾","people_holding_hands_tone1-5":"🧑đŸģ‍🤝‍🧑đŸŋ","people_holding_hands_tone2":"🧑đŸŧ‍🤝‍🧑đŸŧ","people_holding_hands_tone2-1":"🧑đŸŧ‍🤝‍🧑đŸģ","people_holding_hands_tone2-3":"🧑đŸŧ‍🤝‍🧑đŸŊ","people_holding_hands_tone2-4":"🧑đŸŧ‍🤝‍🧑🏾","people_holding_hands_tone2-5":"🧑đŸŧ‍🤝‍🧑đŸŋ","people_holding_hands_tone3":"🧑đŸŊ‍🤝‍🧑đŸŊ","people_holding_hands_tone3-1":"🧑đŸŊ‍🤝‍🧑đŸģ","people_holding_hands_tone3-2":"🧑đŸŊ‍🤝‍🧑đŸŧ","people_holding_hands_tone3-4":"🧑đŸŊ‍🤝‍🧑🏾","people_holding_hands_tone3-5":"🧑đŸŊ‍🤝‍🧑đŸŋ","people_holding_hands_tone4":"🧑🏾‍🤝‍🧑🏾","people_holding_hands_tone4-1":"🧑🏾‍🤝‍🧑đŸģ","people_holding_hands_tone4-2":"🧑🏾‍🤝‍🧑đŸŧ","people_holding_hands_tone4-3":"🧑🏾‍🤝‍🧑đŸŊ","people_holding_hands_tone4-5":"🧑🏾‍🤝‍🧑đŸŋ","people_holding_hands_tone5":"🧑đŸŋ‍🤝‍🧑đŸŋ","people_holding_hands_tone5-1":"🧑đŸŋ‍🤝‍🧑đŸģ","people_holding_hands_tone5-2":"🧑đŸŋ‍🤝‍🧑đŸŧ","people_holding_hands_tone5-3":"🧑đŸŋ‍🤝‍🧑đŸŊ","people_holding_hands_tone5-4":"🧑đŸŋ‍🤝‍🧑🏾","people_hugging":"đŸĢ‚","people_with_bunny_ears_partying":"đŸ‘¯","people_wrestling":"đŸ¤ŧ","performing_arts":"🎭","persevere":"đŸ˜Ŗ","persevering_face":"đŸ˜Ŗ","person_bald":"🧑‍đŸĻ˛","person_bearded":"🧔","person_bearded_tone1":"🧔đŸģ","person_bearded_tone2":"🧔đŸŧ","person_bearded_tone3":"🧔đŸŊ","person_bearded_tone4":"🧔🏾","person_bearded_tone5":"🧔đŸŋ","person_biking":"🚴","person_biking_tone1":"🚴đŸģ","person_biking_tone2":"🚴đŸŧ","person_biking_tone3":"🚴đŸŊ","person_biking_tone4":"🚴🏾","person_biking_tone5":"🚴đŸŋ","person_bouncing_ball":"⛹","person_bouncing_ball_tone1":"⛹đŸģ","person_bouncing_ball_tone2":"⛹đŸŧ","person_bouncing_ball_tone3":"⛹đŸŊ","person_bouncing_ball_tone4":"⛹🏾","person_bouncing_ball_tone5":"⛹đŸŋ","person_bowing":"🙇","person_bowing_tone1":"🙇đŸģ","person_bowing_tone2":"🙇đŸŧ","person_bowing_tone3":"🙇đŸŊ","person_bowing_tone4":"🙇🏾","person_bowing_tone5":"🙇đŸŋ","person_cartwheel":"🤸","person_cartwheel_tone1":"🤸đŸģ","person_cartwheel_tone2":"🤸đŸŧ","person_cartwheel_tone3":"🤸đŸŊ","person_cartwheel_tone4":"🤸🏾","person_cartwheel_tone5":"🤸đŸŋ","person_climbing":"🧗","person_climbing_tone1":"🧗đŸģ","person_climbing_tone2":"🧗đŸŧ","person_climbing_tone3":"🧗đŸŊ","person_climbing_tone4":"🧗🏾","person_climbing_tone5":"🧗đŸŋ","person_curly_hair":"🧑‍đŸĻą","person_facepalming":"đŸ¤Ļ","person_facepalming_tone1":"đŸ¤ĻđŸģ","person_facepalming_tone2":"đŸ¤ĻđŸŧ","person_facepalming_tone3":"đŸ¤ĻđŸŊ","person_facepalming_tone4":"đŸ¤Ļ🏾","person_facepalming_tone5":"đŸ¤ĻđŸŋ","person_feeding_baby":"🧑‍đŸŧ","person_feeding_baby_tone1":"🧑đŸģ‍đŸŧ","person_feeding_baby_tone2":"🧑đŸŧ‍đŸŧ","person_feeding_baby_tone3":"🧑đŸŊ‍đŸŧ","person_feeding_baby_tone4":"🧑🏾‍đŸŧ","person_feeding_baby_tone5":"🧑đŸŋ‍đŸŧ","person_fencing":"đŸ¤ē","person_frowning":"🙍","person_frowning_tone1":"🙍đŸģ","person_frowning_tone2":"🙍đŸŧ","person_frowning_tone3":"🙍đŸŊ","person_frowning_tone4":"🙍🏾","person_frowning_tone5":"🙍đŸŋ","person_gesturing_no":"🙅","person_gesturing_no_tone1":"🙅đŸģ","person_gesturing_no_tone2":"🙅đŸŧ","person_gesturing_no_tone3":"🙅đŸŊ","person_gesturing_no_tone4":"🙅🏾","person_gesturing_no_tone5":"🙅đŸŋ","person_gesturing_ok":"🙆","person_gesturing_ok_tone1":"🙆đŸģ","person_gesturing_ok_tone2":"🙆đŸŧ","person_gesturing_ok_tone3":"🙆đŸŊ","person_gesturing_ok_tone4":"🙆🏾","person_gesturing_ok_tone5":"🙆đŸŋ","person_getting_haircut":"💇","person_getting_haircut_tone1":"💇đŸģ","person_getting_haircut_tone2":"💇đŸŧ","person_getting_haircut_tone3":"💇đŸŊ","person_getting_haircut_tone4":"💇🏾","person_getting_haircut_tone5":"💇đŸŋ","person_getting_massage":"💆","person_getting_massage_tone1":"💆đŸģ","person_getting_massage_tone2":"💆đŸŧ","person_getting_massage_tone3":"💆đŸŊ","person_getting_massage_tone4":"💆🏾","person_getting_massage_tone5":"💆đŸŋ","person_golfing":"🏌","person_golfing_tone1":"🏌đŸģ","person_golfing_tone2":"🏌đŸŧ","person_golfing_tone3":"🏌đŸŊ","person_golfing_tone4":"🏌🏾","person_golfing_tone5":"🏌đŸŋ","person_in_bed":"🛌","person_in_bed_tone1":"🛌đŸģ","person_in_bed_tone2":"🛌đŸŧ","person_in_bed_tone3":"🛌đŸŊ","person_in_bed_tone4":"🛌🏾","person_in_bed_tone5":"🛌đŸŋ","person_in_lotus_position":"🧘","person_in_lotus_position_tone1":"🧘đŸģ","person_in_lotus_position_tone2":"🧘đŸŧ","person_in_lotus_position_tone3":"🧘đŸŊ","person_in_lotus_position_tone4":"🧘🏾","person_in_lotus_position_tone5":"🧘đŸŋ","person_in_manual_wheelchair":"🧑‍đŸĻŊ","person_in_manual_wheelchair_tone1":"🧑đŸģ‍đŸĻŊ","person_in_manual_wheelchair_tone2":"🧑đŸŧ‍đŸĻŊ","person_in_manual_wheelchair_tone3":"🧑đŸŊ‍đŸĻŊ","person_in_manual_wheelchair_tone4":"🧑🏾‍đŸĻŊ","person_in_manual_wheelchair_tone5":"🧑đŸŋ‍đŸĻŊ","person_in_motorized_wheelchair":"🧑‍đŸĻŧ","person_in_motorized_wheelchair_tone1":"🧑đŸģ‍đŸĻŧ","person_in_motorized_wheelchair_tone2":"🧑đŸŧ‍đŸĻŧ","person_in_motorized_wheelchair_tone3":"🧑đŸŊ‍đŸĻŧ","person_in_motorized_wheelchair_tone4":"🧑🏾‍đŸĻŧ","person_in_motorized_wheelchair_tone5":"🧑đŸŋ‍đŸĻŧ","person_in_steamy_room":"🧖","person_in_steamy_room_tone1":"🧖đŸģ","person_in_steamy_room_tone2":"🧖đŸŧ","person_in_steamy_room_tone3":"🧖đŸŊ","person_in_steamy_room_tone4":"🧖🏾","person_in_steamy_room_tone5":"🧖đŸŋ","person_in_suit_levitating":"🕴","person_in_suit_levitating_tone1":"🕴đŸģ","person_in_suit_levitating_tone2":"🕴đŸŧ","person_in_suit_levitating_tone3":"🕴đŸŊ","person_in_suit_levitating_tone4":"🕴🏾","person_in_suit_levitating_tone5":"🕴đŸŋ","person_in_tuxedo":"đŸ¤ĩ","person_in_tuxedo_tone1":"đŸ¤ĩđŸģ","person_in_tuxedo_tone2":"đŸ¤ĩđŸŧ","person_in_tuxedo_tone3":"đŸ¤ĩđŸŊ","person_in_tuxedo_tone4":"đŸ¤ĩ🏾","person_in_tuxedo_tone5":"đŸ¤ĩđŸŋ","person_juggling":"🤹","person_juggling_tone1":"🤹đŸģ","person_juggling_tone2":"🤹đŸŧ","person_juggling_tone3":"🤹đŸŊ","person_juggling_tone4":"🤹🏾","person_juggling_tone5":"🤹đŸŋ","person_kneeling":"🧎","person_kneeling_tone1":"🧎đŸģ","person_kneeling_tone2":"🧎đŸŧ","person_kneeling_tone3":"🧎đŸŊ","person_kneeling_tone4":"🧎🏾","person_kneeling_tone5":"🧎đŸŋ","person_lifting_weights":"🏋","person_lifting_weights_tone1":"🏋đŸģ","person_lifting_weights_tone2":"🏋đŸŧ","person_lifting_weights_tone3":"🏋đŸŊ","person_lifting_weights_tone4":"🏋🏾","person_lifting_weights_tone5":"🏋đŸŋ","person_mountain_biking":"đŸšĩ","person_mountain_biking_tone1":"đŸšĩđŸģ","person_mountain_biking_tone2":"đŸšĩđŸŧ","person_mountain_biking_tone3":"đŸšĩđŸŊ","person_mountain_biking_tone4":"đŸšĩ🏾","person_mountain_biking_tone5":"đŸšĩđŸŋ","person_playing_handball":"🤾","person_playing_handball_tone1":"🤾đŸģ","person_playing_handball_tone2":"🤾đŸŧ","person_playing_handball_tone3":"🤾đŸŊ","person_playing_handball_tone4":"🤾🏾","person_playing_handball_tone5":"🤾đŸŋ","person_playing_water_polo":"đŸ¤Ŋ","person_playing_water_polo_tone1":"đŸ¤ŊđŸģ","person_playing_water_polo_tone2":"đŸ¤ŊđŸŧ","person_playing_water_polo_tone3":"đŸ¤ŊđŸŊ","person_playing_water_polo_tone4":"đŸ¤Ŋ🏾","person_playing_water_polo_tone5":"đŸ¤ŊđŸŋ","person_pouting":"🙎","person_pouting_tone1":"🙎đŸģ","person_pouting_tone2":"🙎đŸŧ","person_pouting_tone3":"🙎đŸŊ","person_pouting_tone4":"🙎🏾","person_pouting_tone5":"🙎đŸŋ","person_raising_hand":"🙋","person_raising_hand_tone1":"🙋đŸģ","person_raising_hand_tone2":"🙋đŸŧ","person_raising_hand_tone3":"🙋đŸŊ","person_raising_hand_tone4":"🙋🏾","person_raising_hand_tone5":"🙋đŸŋ","person_red_hair":"🧑‍đŸĻ°","person_rowing_boat":"đŸšŖ","person_rowing_boat_tone1":"đŸšŖđŸģ","person_rowing_boat_tone2":"đŸšŖđŸŧ","person_rowing_boat_tone3":"đŸšŖđŸŊ","person_rowing_boat_tone4":"đŸšŖđŸž","person_rowing_boat_tone5":"đŸšŖđŸŋ","person_running":"🏃","person_running_tone1":"🏃đŸģ","person_running_tone2":"🏃đŸŧ","person_running_tone3":"🏃đŸŊ","person_running_tone4":"🏃🏾","person_running_tone5":"🏃đŸŋ","person_shrugging":"🤷","person_shrugging_tone1":"🤷đŸģ","person_shrugging_tone2":"🤷đŸŧ","person_shrugging_tone3":"🤷đŸŊ","person_shrugging_tone4":"🤷🏾","person_shrugging_tone5":"🤷đŸŋ","person_skiing":"⛷","person_snowboarding":"🏂","person_snowboarding_tone1":"🏂đŸģ","person_snowboarding_tone2":"🏂đŸŧ","person_snowboarding_tone3":"🏂đŸŊ","person_snowboarding_tone4":"🏂🏾","person_snowboarding_tone5":"🏂đŸŋ","person_standing":"🧍","person_standing_tone1":"🧍đŸģ","person_standing_tone2":"🧍đŸŧ","person_standing_tone3":"🧍đŸŊ","person_standing_tone4":"🧍🏾","person_standing_tone5":"🧍đŸŋ","person_surfing":"🏄","person_surfing_tone1":"🏄đŸģ","person_surfing_tone2":"🏄đŸŧ","person_surfing_tone3":"🏄đŸŊ","person_surfing_tone4":"🏄🏾","person_surfing_tone5":"🏄đŸŋ","person_swimming":"🏊","person_swimming_tone1":"🏊đŸģ","person_swimming_tone2":"🏊đŸŧ","person_swimming_tone3":"🏊đŸŊ","person_swimming_tone4":"🏊🏾","person_swimming_tone5":"🏊đŸŋ","person_taking_bath":"🛀","person_taking_bath_tone1":"🛀đŸģ","person_taking_bath_tone2":"🛀đŸŧ","person_taking_bath_tone3":"🛀đŸŊ","person_taking_bath_tone4":"🛀🏾","person_taking_bath_tone5":"🛀đŸŋ","person_tipping_hand":"💁","person_tipping_hand_tone1":"💁đŸģ","person_tipping_hand_tone2":"💁đŸŧ","person_tipping_hand_tone3":"💁đŸŊ","person_tipping_hand_tone4":"💁🏾","person_tipping_hand_tone5":"💁đŸŋ","person_walking":"đŸšļ","person_walking_tone1":"đŸšļđŸģ","person_walking_tone2":"đŸšļđŸŧ","person_walking_tone3":"đŸšļđŸŊ","person_walking_tone4":"đŸšļ🏾","person_walking_tone5":"đŸšļđŸŋ","person_wearing_turban":"đŸ‘ŗ","person_wearing_turban_tone1":"đŸ‘ŗđŸģ","person_wearing_turban_tone2":"đŸ‘ŗđŸŧ","person_wearing_turban_tone3":"đŸ‘ŗđŸŊ","person_wearing_turban_tone4":"đŸ‘ŗđŸž","person_wearing_turban_tone5":"đŸ‘ŗđŸŋ","person_white_hair":"🧑‍đŸĻŗ","person_with_crown":"đŸĢ…","person_with_crown_tone1":"đŸĢ…đŸģ","person_with_crown_tone2":"đŸĢ…đŸŧ","person_with_crown_tone3":"đŸĢ…đŸŊ","person_with_crown_tone4":"đŸĢ…đŸž","person_with_crown_tone5":"đŸĢ…đŸŋ","person_with_probing_cane":"🧑‍đŸĻ¯","person_with_probing_cane_tone1":"🧑đŸģ‍đŸĻ¯","person_with_probing_cane_tone2":"🧑đŸŧ‍đŸĻ¯","person_with_probing_cane_tone3":"🧑đŸŊ‍đŸĻ¯","person_with_probing_cane_tone4":"🧑🏾‍đŸĻ¯","person_with_probing_cane_tone5":"🧑đŸŋ‍đŸĻ¯","person_with_skullcap":"👲","person_with_skullcap_tone1":"👲đŸģ","person_with_skullcap_tone2":"👲đŸŧ","person_with_skullcap_tone3":"👲đŸŊ","person_with_skullcap_tone4":"👲🏾","person_with_skullcap_tone5":"👲đŸŋ","person_with_turban":"đŸ‘ŗ","person_with_veil":"👰","person_with_veil_tone1":"👰đŸģ","person_with_veil_tone2":"👰đŸŧ","person_with_veil_tone3":"👰đŸŊ","person_with_veil_tone4":"👰🏾","person_with_veil_tone5":"👰đŸŋ","person_with_white_cane":"🧑‍đŸĻ¯","person_with_white_cane_tone1":"🧑đŸģ‍đŸĻ¯","person_with_white_cane_tone2":"🧑đŸŧ‍đŸĻ¯","person_with_white_cane_tone3":"🧑đŸŊ‍đŸĻ¯","person_with_white_cane_tone4":"🧑🏾‍đŸĻ¯","person_with_white_cane_tone5":"🧑đŸŋ‍đŸĻ¯","peru":"đŸ‡ĩđŸ‡Ē","petri_dish":"đŸ§Ģ","philippines":"đŸ‡ĩ🇭","phone":"☎","pick":"⛏","pickup_truck":"đŸ›ģ","pie":"đŸĨ§","pig":"🐷","pig2":"🐖","pig_face":"🐷","pig_nose":"đŸŊ","pill":"💊","pilot":"đŸ§‘â€âœˆī¸","pilot_tone1":"🧑đŸģâ€âœˆī¸","pilot_tone2":"🧑đŸŧâ€âœˆī¸","pilot_tone3":"🧑đŸŊâ€âœˆī¸","pilot_tone4":"đŸ§‘đŸžâ€âœˆī¸","pilot_tone5":"🧑đŸŋâ€âœˆī¸","pinata":"đŸĒ…","pinch":"🤌","pinch_tone1":"🤌đŸģ","pinch_tone2":"🤌đŸŧ","pinch_tone3":"🤌đŸŊ","pinch_tone4":"🤌🏾","pinch_tone5":"🤌đŸŋ","pinched_fingers":"🤌","pinched_fingers_tone1":"🤌đŸģ","pinched_fingers_tone2":"🤌đŸŧ","pinched_fingers_tone3":"🤌đŸŊ","pinched_fingers_tone4":"🤌🏾","pinched_fingers_tone5":"🤌đŸŋ","pinching_hand":"🤏","pinching_hand_tone1":"🤏đŸģ","pinching_hand_tone2":"🤏đŸŧ","pinching_hand_tone3":"🤏đŸŊ","pinching_hand_tone4":"🤏🏾","pinching_hand_tone5":"🤏đŸŋ","pineapple":"🍍","ping_pong":"🏓","pirate_flag":"đŸ´â€â˜ ī¸","pisces":"♓","pistol":"đŸ”Ģ","pitcairn_islands":"đŸ‡ĩđŸ‡ŗ","pizza":"🍕","placard":"đŸĒ§","place_of_worship":"🛐","plate_with_cutlery":"đŸŊ","play":"â–ļ","play_or_pause_button":"⏯","play_pause":"⏯","playground_slide":"🛝","pleading":"đŸĨē","pleading_face":"đŸĨē","plunger":"đŸĒ ","plus":"➕","point_down":"👇","point_down_tone1":"👇đŸģ","point_down_tone2":"👇đŸŧ","point_down_tone3":"👇đŸŊ","point_down_tone4":"👇🏾","point_down_tone5":"👇đŸŋ","point_forward":"đŸĢĩ","point_forward_tone1":"đŸĢĩđŸģ","point_forward_tone2":"đŸĢĩđŸŧ","point_forward_tone3":"đŸĢĩđŸŊ","point_forward_tone4":"đŸĢĩ🏾","point_forward_tone5":"đŸĢĩđŸŋ","point_left":"👈","point_left_tone1":"👈đŸģ","point_left_tone2":"👈đŸŧ","point_left_tone3":"👈đŸŊ","point_left_tone4":"👈🏾","point_left_tone5":"👈đŸŋ","point_right":"👉","point_right_tone1":"👉đŸģ","point_right_tone2":"👉đŸŧ","point_right_tone3":"👉đŸŊ","point_right_tone4":"👉🏾","point_right_tone5":"👉đŸŋ","point_up":"☝","point_up_2":"👆","point_up_2_tone1":"☝đŸģ","point_up_2_tone2":"☝đŸŧ","point_up_2_tone3":"☝đŸŊ","point_up_2_tone4":"☝🏾","point_up_2_tone5":"☝đŸŋ","point_up_tone1":"👆đŸģ","point_up_tone2":"👆đŸŧ","point_up_tone3":"👆đŸŊ","point_up_tone4":"👆🏾","point_up_tone5":"👆đŸŋ","poland":"đŸ‡ĩ🇱","polar_bear":"đŸģâ€â„ī¸","polar_bear_face":"đŸģâ€â„ī¸","police_car":"🚓","police_officer":"👮","police_officer_tone1":"👮đŸģ","police_officer_tone2":"👮đŸŧ","police_officer_tone3":"👮đŸŊ","police_officer_tone4":"👮🏾","police_officer_tone5":"👮đŸŋ","policeman":"đŸ‘Žâ€â™‚ī¸","policewoman":"đŸ‘Žâ€â™€ī¸","poo":"💩","poodle":"🐩","poop":"💩","popcorn":"đŸŋ","portugal":"đŸ‡ĩ🇹","post_office":"đŸŖ","postal_horn":"đŸ“¯","postbox":"📮","pot_of_food":"🍲","potable_water":"🚰","potato":"đŸĨ”","potted_plant":"đŸĒ´","pouch":"👝","poultry_leg":"🍗","pound":"💷","pour":"đŸĢ—","pouring_liquid":"đŸĢ—","pout":"😡","pouting":"🙎","pouting_cat":"😾","pouting_face":"🙎","pouting_man":"đŸ™Žâ€â™‚ī¸","pouting_tone1":"🙎đŸģ","pouting_tone2":"🙎đŸŧ","pouting_tone3":"🙎đŸŊ","pouting_tone4":"🙎🏾","pouting_tone5":"🙎đŸŋ","pouting_woman":"đŸ™Žâ€â™€ī¸","pray":"🙏","pray_tone1":"🙏đŸģ","pray_tone2":"🙏đŸŧ","pray_tone3":"🙏đŸŊ","pray_tone4":"🙏🏾","pray_tone5":"🙏đŸŋ","prayer_beads":"đŸ“ŋ","pregnant_man":"đŸĢƒ","pregnant_man_tone1":"đŸĢƒđŸģ","pregnant_man_tone2":"đŸĢƒđŸŧ","pregnant_man_tone3":"đŸĢƒđŸŊ","pregnant_man_tone4":"đŸĢƒđŸž","pregnant_man_tone5":"đŸĢƒđŸŋ","pregnant_person":"đŸĢ„","pregnant_person_tone1":"đŸĢ„đŸģ","pregnant_person_tone2":"đŸĢ„đŸŧ","pregnant_person_tone3":"đŸĢ„đŸŊ","pregnant_person_tone4":"đŸĢ„đŸž","pregnant_person_tone5":"đŸĢ„đŸŋ","pregnant_woman":"🤰","pregnant_woman_tone1":"🤰đŸģ","pregnant_woman_tone2":"🤰đŸŧ","pregnant_woman_tone3":"🤰đŸŊ","pregnant_woman_tone4":"🤰🏾","pregnant_woman_tone5":"🤰đŸŋ","pretzel":"đŸĨ¨","previous_track":"⏎","previous_track_button":"⏎","prince":"🤴","prince_tone1":"🤴đŸģ","prince_tone2":"🤴đŸŧ","prince_tone3":"🤴đŸŊ","prince_tone4":"🤴🏾","prince_tone5":"🤴đŸŋ","princess":"👸","princess_tone1":"👸đŸģ","princess_tone2":"👸đŸŧ","princess_tone3":"👸đŸŊ","princess_tone4":"👸🏾","princess_tone5":"👸đŸŋ","printer":"🖨","probing_cane":"đŸĻ¯","puerto_rico":"đŸ‡ĩ🇷","punch":"👊","punch_tone1":"👊đŸģ","punch_tone2":"👊đŸŧ","punch_tone3":"👊đŸŊ","punch_tone4":"👊🏾","punch_tone5":"👊đŸŋ","purple_circle":"đŸŸŖ","purple_heart":"💜","purple_square":"đŸŸĒ","purse":"👛","pushpi":"📌","pushpin":"📌","put_litter_in_its_place":"🚮","puzzle_piece":"🧩","qatar":"đŸ‡ļđŸ‡Ļ","question":"❓","quiet_sound":"🔈","rabbit":"🐰","rabbit2":"🐇","rabbit_face":"🐰","raccoon":"đŸĻ","racehorse":"🐎","racing_car":"🏎","radio":"đŸ“ģ","radio_button":"🔘","radioactive":"â˜ĸ","rage":"😡","railway_car":"🚃","railway_track":"🛤","rainbow":"🌈","rainbow_flag":"đŸŗī¸â€đŸŒˆ","rainy":"🌧","raised_back_of_hand":"🤚","raised_back_of_hand_tone1":"🤚đŸģ","raised_back_of_hand_tone2":"🤚đŸŧ","raised_back_of_hand_tone3":"🤚đŸŊ","raised_back_of_hand_tone4":"🤚🏾","raised_back_of_hand_tone5":"🤚đŸŋ","raised_eyebrow":"🤨","raised_hand":"✋","raised_hand_tone1":"✋đŸģ","raised_hand_tone2":"✋đŸŧ","raised_hand_tone3":"✋đŸŊ","raised_hand_tone4":"✋🏾","raised_hand_tone5":"✋đŸŋ","raised_hand_with_fingers_splayed":"🖐","raised_hand_with_fingers_splayed_tone1":"🖐đŸģ","raised_hand_with_fingers_splayed_tone2":"🖐đŸŧ","raised_hand_with_fingers_splayed_tone3":"🖐đŸŊ","raised_hand_with_fingers_splayed_tone4":"🖐🏾","raised_hand_with_fingers_splayed_tone5":"🖐đŸŋ","raised_hands":"🙌","raised_hands_tone1":"🙌đŸģ","raised_hands_tone2":"🙌đŸŧ","raised_hands_tone3":"🙌đŸŊ","raised_hands_tone4":"🙌🏾","raised_hands_tone5":"🙌đŸŋ","raising_hand":"🙋","raising_hand_man":"đŸ™‹â€â™‚ī¸","raising_hand_woman":"đŸ™‹â€â™€ī¸","ram":"🐏","ramen":"🍜","rat":"🐀","razor":"đŸĒ’","receipt":"🧾","record":"âē","record_button":"âē","recycl":"â™ģī¸","recycle":"â™ģ","recycling_symbol":"â™ģ","red_apple":"🍎","red_car":"🚗","red_circle":"🔴","red_envelope":"🧧","red_hair":"đŸĻ°","red_haired":"🧑‍đŸĻ°","red_haired_man":"👨‍đŸĻ°","red_haired_tone1":"🧑đŸģ‍đŸĻ°","red_haired_tone2":"🧑đŸŧ‍đŸĻ°","red_haired_tone3":"🧑đŸŊ‍đŸĻ°","red_haired_tone4":"🧑🏾‍đŸĻ°","red_haired_tone5":"🧑đŸŋ‍đŸĻ°","red_haired_woman":"👩‍đŸĻ°","red_heart":"❤","red_o":"⭕","red_paper_lantern":"🏮","red_square":"đŸŸĨ","regional_indicator_a":"đŸ‡Ļ","regional_indicator_b":"🇧","regional_indicator_c":"🇨","regional_indicator_d":"🇩","regional_indicator_e":"đŸ‡Ē","regional_indicator_f":"đŸ‡Ģ","regional_indicator_g":"đŸ‡Ŧ","regional_indicator_h":"🇭","regional_indicator_i":"🇮","regional_indicator_j":"đŸ‡¯","regional_indicator_k":"🇰","regional_indicator_l":"🇱","regional_indicator_m":"🇲","regional_indicator_n":"đŸ‡ŗ","regional_indicator_o":"🇴","regional_indicator_p":"đŸ‡ĩ","regional_indicator_q":"đŸ‡ļ","regional_indicator_r":"🇷","regional_indicator_s":"🇸","regional_indicator_t":"🇹","regional_indicator_u":"đŸ‡ē","regional_indicator_v":"đŸ‡ģ","regional_indicator_w":"đŸ‡ŧ","regional_indicator_x":"đŸ‡Ŋ","regional_indicator_y":"🇾","regional_indicator_z":"đŸ‡ŋ","registered":"ÂŽ","relaxed":"â˜ē","relieved":"😌","relieved_face":"😌","reminder_ribbon":"🎗","repeat":"🔁","repeat_one":"🔂","rescue_worker_helmet":"⛑","restroom":"đŸšģ","reunion":"🇷đŸ‡Ē","reverse":"◀","revolving_hearts":"💞","rewin":"âĒī¸","rewind":"âĒ","rhino":"đŸĻ","rhinoceros":"đŸĻ","ribbon":"🎀","rice":"🍚","rice_ball":"🍙","rice_cracker":"🍘","rice_scene":"🎑","right_anger_bubble":"đŸ—¯","right_bicep":"đŸ’Ē","right_bicep_tone1":"đŸ’ĒđŸģ","right_bicep_tone2":"đŸ’ĒđŸŧ","right_bicep_tone3":"đŸ’ĒđŸŊ","right_bicep_tone4":"đŸ’Ē🏾","right_bicep_tone5":"đŸ’ĒđŸŋ","right_facing_fist":"🤜","right_facing_fist_tone1":"🤜đŸģ","right_facing_fist_tone2":"🤜đŸŧ","right_facing_fist_tone3":"🤜đŸŊ","right_facing_fist_tone4":"🤜🏾","right_facing_fist_tone5":"🤜đŸŋ","rightwards_arrow_with_hook":"â†Ē","rightwards_hand":"đŸĢą","rightwards_hand_tone1":"đŸĢąđŸģ","rightwards_hand_tone2":"đŸĢąđŸŧ","rightwards_hand_tone3":"đŸĢąđŸŊ","rightwards_hand_tone4":"đŸĢąđŸž","rightwards_hand_tone5":"đŸĢąđŸŋ","ring":"💍","ring_buoy":"🛟","ringed_planet":"đŸĒ","robot":"🤖","robot_face":"🤖","rock":"đŸĒ¨","rocke":"🚀","rocket":"🚀","rofl":"đŸ¤Ŗ","roll_eyes":"🙄","roll_of_paper":"đŸ§ģ","rolled_up_newspaper":"🗞","roller_coaster":"đŸŽĸ","roller_skate":"đŸ›ŧ","rolling_eyes":"🙄","romania":"🇷🇴","rooster":"🐓","rose":"🌹","rosette":"đŸĩ","rotating_ligh":"🚨","rotating_light":"🚨","round_pushpin":"📍","rowboat":"đŸšŖ","rowboat_tone1":"đŸšŖđŸģ","rowboat_tone2":"đŸšŖđŸŧ","rowboat_tone3":"đŸšŖđŸŊ","rowboat_tone4":"đŸšŖđŸž","rowboat_tone5":"đŸšŖđŸŋ","rowing_man":"đŸšŖâ€â™‚ī¸","rowing_woman":"đŸšŖâ€â™€ī¸","royalty":"đŸĢ…","royalty_tone1":"đŸĢ…đŸģ","royalty_tone2":"đŸĢ…đŸŧ","royalty_tone3":"đŸĢ…đŸŊ","royalty_tone4":"đŸĢ…đŸž","royalty_tone5":"đŸĢ…đŸŋ","ru":"🇷đŸ‡ē","rugby_football":"🏉","runner":"🏃","running":"🏃","running_man":"đŸƒâ€â™‚ī¸","running_shirt":"đŸŽŊ","running_shirt_with_sash":"đŸŽŊ","running_tone1":"🏃đŸģ","running_tone2":"🏃đŸŧ","running_tone3":"🏃đŸŊ","running_tone4":"🏃🏾","running_tone5":"🏃đŸŋ","running_woman":"đŸƒâ€â™€ī¸","russia":"🇷đŸ‡ē","rwanda":"🇷đŸ‡ŧ","sa":"🈂","sad_relieved_face":"đŸ˜Ĩ","safety_pin":"🧷","safety_vest":"đŸĻē","sagittarius":"♐","sailboat":"â›ĩ","sake":"đŸļ","salad":"đŸĨ—","salt":"🧂","salute":"đŸĢĄ","saluting_face":"đŸĢĄ","samoa":"đŸ‡ŧ🇸","san_marino":"🇸🇲","sandal":"👡","sandwich":"đŸĨĒ","santa":"🎅","santa_tone1":"🎅đŸģ","santa_tone2":"🎅đŸŧ","santa_tone3":"🎅đŸŊ","santa_tone4":"🎅🏾","santa_tone5":"🎅đŸŋ","sao_tome_principe":"🇸🇹","sari":"đŸĨģ","sassy_man":"đŸ’â€â™‚ī¸","sassy_woman":"đŸ’â€â™€ī¸","satellite":"📡","satellite_antenna":"📡","satisfied":"😆","saturn":"đŸĒ","saudi_arabia":"🇸đŸ‡Ļ","sauna_man":"đŸ§–â€â™‚ī¸","sauna_person":"🧖","sauna_woman":"đŸ§–â€â™€ī¸","sauropod":"đŸĻ•","savoring_food":"😋","saxophone":"🎷","scales":"⚖","scarf":"đŸ§Ŗ","school":"đŸĢ","school_satchel":"🎒","scientist":"🧑‍đŸ”Ŧ","scientist_tone1":"🧑đŸģ‍đŸ”Ŧ","scientist_tone2":"🧑đŸŧ‍đŸ”Ŧ","scientist_tone3":"🧑đŸŊ‍đŸ”Ŧ","scientist_tone4":"🧑🏾‍đŸ”Ŧ","scientist_tone5":"🧑đŸŋ‍đŸ”Ŧ","scissors":"✂","scooter":"🛴","scorpion":"đŸĻ‚","scorpius":"♏","scotland":"đŸ´ķ §ķ ĸķ ŗķ Ŗķ ´ķ ŋ","scream":"😱","scream_cat":"🙀","screaming_in_fear":"😱","screwdriver":"đŸĒ›","scroll":"📜","seal":"đŸĻ­","seat":"đŸ’ē","second_place_medal":"đŸĨˆ","secret":"㊙","see_no_evi":"🙈","see_no_evil":"🙈","seedlin":"🌱","seedling":"🌱","selfie":"đŸ¤ŗ","selfie_tone1":"đŸ¤ŗđŸģ","selfie_tone2":"đŸ¤ŗđŸŧ","selfie_tone3":"đŸ¤ŗđŸŊ","selfie_tone4":"đŸ¤ŗđŸž","selfie_tone5":"đŸ¤ŗđŸŋ","senegal":"đŸ‡¸đŸ‡ŗ","serbia":"🇷🇸","service_dog":"🐕‍đŸĻē","seven":"7ī¸âƒŖ","sewing_needle":"đŸĒĄ","seychelles":"🇸🇨","shallow_pan_of_food":"đŸĨ˜","shamrock":"☘","shark":"đŸĻˆ","shaved_ice":"🍧","sheaf_of_rice":"🌾","sheep":"🐑","shell":"🐚","shield":"🛡","shinto_shrine":"⛩","ship":"đŸšĸ","shirt":"👕","shit":"💩","shoe":"👞","shooting_star":"🌠","shopping":"🛍","shopping_bags":"🛍","shopping_cart":"🛒","shortcake":"🍰","shorts":"đŸŠŗ","shower":"đŸšŋ","shrimp":"đŸĻ","shrug":"🤷","shrug_tone1":"🤷đŸģ","shrug_tone2":"🤷đŸŧ","shrug_tone3":"🤷đŸŊ","shrug_tone4":"🤷🏾","shrug_tone5":"🤷đŸŋ","shuffle":"🔀","shush":"đŸ¤Ģ","shushing_face":"đŸ¤Ģ","sierra_leone":"🇸🇱","sign_of_the_horns":"🤘","sign_of_the_horns_tone1":"🤘đŸģ","sign_of_the_horns_tone2":"🤘đŸŧ","sign_of_the_horns_tone3":"🤘đŸŊ","sign_of_the_horns_tone4":"🤘🏾","sign_of_the_horns_tone5":"🤘đŸŋ","signal_strength":"đŸ“ļ","singapore":"🇸đŸ‡Ŧ","singer":"🧑‍🎤","singer_tone1":"🧑đŸģ‍🎤","singer_tone2":"🧑đŸŧ‍🎤","singer_tone3":"🧑đŸŊ‍🎤","singer_tone4":"🧑🏾‍🎤","singer_tone5":"🧑đŸŋ‍🎤","sint_maarten":"🇸đŸ‡Ŋ","six":"6ī¸âƒŖ","six_pointed_star":"đŸ”¯","skateboard":"🛹","ski":"đŸŽŋ","skier":"⛷","skiing":"⛷","skull":"💀","skull_and_crossbones":"☠","skunk":"đŸĻ¨","sled":"🛷","sleeping":"😴","sleeping_accommodation":"🛌","sleeping_accommodation_tone1":"🛌đŸģ","sleeping_accommodation_tone2":"🛌đŸŧ","sleeping_accommodation_tone3":"🛌đŸŊ","sleeping_accommodation_tone4":"🛌🏾","sleeping_accommodation_tone5":"🛌đŸŋ","sleeping_bed":"🛌","sleeping_face":"😴","sleepy":"đŸ˜Ē","sleepy_face":"đŸ˜Ē","slide":"🛝","slightly_frowning_face":"🙁","slightly_smiling_face":"🙂","slot_machine":"🎰","sloth":"đŸĻĨ","slovakia":"🇸🇰","slovenia":"🇸🇮","small_airplane":"🛩","small_blue_diamond":"🔹","small_orange_diamond":"🔸","small_red_triangle":"đŸ”ē","small_red_triangle_down":"đŸ”ģ","smile":"😄","smile_cat":"😸","smiley":"😃","smiley_cat":"đŸ˜ē","smiling_cat_with_heart_eyes":"đŸ˜ģ","smiling_face":"â˜ē","smiling_face_with_3_hearts":"đŸĨ°","smiling_face_with_closed_eyes":"😊","smiling_face_with_heart_eyes":"😍","smiling_face_with_sunglasses":"😎","smiling_face_with_tear":"đŸĨ˛","smiling_face_with_three_hearts":"đŸĨ°","smiling_imp":"😈","smirk":"😏","smirk_cat":"đŸ˜ŧ","smirking":"😏","smirking_face":"😏","smoking":"đŸšŦ","snail":"🐌","snake":"🐍","sneaker":"👟","sneezing":"🤧","sneezing_face":"🤧","snowboarder":"🏂","snowboarder_tone1":"🏂đŸģ","snowboarder_tone2":"🏂đŸŧ","snowboarder_tone3":"🏂đŸŊ","snowboarder_tone4":"🏂🏾","snowboarder_tone5":"🏂đŸŋ","snowboarding":"🏂","snowboarding_tone1":"🏂đŸģ","snowboarding_tone2":"🏂đŸŧ","snowboarding_tone3":"🏂đŸŊ","snowboarding_tone4":"🏂🏾","snowboarding_tone5":"🏂đŸŋ","snowflake":"❄","snowman":"⛄","snowman2":"☃","snowman_with_snow":"☃","snowy":"🌨","soap":"đŸ§ŧ","sob":"😭","soccer":"âšŊ","socks":"đŸ§Ļ","soft_serve":"đŸĻ","softball":"đŸĨŽ","solomon_islands":"🇸🇧","somalia":"🇸🇴","soon":"🔜","sos":"🆘","sound":"🔉","south_africa":"đŸ‡ŋđŸ‡Ļ","south_georgia_south_sandwich_islands":"đŸ‡Ŧ🇸","south_korea":"🇰🇷","south_sudan":"🇸🇸","space_invader":"👾","spades":"♠","spaghetti":"🍝","spain":"đŸ‡Ē🇸","sparkle":"❇","sparkler":"🎇","sparkles":"✨","sparkling_heart":"💖","speak_no_evil":"🙊","speaker":"🔈","speaking_head":"đŸ—Ŗ","speech_balloo":"đŸ’Ŧ","speech_balloon":"đŸ’Ŧ","speedboat":"🚤","spider":"🕷","spider_web":"🕸","spiral_calendar":"🗓","spiral_notepad":"🗒","sponge":"đŸ§Ŋ","spoon":"đŸĨ„","sports_medal":"🏅","spouting_whale":"đŸŗ","squid":"đŸĻ‘","squinting_face":"😆","sri_lanka":"🇱🇰","st_barthelemy":"🇧🇱","st_helena":"🇸🇭","st_kitts_nevis":"đŸ‡°đŸ‡ŗ","st_lucia":"🇱🇨","st_martin":"🇲đŸ‡Ģ","st_pierre_miquelon":"đŸ‡ĩ🇲","st_vincent_grenadines":"đŸ‡ģ🇨","stadium":"🏟","standing":"🧍","standing_man":"đŸ§â€â™‚ī¸","standing_person":"🧍","standing_tone1":"🧍đŸģ","standing_tone2":"🧍đŸŧ","standing_tone3":"🧍đŸŊ","standing_tone4":"🧍🏾","standing_tone5":"🧍đŸŋ","standing_woman":"đŸ§â€â™€ī¸","star":"⭐","star2":"🌟","star_and_crescent":"â˜Ē","star_of_david":"✡","star_struck":"🤩","stars":"🌠","station":"🚉","statue_of_liberty":"đŸ—Ŋ","steam_locomotive":"🚂","steaming_bowl":"🍜","stethoscop":"đŸŠē","stethoscope":"đŸŠē","stew":"🍲","stop":"⏚","stop_button":"⏚","stop_sign":"🛑","stopwatch":"⏱","stormy":"⛈","straight_ruler":"📏","strawberry":"🍓","stuck_out_tongue":"😛","stuck_out_tongue_closed_eyes":"😝","stuck_out_tongue_winking_eye":"😜","student":"🧑‍🎓","student_tone1":"🧑đŸģ‍🎓","student_tone2":"🧑đŸŧ‍🎓","student_tone3":"🧑đŸŊ‍🎓","student_tone4":"🧑🏾‍🎓","student_tone5":"🧑đŸŋ‍🎓","studio_microphone":"🎙","stuffed_flatbread":"đŸĨ™","sudan":"🇸🇩","sun":"☀","sun_and_rain":"đŸŒĻ","sun_behind_cloud":"⛅","sun_behind_large_cloud":"đŸŒĨ","sun_behind_rain_cloud":"đŸŒĻ","sun_behind_small_cloud":"🌤","sun_with_face":"🌞","sunflower":"đŸŒģ","sunglasses":"😎","sunglasses_cool":"😎","sunny":"☀","sunrise":"🌅","sunrise_over_mountains":"🌄","superhero":"đŸĻ¸","superhero_man":"đŸĻ¸â€â™‚ī¸","superhero_tone1":"đŸĻ¸đŸģ","superhero_tone2":"đŸĻ¸đŸŧ","superhero_tone3":"đŸĻ¸đŸŊ","superhero_tone4":"đŸĻ¸đŸž","superhero_tone5":"đŸĻ¸đŸŋ","superhero_woman":"đŸĻ¸â€â™€ī¸","supervillain":"đŸĻš","supervillain_man":"đŸĻšâ€â™‚ī¸","supervillain_tone1":"đŸĻšđŸģ","supervillain_tone2":"đŸĻšđŸŧ","supervillain_tone3":"đŸĻšđŸŊ","supervillain_tone4":"đŸĻšđŸž","supervillain_tone5":"đŸĻšđŸŋ","supervillain_woman":"đŸĻšâ€â™€ī¸","surfer":"🏄","surfer_tone1":"🏄đŸģ","surfer_tone2":"🏄đŸŧ","surfer_tone3":"🏄đŸŊ","surfer_tone4":"🏄🏾","surfer_tone5":"🏄đŸŋ","surfing":"🏄","surfing_man":"đŸ„â€â™‚ī¸","surfing_tone1":"🏄đŸģ","surfing_tone2":"🏄đŸŧ","surfing_tone3":"🏄đŸŊ","surfing_tone4":"🏄🏾","surfing_tone5":"🏄đŸŋ","surfing_woman":"đŸ„â€â™€ī¸","suriname":"🇸🇷","sushi":"đŸŖ","suspension_railway":"🚟","suv":"🚙","svalbard_jan_mayen":"đŸ‡¸đŸ‡¯","swan":"đŸĻĸ","swaziland":"🇸đŸ‡ŋ","sweat":"😓","sweat_drops":"đŸ’Ļ","sweat_smile":"😅","sweden":"🇸đŸ‡Ē","sweet_potato":"🍠","swim_brief":"🩲","swimmer":"🏊","swimmer_tone1":"🏊đŸģ","swimmer_tone2":"🏊đŸŧ","swimmer_tone3":"🏊đŸŊ","swimmer_tone4":"🏊🏾","swimmer_tone5":"🏊đŸŋ","swimming":"🏊","swimming_man":"đŸŠâ€â™‚ī¸","swimming_tone1":"🏊đŸģ","swimming_tone2":"🏊đŸŧ","swimming_tone3":"🏊đŸŊ","swimming_tone4":"🏊🏾","swimming_tone5":"🏊đŸŋ","swimming_woman":"đŸŠâ€â™€ī¸","switzerland":"🇨🇭","symbols":"đŸ”Ŗ","synagogue":"🕍","syria":"🇸🇾","syringe":"💉","t-rex":"đŸĻ–","taco":"🌮","tad":"🎉","tada":"🎉","taiwan":"🇹đŸ‡ŧ","tajikistan":"đŸ‡šđŸ‡¯","takeout_box":"đŸĨĄ","tamale":"đŸĢ”","tanabata_tree":"🎋","tangerine":"🍊","tanzania":"🇹đŸ‡ŋ","taurus":"♉","taxi":"🚕","tea":"đŸĩ","teacher":"🧑‍đŸĢ","teacher_tone1":"🧑đŸģ‍đŸĢ","teacher_tone2":"🧑đŸŧ‍đŸĢ","teacher_tone3":"🧑đŸŊ‍đŸĢ","teacher_tone4":"🧑🏾‍đŸĢ","teacher_tone5":"🧑đŸŋ‍đŸĢ","teapot":"đŸĢ–","tears_of_joy":"😂","tears_of_joy_cat":"😹","technologis":"🧑‍đŸ’ģ","technologist":"🧑‍đŸ’ģ","technologist_tone1":"🧑đŸģ‍đŸ’ģ","technologist_tone2":"🧑đŸŧ‍đŸ’ģ","technologist_tone3":"🧑đŸŊ‍đŸ’ģ","technologist_tone4":"🧑🏾‍đŸ’ģ","technologist_tone5":"🧑đŸŋ‍đŸ’ģ","teddy_bear":"🧸","telephone":"☎","telephone_receiver":"📞","telescope":"🔭","ten":"🔟","tennis":"🎾","tent":"â›ē","test_tub":"đŸ§Ē","test_tube":"đŸ§Ē","thailand":"🇹🇭","thermometer":"🌡","thinking":"🤔","thinking_face":"🤔","third_place_medal":"đŸĨ‰","thong_sandal":"🩴","thought_balloon":"💭","thread":"đŸ§ĩ","three":"3ī¸âƒŖ","thumbsdown":"👎","thumbsdown_tone1":"👎đŸģ","thumbsdown_tone2":"👎đŸŧ","thumbsdown_tone3":"👎đŸŊ","thumbsdown_tone4":"👎🏾","thumbsdown_tone5":"👎đŸŋ","thumbsup":"👍","thumbsup_tone1":"👍đŸģ","thumbsup_tone2":"👍đŸŧ","thumbsup_tone3":"👍đŸŊ","thumbsup_tone4":"👍🏾","thumbsup_tone5":"👍đŸŋ","thunder_cloud_and_rain":"⛈","ticket":"đŸŽĢ","tickets":"🎟","tiger":"đŸ¯","tiger2":"🐅","tiger_face":"đŸ¯","timer_clock":"⏲","timor_leste":"🇹🇱","tipping_hand_man":"đŸ’â€â™‚ī¸","tipping_hand_person":"💁","tipping_hand_woman":"đŸ’â€â™€ī¸","tired":"đŸ˜Ģ","tired_face":"đŸ˜Ģ","tm":"â„ĸ","togo":"🇹đŸ‡Ŧ","toilet":"đŸšŊ","toilet_paper":"đŸ§ģ","tokelau":"🇹🇰","tokyo_tower":"đŸ—ŧ","tomato":"🍅","tone1":"đŸģ","tone2":"đŸŧ","tone3":"đŸŊ","tone4":"🏾","tone5":"đŸŋ","tone_dark":"đŸŋ","tone_light":"đŸģ","tone_medium":"đŸŊ","tone_medium_dark":"🏾","tone_medium_light":"đŸŧ","tonga":"🇹🇴","tongue":"👅","too_cool":"😎","toolbox":"🧰","tooth":"đŸĻˇ","toothbrush":"đŸĒĨ","top":"🔝","top_hat":"🎩","tophat":"🎩","tornado":"đŸŒĒ","tr":"🇹🇷","trackball":"🖲","tractor":"🚜","trade_mark":"â„ĸ","traffic_light":"đŸšĨ","train":"🚋","train2":"🚆","tram":"🚊","tram_car":"🚋","transgender_flag":"đŸŗī¸â€âš§ī¸","transgender_symbol":"⚧","trashcan":"🗑","trex":"đŸĻ–","triangular_flag":"🚩","triangular_flag_on_pos":"🚩","triangular_flag_on_post":"🚩","triangular_ruler":"📐","trident":"🔱","trinidad_tobago":"🇹🇹","tristan_da_cunha":"🇹đŸ‡Ļ","triumph":"😤","troll":"🧌","trolleybus":"🚎","trophy":"🏆","tropical_drink":"🍹","tropical_fish":"🐠","truc":"🚚","truck":"🚚","trumpet":"đŸŽē","tshirt":"👕","tulip":"🌷","tumbler_glass":"đŸĨƒ","tunisia":"đŸ‡šđŸ‡ŗ","turkey":"đŸĻƒ","turkey_tr":"🇹🇷","turkmenistan":"🇹🇲","turks_caicos_islands":"🇹🇨","turtle":"đŸĸ","tuvalu":"🇹đŸ‡ģ","tv":"đŸ“ē","twisted_rightwards_arrow":"🔀","twisted_rightwards_arrows":"🔀","two":"2ī¸âƒŖ","two_hearts":"💕","two_men_holding_hands":"đŸ‘Ŧ","two_men_holding_hands_tone1":"đŸ‘ŦđŸģ","two_men_holding_hands_tone1-2":"👨đŸģ‍🤝‍👨đŸŧ","two_men_holding_hands_tone1-3":"👨đŸģ‍🤝‍👨đŸŊ","two_men_holding_hands_tone1-4":"👨đŸģ‍🤝‍👨🏾","two_men_holding_hands_tone1-5":"👨đŸģ‍🤝‍👨đŸŋ","two_men_holding_hands_tone2":"đŸ‘ŦđŸŧ","two_men_holding_hands_tone2-1":"👨đŸŧ‍🤝‍👨đŸģ","two_men_holding_hands_tone2-3":"👨đŸŧ‍🤝‍👨đŸŊ","two_men_holding_hands_tone2-4":"👨đŸŧ‍🤝‍👨🏾","two_men_holding_hands_tone2-5":"👨đŸŧ‍🤝‍👨đŸŋ","two_men_holding_hands_tone3":"đŸ‘ŦđŸŊ","two_men_holding_hands_tone3-1":"👨đŸŊ‍🤝‍👨đŸģ","two_men_holding_hands_tone3-2":"👨đŸŊ‍🤝‍👨đŸŧ","two_men_holding_hands_tone3-4":"👨đŸŊ‍🤝‍👨🏾","two_men_holding_hands_tone3-5":"👨đŸŊ‍🤝‍👨đŸŋ","two_men_holding_hands_tone4":"đŸ‘Ŧ🏾","two_men_holding_hands_tone4-1":"👨🏾‍🤝‍👨đŸģ","two_men_holding_hands_tone4-2":"👨🏾‍🤝‍👨đŸŧ","two_men_holding_hands_tone4-3":"👨🏾‍🤝‍👨đŸŊ","two_men_holding_hands_tone4-5":"👨🏾‍🤝‍👨đŸŋ","two_men_holding_hands_tone5":"đŸ‘ŦđŸŋ","two_men_holding_hands_tone5-1":"👨đŸŋ‍🤝‍👨đŸģ","two_men_holding_hands_tone5-2":"👨đŸŋ‍🤝‍👨đŸŧ","two_men_holding_hands_tone5-3":"👨đŸŋ‍🤝‍👨đŸŊ","two_men_holding_hands_tone5-4":"👨đŸŋ‍🤝‍👨🏾","two_women_holding_hands":"👭","two_women_holding_hands_tone1":"👭đŸģ","two_women_holding_hands_tone1-2":"👩đŸģ‍🤝‍👩đŸŧ","two_women_holding_hands_tone1-3":"👩đŸģ‍🤝‍👩đŸŊ","two_women_holding_hands_tone1-4":"👩đŸģ‍🤝‍👩🏾","two_women_holding_hands_tone1-5":"👩đŸģ‍🤝‍👩đŸŋ","two_women_holding_hands_tone2":"👭đŸŧ","two_women_holding_hands_tone2-1":"👩đŸŧ‍🤝‍👩đŸģ","two_women_holding_hands_tone2-3":"👩đŸŧ‍🤝‍👩đŸŊ","two_women_holding_hands_tone2-4":"👩đŸŧ‍🤝‍👩🏾","two_women_holding_hands_tone2-5":"👩đŸŧ‍🤝‍👩đŸŋ","two_women_holding_hands_tone3":"👭đŸŊ","two_women_holding_hands_tone3-1":"👩đŸŊ‍🤝‍👩đŸģ","two_women_holding_hands_tone3-2":"👩đŸŊ‍🤝‍👩đŸŧ","two_women_holding_hands_tone3-4":"👩đŸŊ‍🤝‍👩🏾","two_women_holding_hands_tone3-5":"👩đŸŊ‍🤝‍👩đŸŋ","two_women_holding_hands_tone4":"👭🏾","two_women_holding_hands_tone4-1":"👩🏾‍🤝‍👩đŸģ","two_women_holding_hands_tone4-2":"👩🏾‍🤝‍👩đŸŧ","two_women_holding_hands_tone4-3":"👩🏾‍🤝‍👩đŸŊ","two_women_holding_hands_tone4-5":"👩🏾‍🤝‍👩đŸŋ","two_women_holding_hands_tone5":"👭đŸŋ","two_women_holding_hands_tone5-1":"👩đŸŋ‍🤝‍👩đŸģ","two_women_holding_hands_tone5-2":"👩đŸŋ‍🤝‍👩đŸŧ","two_women_holding_hands_tone5-3":"👩đŸŋ‍🤝‍👩đŸŊ","two_women_holding_hands_tone5-4":"👩đŸŋ‍🤝‍👩🏾","u5272":"🈹","u5408":"🈴","u55b6":"đŸˆē","u6307":"đŸˆ¯","u6708":"🈷","u6709":"đŸˆļ","u6e80":"đŸˆĩ","u7121":"🈚","u7533":"🈸","u7981":"🈲","u7a7a":"đŸˆŗ","uganda":"đŸ‡ēđŸ‡Ŧ","uk":"đŸ‡Ŧ🇧","ukraine":"đŸ‡ēđŸ‡Ļ","umbrella":"☔","umbrella_on_ground":"⛱","umbrella_with_rain":"☔","un":"đŸ‡ēđŸ‡ŗ","unamused":"😒","unamused_face":"😒","underage":"🔞","unicorn":"đŸĻ„","unicorn_face":"đŸĻ„","united_arab_emirates":"đŸ‡ĻđŸ‡Ē","united_kingdom":"đŸ‡Ŧ🇧","united_nations":"đŸ‡ēđŸ‡ŗ","united_states":"đŸ‡ē🇸","unlock":"🔓","unlocked":"🔓","up":"🆙","up2":"🆙","upside_down_face":"🙃","uruguay":"đŸ‡ē🇾","us":"đŸ‡ē🇸","us_outlying_islands":"đŸ‡ē🇲","us_virgin_islands":"đŸ‡ģ🇮","usa":"đŸ‡ē🇸","uzbekistan":"đŸ‡ēđŸ‡ŋ","v":"✌","v_tone1":"✌đŸģ","v_tone2":"✌đŸŧ","v_tone3":"✌đŸŊ","v_tone4":"✌🏾","v_tone5":"✌đŸŋ","vampire":"🧛","vampire_man":"đŸ§›â€â™‚ī¸","vampire_tone1":"🧛đŸģ","vampire_tone2":"🧛đŸŧ","vampire_tone3":"🧛đŸŊ","vampire_tone4":"🧛🏾","vampire_tone5":"🧛đŸŋ","vampire_woman":"đŸ§›â€â™€ī¸","vanuatu":"đŸ‡ģđŸ‡ē","vatican_city":"đŸ‡ģđŸ‡Ļ","venezuela":"đŸ‡ģđŸ‡Ē","vertical_traffic_light":"đŸšĻ","vhs":"đŸ“ŧ","vibration_mode":"đŸ“ŗ","victory":"✌","victory_tone1":"✌đŸģ","victory_tone2":"✌đŸŧ","victory_tone3":"✌đŸŊ","victory_tone4":"✌🏾","victory_tone5":"✌đŸŋ","video_camera":"📹","video_game":"🎮","videocassette":"đŸ“ŧ","vietnam":"đŸ‡ģđŸ‡ŗ","violin":"đŸŽģ","virgo":"♍","volcano":"🌋","volleyball":"🏐","vomiting":"🤮","vomiting_face":"🤮","vs":"🆚","vulcan":"🖖","vulcan_salute":"🖖","vulcan_tone1":"🖖đŸģ","vulcan_tone2":"🖖đŸŧ","vulcan_tone3":"🖖đŸŊ","vulcan_tone4":"🖖🏾","vulcan_tone5":"🖖đŸŋ","waffle":"🧇","wales":"đŸ´ķ §ķ ĸķ ˇķ Ŧķ ŗķ ŋ","walking":"đŸšļ","walking_man":"đŸšļâ€â™‚ī¸","walking_tone1":"đŸšļđŸģ","walking_tone2":"đŸšļđŸŧ","walking_tone3":"đŸšļđŸŊ","walking_tone4":"đŸšļ🏾","walking_tone5":"đŸšļđŸŋ","walking_woman":"đŸšļâ€â™€ī¸","wallis_futuna":"đŸ‡ŧđŸ‡Ģ","waning_crescent_moon":"🌘","waning_gibbous_moon":"🌖","warning":"⚠","wastebaske":"đŸ—‘ī¸","wastebasket":"🗑","watch":"⌚","water_buffalo":"🐃","water_closet":"🚾","water_polo":"đŸ¤Ŋ","water_polo_tone1":"đŸ¤ŊđŸģ","water_polo_tone2":"đŸ¤ŊđŸŧ","water_polo_tone3":"đŸ¤ŊđŸŊ","water_polo_tone4":"đŸ¤Ŋ🏾","water_polo_tone5":"đŸ¤ŊđŸŋ","water_wave":"🌊","watermelon":"🍉","watery_eyes":"đŸĨš","wave":"👋","wave_tone1":"👋đŸģ","wave_tone2":"👋đŸŧ","wave_tone3":"👋đŸŊ","wave_tone4":"👋🏾","wave_tone5":"👋đŸŋ","waving_hand":"👋","waving_hand_tone1":"👋đŸģ","waving_hand_tone2":"👋đŸŧ","waving_hand_tone3":"👋đŸŊ","waving_hand_tone4":"👋🏾","waving_hand_tone5":"👋đŸŋ","wavy_dash":"〰","waxing_crescent_moon":"🌒","waxing_gibbous_moon":"🌔","wc":"🚾","weary":"😩","weary_cat":"🙀","weary_face":"😩","wedding":"💒","weight_lifter":"🏋","weight_lifter_tone1":"🏋đŸģ","weight_lifter_tone2":"🏋đŸŧ","weight_lifter_tone3":"🏋đŸŊ","weight_lifter_tone4":"🏋🏾","weight_lifter_tone5":"🏋đŸŋ","weight_lifting":"🏋","weight_lifting_man":"đŸ‹ī¸â€â™‚ī¸","weight_lifting_tone1":"🏋đŸģ","weight_lifting_tone2":"🏋đŸŧ","weight_lifting_tone3":"🏋đŸŊ","weight_lifting_tone4":"🏋🏾","weight_lifting_tone5":"🏋đŸŋ","weight_lifting_woman":"đŸ‹ī¸â€â™€ī¸","western_sahara":"đŸ‡Ē🇭","whale":"đŸŗ","whale2":"🐋","wheel":"🛞","wheel_of_dharma":"☸","wheelchai":"â™ŋī¸","wheelchair":"â™ŋ","whisky":"đŸĨƒ","white_cane":"đŸĻ¯","white_check_mar":"✅","white_check_mark":"✅","white_circle":"âšĒ","white_exclamation":"❕","white_flag":"đŸŗ","white_flower":"💮","white_frowning_face":"☚","white_hair":"đŸĻŗ","white_haired":"🧑‍đŸĻŗ","white_haired_man":"👨‍đŸĻŗ","white_haired_tone1":"🧑đŸģ‍đŸĻŗ","white_haired_tone2":"🧑đŸŧ‍đŸĻŗ","white_haired_tone3":"🧑đŸŊ‍đŸĻŗ","white_haired_tone4":"🧑🏾‍đŸĻŗ","white_haired_tone5":"🧑đŸŋ‍đŸĻŗ","white_haired_woman":"👩‍đŸĻŗ","white_heart":"🤍","white_large_square":"âŦœ","white_medium_small_square":"â—Ŋ","white_medium_square":"â—ģ","white_question":"❔","white_small_square":"â–Ģ","white_square_button":"đŸ”ŗ","wilted_flower":"đŸĨ€","wind_blowing_face":"đŸŒŦ","wind_chime":"🎐","wind_face":"đŸŒŦ","window":"đŸĒŸ","wine_glass":"🍷","wink":"😉","winking_face":"😉","wolf":"đŸē","wolf_face":"đŸē","woman":"👩","woman_artist":"👩‍🎨","woman_artist_tone1":"👩đŸģ‍🎨","woman_artist_tone2":"👩đŸŧ‍🎨","woman_artist_tone3":"👩đŸŊ‍🎨","woman_artist_tone4":"👩🏾‍🎨","woman_artist_tone5":"👩đŸŋ‍🎨","woman_astronaut":"👩‍🚀","woman_astronaut_tone1":"👩đŸģ‍🚀","woman_astronaut_tone2":"👩đŸŧ‍🚀","woman_astronaut_tone3":"👩đŸŊ‍🚀","woman_astronaut_tone4":"👩🏾‍🚀","woman_astronaut_tone5":"👩đŸŋ‍🚀","woman_bald":"👩‍đŸĻ˛","woman_bald_tone1":"👩đŸģ‍đŸĻ˛","woman_bald_tone2":"👩đŸŧ‍đŸĻ˛","woman_bald_tone3":"👩đŸŊ‍đŸĻ˛","woman_bald_tone4":"👩🏾‍đŸĻ˛","woman_bald_tone5":"👩đŸŋ‍đŸĻ˛","woman_beard":"đŸ§”â€â™€ī¸","woman_bearded":"đŸ§”â€â™€ī¸","woman_bearded_tone1":"🧔đŸģâ€â™€ī¸","woman_bearded_tone2":"🧔đŸŧâ€â™€ī¸","woman_bearded_tone3":"🧔đŸŊâ€â™€ī¸","woman_bearded_tone4":"đŸ§”đŸžâ€â™€ī¸","woman_bearded_tone5":"🧔đŸŋâ€â™€ī¸","woman_biking":"đŸš´â€â™€ī¸","woman_biking_tone1":"🚴đŸģâ€â™€ī¸","woman_biking_tone2":"🚴đŸŧâ€â™€ī¸","woman_biking_tone3":"🚴đŸŊâ€â™€ī¸","woman_biking_tone4":"đŸš´đŸžâ€â™€ī¸","woman_biking_tone5":"🚴đŸŋâ€â™€ī¸","woman_blond_haired":"đŸ‘ąâ€â™€ī¸","woman_blond_haired_tone1":"👱đŸģâ€â™€ī¸","woman_blond_haired_tone2":"👱đŸŧâ€â™€ī¸","woman_blond_haired_tone3":"👱đŸŊâ€â™€ī¸","woman_blond_haired_tone4":"đŸ‘ąđŸžâ€â™€ī¸","woman_blond_haired_tone5":"👱đŸŋâ€â™€ī¸","woman_bouncing_ball":"â›šī¸â€â™€ī¸","woman_bouncing_ball_tone1":"⛹đŸģâ€â™€ī¸","woman_bouncing_ball_tone2":"⛹đŸŧâ€â™€ī¸","woman_bouncing_ball_tone3":"⛹đŸŊâ€â™€ī¸","woman_bouncing_ball_tone4":"â›šđŸžâ€â™€ī¸","woman_bouncing_ball_tone5":"⛹đŸŋâ€â™€ī¸","woman_bowing":"đŸ™‡â€â™€ī¸","woman_bowing_tone1":"🙇đŸģâ€â™€ī¸","woman_bowing_tone2":"🙇đŸŧâ€â™€ī¸","woman_bowing_tone3":"🙇đŸŊâ€â™€ī¸","woman_bowing_tone4":"đŸ™‡đŸžâ€â™€ī¸","woman_bowing_tone5":"🙇đŸŋâ€â™€ī¸","woman_cartwheeling":"đŸ¤¸â€â™€ī¸","woman_cartwheeling_tone1":"🤸đŸģâ€â™€ī¸","woman_cartwheeling_tone2":"🤸đŸŧâ€â™€ī¸","woman_cartwheeling_tone3":"🤸đŸŊâ€â™€ī¸","woman_cartwheeling_tone4":"đŸ¤¸đŸžâ€â™€ī¸","woman_cartwheeling_tone5":"🤸đŸŋâ€â™€ī¸","woman_climbing":"đŸ§—â€â™€ī¸","woman_climbing_tone1":"🧗đŸģâ€â™€ī¸","woman_climbing_tone2":"🧗đŸŧâ€â™€ī¸","woman_climbing_tone3":"🧗đŸŊâ€â™€ī¸","woman_climbing_tone4":"đŸ§—đŸžâ€â™€ī¸","woman_climbing_tone5":"🧗đŸŋâ€â™€ī¸","woman_construction_worker":"đŸ‘ˇâ€â™€ī¸","woman_construction_worker_tone1":"👷đŸģâ€â™€ī¸","woman_construction_worker_tone2":"👷đŸŧâ€â™€ī¸","woman_construction_worker_tone3":"👷đŸŊâ€â™€ī¸","woman_construction_worker_tone4":"đŸ‘ˇđŸžâ€â™€ī¸","woman_construction_worker_tone5":"👷đŸŋâ€â™€ī¸","woman_cook":"đŸ‘Šâ€đŸŗ","woman_cook_tone1":"👩đŸģâ€đŸŗ","woman_cook_tone2":"👩đŸŧâ€đŸŗ","woman_cook_tone3":"👩đŸŊâ€đŸŗ","woman_cook_tone4":"đŸ‘ŠđŸžâ€đŸŗ","woman_cook_tone5":"👩đŸŋâ€đŸŗ","woman_curly_haired":"👩‍đŸĻą","woman_curly_haired_tone1":"👩đŸģ‍đŸĻą","woman_curly_haired_tone2":"👩đŸŧ‍đŸĻą","woman_curly_haired_tone3":"👩đŸŊ‍đŸĻą","woman_curly_haired_tone4":"👩🏾‍đŸĻą","woman_curly_haired_tone5":"👩đŸŋ‍đŸĻą","woman_dancing":"💃","woman_dancing_tone1":"💃đŸģ","woman_dancing_tone2":"💃đŸŧ","woman_dancing_tone3":"💃đŸŊ","woman_dancing_tone4":"💃🏾","woman_dancing_tone5":"💃đŸŋ","woman_detective":"đŸ•ĩī¸â€â™€ī¸","woman_detective_tone1":"đŸ•ĩđŸģâ€â™€ī¸","woman_detective_tone2":"đŸ•ĩđŸŧâ€â™€ī¸","woman_detective_tone3":"đŸ•ĩđŸŊâ€â™€ī¸","woman_detective_tone4":"đŸ•ĩđŸžâ€â™€ī¸","woman_detective_tone5":"đŸ•ĩđŸŋâ€â™€ī¸","woman_elf":"đŸ§â€â™€ī¸","woman_elf_tone1":"🧝đŸģâ€â™€ī¸","woman_elf_tone2":"🧝đŸŧâ€â™€ī¸","woman_elf_tone3":"🧝đŸŊâ€â™€ī¸","woman_elf_tone4":"đŸ§đŸžâ€â™€ī¸","woman_elf_tone5":"🧝đŸŋâ€â™€ī¸","woman_facepalming":"đŸ¤Ļâ€â™€ī¸","woman_facepalming_tone1":"đŸ¤ĻđŸģâ€â™€ī¸","woman_facepalming_tone2":"đŸ¤ĻđŸŧâ€â™€ī¸","woman_facepalming_tone3":"đŸ¤ĻđŸŊâ€â™€ī¸","woman_facepalming_tone4":"đŸ¤ĻđŸžâ€â™€ī¸","woman_facepalming_tone5":"đŸ¤ĻđŸŋâ€â™€ī¸","woman_factory_worker":"👩‍🏭","woman_factory_worker_tone1":"👩đŸģ‍🏭","woman_factory_worker_tone2":"👩đŸŧ‍🏭","woman_factory_worker_tone3":"👩đŸŊ‍🏭","woman_factory_worker_tone4":"👩🏾‍🏭","woman_factory_worker_tone5":"👩đŸŋ‍🏭","woman_fairy":"đŸ§šâ€â™€ī¸","woman_fairy_tone1":"🧚đŸģâ€â™€ī¸","woman_fairy_tone2":"🧚đŸŧâ€â™€ī¸","woman_fairy_tone3":"🧚đŸŊâ€â™€ī¸","woman_fairy_tone4":"đŸ§šđŸžâ€â™€ī¸","woman_fairy_tone5":"🧚đŸŋâ€â™€ī¸","woman_farmer":"👩‍🌾","woman_farmer_tone1":"👩đŸģ‍🌾","woman_farmer_tone2":"👩đŸŧ‍🌾","woman_farmer_tone3":"👩đŸŊ‍🌾","woman_farmer_tone4":"👩🏾‍🌾","woman_farmer_tone5":"👩đŸŋ‍🌾","woman_feeding_baby":"👩‍đŸŧ","woman_feeding_baby_tone1":"👩đŸģ‍đŸŧ","woman_feeding_baby_tone2":"👩đŸŧ‍đŸŧ","woman_feeding_baby_tone3":"👩đŸŊ‍đŸŧ","woman_feeding_baby_tone4":"👩🏾‍đŸŧ","woman_feeding_baby_tone5":"👩đŸŋ‍đŸŧ","woman_firefighter":"👩‍🚒","woman_firefighter_tone1":"👩đŸģ‍🚒","woman_firefighter_tone2":"👩đŸŧ‍🚒","woman_firefighter_tone3":"👩đŸŊ‍🚒","woman_firefighter_tone4":"👩🏾‍🚒","woman_firefighter_tone5":"👩đŸŋ‍🚒","woman_frowning":"đŸ™â€â™€ī¸","woman_frowning_tone1":"🙍đŸģâ€â™€ī¸","woman_frowning_tone2":"🙍đŸŧâ€â™€ī¸","woman_frowning_tone3":"🙍đŸŊâ€â™€ī¸","woman_frowning_tone4":"đŸ™đŸžâ€â™€ī¸","woman_frowning_tone5":"🙍đŸŋâ€â™€ī¸","woman_genie":"đŸ§žâ€â™€ī¸","woman_gesturing_no":"đŸ™…â€â™€ī¸","woman_gesturing_no_tone1":"🙅đŸģâ€â™€ī¸","woman_gesturing_no_tone2":"🙅đŸŧâ€â™€ī¸","woman_gesturing_no_tone3":"🙅đŸŊâ€â™€ī¸","woman_gesturing_no_tone4":"đŸ™…đŸžâ€â™€ī¸","woman_gesturing_no_tone5":"🙅đŸŋâ€â™€ī¸","woman_gesturing_ok":"đŸ™†â€â™€ī¸","woman_gesturing_ok_tone1":"🙆đŸģâ€â™€ī¸","woman_gesturing_ok_tone2":"🙆đŸŧâ€â™€ī¸","woman_gesturing_ok_tone3":"🙆đŸŊâ€â™€ī¸","woman_gesturing_ok_tone4":"đŸ™†đŸžâ€â™€ī¸","woman_gesturing_ok_tone5":"🙆đŸŋâ€â™€ī¸","woman_getting_haircut":"đŸ’‡â€â™€ī¸","woman_getting_haircut_tone1":"💇đŸģâ€â™€ī¸","woman_getting_haircut_tone2":"💇đŸŧâ€â™€ī¸","woman_getting_haircut_tone3":"💇đŸŊâ€â™€ī¸","woman_getting_haircut_tone4":"đŸ’‡đŸžâ€â™€ī¸","woman_getting_haircut_tone5":"💇đŸŋâ€â™€ī¸","woman_getting_massage":"đŸ’†â€â™€ī¸","woman_getting_massage_tone1":"💆đŸģâ€â™€ī¸","woman_getting_massage_tone2":"💆đŸŧâ€â™€ī¸","woman_getting_massage_tone3":"💆đŸŊâ€â™€ī¸","woman_getting_massage_tone4":"đŸ’†đŸžâ€â™€ī¸","woman_getting_massage_tone5":"💆đŸŋâ€â™€ī¸","woman_golfing":"đŸŒī¸â€â™€ī¸","woman_golfing_tone1":"🏌đŸģâ€â™€ī¸","woman_golfing_tone2":"🏌đŸŧâ€â™€ī¸","woman_golfing_tone3":"🏌đŸŊâ€â™€ī¸","woman_golfing_tone4":"đŸŒđŸžâ€â™€ī¸","woman_golfing_tone5":"🏌đŸŋâ€â™€ī¸","woman_guard":"đŸ’‚â€â™€ī¸","woman_guard_tone1":"💂đŸģâ€â™€ī¸","woman_guard_tone2":"💂đŸŧâ€â™€ī¸","woman_guard_tone3":"💂đŸŊâ€â™€ī¸","woman_guard_tone4":"đŸ’‚đŸžâ€â™€ī¸","woman_guard_tone5":"💂đŸŋâ€â™€ī¸","woman_health_worker":"đŸ‘Šâ€âš•ī¸","woman_health_worker_tone1":"👩đŸģâ€âš•ī¸","woman_health_worker_tone2":"👩đŸŧâ€âš•ī¸","woman_health_worker_tone3":"👩đŸŊâ€âš•ī¸","woman_health_worker_tone4":"đŸ‘ŠđŸžâ€âš•ī¸","woman_health_worker_tone5":"👩đŸŋâ€âš•ī¸","woman_in_lotus_position":"đŸ§˜â€â™€ī¸","woman_in_lotus_position_tone1":"🧘đŸģâ€â™€ī¸","woman_in_lotus_position_tone2":"🧘đŸŧâ€â™€ī¸","woman_in_lotus_position_tone3":"🧘đŸŊâ€â™€ī¸","woman_in_lotus_position_tone4":"đŸ§˜đŸžâ€â™€ī¸","woman_in_lotus_position_tone5":"🧘đŸŋâ€â™€ī¸","woman_in_manual_wheelchair":"👩‍đŸĻŊ","woman_in_manual_wheelchair_tone1":"👩đŸģ‍đŸĻŊ","woman_in_manual_wheelchair_tone2":"👩đŸŧ‍đŸĻŊ","woman_in_manual_wheelchair_tone3":"👩đŸŊ‍đŸĻŊ","woman_in_manual_wheelchair_tone4":"👩🏾‍đŸĻŊ","woman_in_manual_wheelchair_tone5":"👩đŸŋ‍đŸĻŊ","woman_in_motorized_wheelchair":"👩‍đŸĻŧ","woman_in_motorized_wheelchair_tone1":"👩đŸģ‍đŸĻŧ","woman_in_motorized_wheelchair_tone2":"👩đŸŧ‍đŸĻŧ","woman_in_motorized_wheelchair_tone3":"👩đŸŊ‍đŸĻŧ","woman_in_motorized_wheelchair_tone4":"👩🏾‍đŸĻŧ","woman_in_motorized_wheelchair_tone5":"👩đŸŋ‍đŸĻŧ","woman_in_steamy_room":"đŸ§–â€â™€ī¸","woman_in_steamy_room_tone1":"🧖đŸģâ€â™€ī¸","woman_in_steamy_room_tone2":"🧖đŸŧâ€â™€ī¸","woman_in_steamy_room_tone3":"🧖đŸŊâ€â™€ī¸","woman_in_steamy_room_tone4":"đŸ§–đŸžâ€â™€ī¸","woman_in_steamy_room_tone5":"🧖đŸŋâ€â™€ī¸","woman_in_tuxedo":"đŸ¤ĩâ€â™€ī¸","woman_in_tuxedo_tone1":"đŸ¤ĩđŸģâ€â™€ī¸","woman_in_tuxedo_tone2":"đŸ¤ĩđŸŧâ€â™€ī¸","woman_in_tuxedo_tone3":"đŸ¤ĩđŸŊâ€â™€ī¸","woman_in_tuxedo_tone4":"đŸ¤ĩđŸžâ€â™€ī¸","woman_in_tuxedo_tone5":"đŸ¤ĩđŸŋâ€â™€ī¸","woman_judge":"đŸ‘Šâ€âš–ī¸","woman_judge_tone1":"👩đŸģâ€âš–ī¸","woman_judge_tone2":"👩đŸŧâ€âš–ī¸","woman_judge_tone3":"👩đŸŊâ€âš–ī¸","woman_judge_tone4":"đŸ‘ŠđŸžâ€âš–ī¸","woman_judge_tone5":"👩đŸŋâ€âš–ī¸","woman_juggling":"đŸ¤šâ€â™€ī¸","woman_juggling_tone1":"🤹đŸģâ€â™€ī¸","woman_juggling_tone2":"🤹đŸŧâ€â™€ī¸","woman_juggling_tone3":"🤹đŸŊâ€â™€ī¸","woman_juggling_tone4":"đŸ¤šđŸžâ€â™€ī¸","woman_juggling_tone5":"🤹đŸŋâ€â™€ī¸","woman_kneeling":"đŸ§Žâ€â™€ī¸","woman_kneeling_tone1":"🧎đŸģâ€â™€ī¸","woman_kneeling_tone2":"🧎đŸŧâ€â™€ī¸","woman_kneeling_tone3":"🧎đŸŊâ€â™€ī¸","woman_kneeling_tone4":"đŸ§ŽđŸžâ€â™€ī¸","woman_kneeling_tone5":"🧎đŸŋâ€â™€ī¸","woman_lifting_weights":"đŸ‹ī¸â€â™€ī¸","woman_lifting_weights_tone1":"🏋đŸģâ€â™€ī¸","woman_lifting_weights_tone2":"🏋đŸŧâ€â™€ī¸","woman_lifting_weights_tone3":"🏋đŸŊâ€â™€ī¸","woman_lifting_weights_tone4":"đŸ‹đŸžâ€â™€ī¸","woman_lifting_weights_tone5":"🏋đŸŋâ€â™€ī¸","woman_mage":"đŸ§™â€â™€ī¸","woman_mage_tone1":"🧙đŸģâ€â™€ī¸","woman_mage_tone2":"🧙đŸŧâ€â™€ī¸","woman_mage_tone3":"🧙đŸŊâ€â™€ī¸","woman_mage_tone4":"đŸ§™đŸžâ€â™€ī¸","woman_mage_tone5":"🧙đŸŋâ€â™€ī¸","woman_mechanic":"👩‍🔧","woman_mechanic_tone1":"👩đŸģ‍🔧","woman_mechanic_tone2":"👩đŸŧ‍🔧","woman_mechanic_tone3":"👩đŸŊ‍🔧","woman_mechanic_tone4":"👩🏾‍🔧","woman_mechanic_tone5":"👩đŸŋ‍🔧","woman_mountain_biking":"đŸšĩâ€â™€ī¸","woman_mountain_biking_tone1":"đŸšĩđŸģâ€â™€ī¸","woman_mountain_biking_tone2":"đŸšĩđŸŧâ€â™€ī¸","woman_mountain_biking_tone3":"đŸšĩđŸŊâ€â™€ī¸","woman_mountain_biking_tone4":"đŸšĩđŸžâ€â™€ī¸","woman_mountain_biking_tone5":"đŸšĩđŸŋâ€â™€ī¸","woman_office_worker":"👩‍đŸ’ŧ","woman_office_worker_tone1":"👩đŸģ‍đŸ’ŧ","woman_office_worker_tone2":"👩đŸŧ‍đŸ’ŧ","woman_office_worker_tone3":"👩đŸŊ‍đŸ’ŧ","woman_office_worker_tone4":"👩🏾‍đŸ’ŧ","woman_office_worker_tone5":"👩đŸŋ‍đŸ’ŧ","woman_pilot":"đŸ‘Šâ€âœˆī¸","woman_pilot_tone1":"👩đŸģâ€âœˆī¸","woman_pilot_tone2":"👩đŸŧâ€âœˆī¸","woman_pilot_tone3":"👩đŸŊâ€âœˆī¸","woman_pilot_tone4":"đŸ‘ŠđŸžâ€âœˆī¸","woman_pilot_tone5":"👩đŸŋâ€âœˆī¸","woman_playing_handball":"đŸ¤žâ€â™€ī¸","woman_playing_handball_tone1":"🤾đŸģâ€â™€ī¸","woman_playing_handball_tone2":"🤾đŸŧâ€â™€ī¸","woman_playing_handball_tone3":"🤾đŸŊâ€â™€ī¸","woman_playing_handball_tone4":"đŸ¤žđŸžâ€â™€ī¸","woman_playing_handball_tone5":"🤾đŸŋâ€â™€ī¸","woman_playing_water_polo":"đŸ¤Ŋâ€â™€ī¸","woman_playing_water_polo_tone1":"đŸ¤ŊđŸģâ€â™€ī¸","woman_playing_water_polo_tone2":"đŸ¤ŊđŸŧâ€â™€ī¸","woman_playing_water_polo_tone3":"đŸ¤ŊđŸŊâ€â™€ī¸","woman_playing_water_polo_tone4":"đŸ¤ŊđŸžâ€â™€ī¸","woman_playing_water_polo_tone5":"đŸ¤ŊđŸŋâ€â™€ī¸","woman_police_officer":"đŸ‘Žâ€â™€ī¸","woman_police_officer_tone1":"👮đŸģâ€â™€ī¸","woman_police_officer_tone2":"👮đŸŧâ€â™€ī¸","woman_police_officer_tone3":"👮đŸŊâ€â™€ī¸","woman_police_officer_tone4":"đŸ‘ŽđŸžâ€â™€ī¸","woman_police_officer_tone5":"👮đŸŋâ€â™€ī¸","woman_pouting":"đŸ™Žâ€â™€ī¸","woman_pouting_tone1":"🙎đŸģâ€â™€ī¸","woman_pouting_tone2":"🙎đŸŧâ€â™€ī¸","woman_pouting_tone3":"🙎đŸŊâ€â™€ī¸","woman_pouting_tone4":"đŸ™ŽđŸžâ€â™€ī¸","woman_pouting_tone5":"🙎đŸŋâ€â™€ī¸","woman_raising_hand":"đŸ™‹â€â™€ī¸","woman_raising_hand_tone1":"🙋đŸģâ€â™€ī¸","woman_raising_hand_tone2":"🙋đŸŧâ€â™€ī¸","woman_raising_hand_tone3":"🙋đŸŊâ€â™€ī¸","woman_raising_hand_tone4":"đŸ™‹đŸžâ€â™€ī¸","woman_raising_hand_tone5":"🙋đŸŋâ€â™€ī¸","woman_red_haired":"👩‍đŸĻ°","woman_red_haired_tone1":"👩đŸģ‍đŸĻ°","woman_red_haired_tone2":"👩đŸŧ‍đŸĻ°","woman_red_haired_tone3":"👩đŸŊ‍đŸĻ°","woman_red_haired_tone4":"👩🏾‍đŸĻ°","woman_red_haired_tone5":"👩đŸŋ‍đŸĻ°","woman_rowing_boat":"đŸšŖâ€â™€ī¸","woman_rowing_boat_tone1":"đŸšŖđŸģâ€â™€ī¸","woman_rowing_boat_tone2":"đŸšŖđŸŧâ€â™€ī¸","woman_rowing_boat_tone3":"đŸšŖđŸŊâ€â™€ī¸","woman_rowing_boat_tone4":"đŸšŖđŸžâ€â™€ī¸","woman_rowing_boat_tone5":"đŸšŖđŸŋâ€â™€ī¸","woman_running":"đŸƒâ€â™€ī¸","woman_running_tone1":"🏃đŸģâ€â™€ī¸","woman_running_tone2":"🏃đŸŧâ€â™€ī¸","woman_running_tone3":"🏃đŸŊâ€â™€ī¸","woman_running_tone4":"đŸƒđŸžâ€â™€ī¸","woman_running_tone5":"🏃đŸŋâ€â™€ī¸","woman_scientist":"👩‍đŸ”Ŧ","woman_scientist_tone1":"👩đŸģ‍đŸ”Ŧ","woman_scientist_tone2":"👩đŸŧ‍đŸ”Ŧ","woman_scientist_tone3":"👩đŸŊ‍đŸ”Ŧ","woman_scientist_tone4":"👩🏾‍đŸ”Ŧ","woman_scientist_tone5":"👩đŸŋ‍đŸ”Ŧ","woman_shrugging":"đŸ¤ˇâ€â™€ī¸","woman_shrugging_tone1":"🤷đŸģâ€â™€ī¸","woman_shrugging_tone2":"🤷đŸŧâ€â™€ī¸","woman_shrugging_tone3":"🤷đŸŊâ€â™€ī¸","woman_shrugging_tone4":"đŸ¤ˇđŸžâ€â™€ī¸","woman_shrugging_tone5":"🤷đŸŋâ€â™€ī¸","woman_singer":"👩‍🎤","woman_singer_tone1":"👩đŸģ‍🎤","woman_singer_tone2":"👩đŸŧ‍🎤","woman_singer_tone3":"👩đŸŊ‍🎤","woman_singer_tone4":"👩🏾‍🎤","woman_singer_tone5":"👩đŸŋ‍🎤","woman_standing":"đŸ§â€â™€ī¸","woman_standing_tone1":"🧍đŸģâ€â™€ī¸","woman_standing_tone2":"🧍đŸŧâ€â™€ī¸","woman_standing_tone3":"🧍đŸŊâ€â™€ī¸","woman_standing_tone4":"đŸ§đŸžâ€â™€ī¸","woman_standing_tone5":"🧍đŸŋâ€â™€ī¸","woman_student":"👩‍🎓","woman_student_tone1":"👩đŸģ‍🎓","woman_student_tone2":"👩đŸŧ‍🎓","woman_student_tone3":"👩đŸŊ‍🎓","woman_student_tone4":"👩🏾‍🎓","woman_student_tone5":"👩đŸŋ‍🎓","woman_superhero":"đŸĻ¸â€â™€ī¸","woman_superhero_tone1":"đŸĻ¸đŸģâ€â™€ī¸","woman_superhero_tone2":"đŸĻ¸đŸŧâ€â™€ī¸","woman_superhero_tone3":"đŸĻ¸đŸŊâ€â™€ī¸","woman_superhero_tone4":"đŸĻ¸đŸžâ€â™€ī¸","woman_superhero_tone5":"đŸĻ¸đŸŋâ€â™€ī¸","woman_supervillain":"đŸĻšâ€â™€ī¸","woman_supervillain_tone1":"đŸĻšđŸģâ€â™€ī¸","woman_supervillain_tone2":"đŸĻšđŸŧâ€â™€ī¸","woman_supervillain_tone3":"đŸĻšđŸŊâ€â™€ī¸","woman_supervillain_tone4":"đŸĻšđŸžâ€â™€ī¸","woman_supervillain_tone5":"đŸĻšđŸŋâ€â™€ī¸","woman_surfing":"đŸ„â€â™€ī¸","woman_surfing_tone1":"🏄đŸģâ€â™€ī¸","woman_surfing_tone2":"🏄đŸŧâ€â™€ī¸","woman_surfing_tone3":"🏄đŸŊâ€â™€ī¸","woman_surfing_tone4":"đŸ„đŸžâ€â™€ī¸","woman_surfing_tone5":"🏄đŸŋâ€â™€ī¸","woman_swimming":"đŸŠâ€â™€ī¸","woman_swimming_tone1":"🏊đŸģâ€â™€ī¸","woman_swimming_tone2":"🏊đŸŧâ€â™€ī¸","woman_swimming_tone3":"🏊đŸŊâ€â™€ī¸","woman_swimming_tone4":"đŸŠđŸžâ€â™€ī¸","woman_swimming_tone5":"🏊đŸŋâ€â™€ī¸","woman_teacher":"👩‍đŸĢ","woman_teacher_tone1":"👩đŸģ‍đŸĢ","woman_teacher_tone2":"👩đŸŧ‍đŸĢ","woman_teacher_tone3":"👩đŸŊ‍đŸĢ","woman_teacher_tone4":"👩🏾‍đŸĢ","woman_teacher_tone5":"👩đŸŋ‍đŸĢ","woman_technologist":"👩‍đŸ’ģ","woman_technologist_tone1":"👩đŸģ‍đŸ’ģ","woman_technologist_tone2":"👩đŸŧ‍đŸ’ģ","woman_technologist_tone3":"👩đŸŊ‍đŸ’ģ","woman_technologist_tone4":"👩🏾‍đŸ’ģ","woman_technologist_tone5":"👩đŸŋ‍đŸ’ģ","woman_tipping_hand":"đŸ’â€â™€ī¸","woman_tipping_hand_tone1":"💁đŸģâ€â™€ī¸","woman_tipping_hand_tone2":"💁đŸŧâ€â™€ī¸","woman_tipping_hand_tone3":"💁đŸŊâ€â™€ī¸","woman_tipping_hand_tone4":"đŸ’đŸžâ€â™€ī¸","woman_tipping_hand_tone5":"💁đŸŋâ€â™€ī¸","woman_tone1":"👩đŸģ","woman_tone2":"👩đŸŧ","woman_tone3":"👩đŸŊ","woman_tone4":"👩🏾","woman_tone5":"👩đŸŋ","woman_vampire":"đŸ§›â€â™€ī¸","woman_vampire_tone1":"🧛đŸģâ€â™€ī¸","woman_vampire_tone2":"🧛đŸŧâ€â™€ī¸","woman_vampire_tone3":"🧛đŸŊâ€â™€ī¸","woman_vampire_tone4":"đŸ§›đŸžâ€â™€ī¸","woman_vampire_tone5":"🧛đŸŋâ€â™€ī¸","woman_walking":"đŸšļâ€â™€ī¸","woman_walking_tone1":"đŸšļđŸģâ€â™€ī¸","woman_walking_tone2":"đŸšļđŸŧâ€â™€ī¸","woman_walking_tone3":"đŸšļđŸŊâ€â™€ī¸","woman_walking_tone4":"đŸšļđŸžâ€â™€ī¸","woman_walking_tone5":"đŸšļđŸŋâ€â™€ī¸","woman_wearing_turban":"đŸ‘ŗâ€â™€ī¸","woman_wearing_turban_tone1":"đŸ‘ŗđŸģâ€â™€ī¸","woman_wearing_turban_tone2":"đŸ‘ŗđŸŧâ€â™€ī¸","woman_wearing_turban_tone3":"đŸ‘ŗđŸŊâ€â™€ī¸","woman_wearing_turban_tone4":"đŸ‘ŗđŸžâ€â™€ī¸","woman_wearing_turban_tone5":"đŸ‘ŗđŸŋâ€â™€ī¸","woman_white_haired":"👩‍đŸĻŗ","woman_white_haired_tone1":"👩đŸģ‍đŸĻŗ","woman_white_haired_tone2":"👩đŸŧ‍đŸĻŗ","woman_white_haired_tone3":"👩đŸŊ‍đŸĻŗ","woman_white_haired_tone4":"👩🏾‍đŸĻŗ","woman_white_haired_tone5":"👩đŸŋ‍đŸĻŗ","woman_with_headscarf":"🧕","woman_with_headscarf_tone1":"🧕đŸģ","woman_with_headscarf_tone2":"🧕đŸŧ","woman_with_headscarf_tone3":"🧕đŸŊ","woman_with_headscarf_tone4":"🧕🏾","woman_with_headscarf_tone5":"🧕đŸŋ","woman_with_probing_cane":"👩‍đŸĻ¯","woman_with_probing_cane_tone1":"👩đŸģ‍đŸĻ¯","woman_with_probing_cane_tone2":"👩đŸŧ‍đŸĻ¯","woman_with_probing_cane_tone3":"👩đŸŊ‍đŸĻ¯","woman_with_probing_cane_tone4":"👩🏾‍đŸĻ¯","woman_with_probing_cane_tone5":"👩đŸŋ‍đŸĻ¯","woman_with_turban":"đŸ‘ŗâ€â™€ī¸","woman_with_veil":"đŸ‘°â€â™€ī¸","woman_with_veil_tone1":"👰đŸģâ€â™€ī¸","woman_with_veil_tone2":"👰đŸŧâ€â™€ī¸","woman_with_veil_tone3":"👰đŸŊâ€â™€ī¸","woman_with_veil_tone4":"đŸ‘°đŸžâ€â™€ī¸","woman_with_veil_tone5":"👰đŸŋâ€â™€ī¸","woman_with_white_cane":"👩‍đŸĻ¯","woman_with_white_cane_tone1":"👩đŸģ‍đŸĻ¯","woman_with_white_cane_tone2":"👩đŸŧ‍đŸĻ¯","woman_with_white_cane_tone3":"👩đŸŊ‍đŸĻ¯","woman_with_white_cane_tone4":"👩🏾‍đŸĻ¯","woman_with_white_cane_tone5":"👩đŸŋ‍đŸĻ¯","woman_zombie":"đŸ§Ÿâ€â™€ī¸","womans_clothes":"👚","womans_flat_shoe":"đŸĨŋ","womans_hat":"👒","women_with_bunny_ears_partying":"đŸ‘¯â€â™€ī¸","women_wrestling":"đŸ¤ŧâ€â™€ī¸","womens":"đŸšē","wood":"đŸĒĩ","woozy":"đŸĨ´","woozy_face":"đŸĨ´","world_map":"đŸ—ē","worm":"đŸĒą","worried":"😟","worried_face":"😟","wrenc":"🔧","wrench":"🔧","wrestlers":"đŸ¤ŧ","wrestling":"đŸ¤ŧ","writing_hand":"✍","writing_hand_tone1":"✍đŸģ","writing_hand_tone2":"✍đŸŧ","writing_hand_tone3":"✍đŸŊ","writing_hand_tone4":"✍🏾","writing_hand_tone5":"✍đŸŋ","wry_smile_cat":"đŸ˜ŧ","wtf":"🤔","x":"❌","x-ray":"đŸŠģ","xray":"đŸŠģ","yarn":"đŸ§ļ","yawn":"đŸĨą","yawning":"đŸĨą","yawning_face":"đŸĨą","yellow_circle":"🟡","yellow_heart":"💛","yellow_square":"🟨","yemen":"🇾đŸ‡Ē","yen":"💴","yes":"👍","yes_tone1":"👍đŸģ","yes_tone2":"👍đŸŧ","yes_tone3":"👍đŸŊ","yes_tone4":"👍🏾","yes_tone5":"👍đŸŋ","yin_yang":"☯","yo_yo":"đŸĒ€","yum":"😋","za":"âšĄī¸","zambia":"đŸ‡ŋ🇲","zany":"đŸ¤Ē","zany_face":"đŸ¤Ē","zap":"⚡","zebra":"đŸĻ“","zero":"0ī¸âƒŖ","zimbabwe":"đŸ‡ŋđŸ‡ŧ","zipper_mouth":"🤐","zipper_mouth_face":"🤐","zombie":"🧟","zombie_man":"đŸ§Ÿâ€â™‚ī¸","zombie_woman":"đŸ§Ÿâ€â™€ī¸","zzz":"💤"} \ No newline at end of file diff --git a/src/emojis.ts b/src/emojis.ts index 09c2a9f2b1797..9784430d16965 100644 --- a/src/emojis.ts +++ b/src/emojis.ts @@ -1,7 +1,12 @@ -import emojis from './emojis.json'; +import { emojis as compressed } from './emojis.generated'; +import { decompressFromBase64LZString } from './system/string'; -const emojiRegex = /:([-+_a-z0-9]+):/g; +const emojiRegex = /(^|\s):([-+_a-z0-9]+):($|\s)/g; +let emojis: Record | undefined = undefined; export function emojify(message: string) { - return message.replace(emojiRegex, (s, code) => (emojis as Record)[code] || s); + if (emojis == null) { + emojis = JSON.parse(decompressFromBase64LZString(compressed)); + } + return message.replace(emojiRegex, (s, $1, code, $3) => (emojis![code] ? `${$1}${emojis![code]}${$3}` : s)); } diff --git a/src/env/browser/base64.ts b/src/env/browser/base64.ts index 21a7a5a1a9b9f..43897b4e5f460 100644 --- a/src/env/browser/base64.ts +++ b/src/env/browser/base64.ts @@ -4,16 +4,18 @@ const textEncoder = new TextEncoder(); export function base64(s: string): string; export function base64(bytes: Uint8Array): string; export function base64(data: string | Uint8Array): string { - let bytes = typeof data === 'string' ? textEncoder.encode(data) : data; + const bytes = typeof data === 'string' ? textEncoder.encode(data) : data; let output = ''; for (let i = 0, { length } = bytes; i < length; i++) { output += fromCharCode(bytes[i]); } + // eslint-disable-next-line @typescript-eslint/no-deprecated return globalThis.btoa(output); } export function fromBase64(s: string): Uint8Array { + // eslint-disable-next-line @typescript-eslint/no-deprecated const decoded = globalThis.atob(s); const len = decoded.length; diff --git a/src/env/browser/fetch.ts b/src/env/browser/fetch.ts index ed131b4464707..1841ef17aac4d 100644 --- a/src/env/browser/fetch.ts +++ b/src/env/browser/fetch.ts @@ -9,10 +9,17 @@ declare global { } declare type _BodyInit = BodyInit; +declare type _HeadersInit = HeadersInit; declare type _RequestInit = RequestInit; declare type _Response = Response; declare type _RequestInfo = RequestInfo; -export type { _BodyInit as BodyInit, _RequestInit as RequestInit, _Response as Response, _RequestInfo as RequestInfo }; +export type { + _BodyInit as BodyInit, + _HeadersInit as HeadersInit, + _RequestInit as RequestInit, + _Response as Response, + _RequestInfo as RequestInfo, +}; export function getProxyAgent(_strictSSL?: boolean): HttpsProxyAgent | undefined { return undefined; diff --git a/src/env/browser/md5.ts b/src/env/browser/md5.ts index 24342704306dc..f810492a29895 100644 --- a/src/env/browser/md5.ts +++ b/src/env/browser/md5.ts @@ -153,15 +153,15 @@ function md5blk(s: string) { function md51(s: string) { const n = s.length; - let state = [1732584193, -271733879, -1732584194, 271733878]; + const state = [1732584193, -271733879, -1732584194, 271733878]; let i; for (i = 64; i <= n; i += 64) { md5cycle(state, md5blk(s.substring(i - 64, i))); } s = s.substring(i - 64); - let length = s.length; - let tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + const length = s.length; + const tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; for (i = 0; i < length; i += 1) { tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3); } @@ -185,11 +185,11 @@ function md51(s: string) { return state; } -const hex_chr = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; +const hexChr = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; function rhex(n: number) { let s = ''; for (let j = 0; j < 4; j += 1) { - s += hex_chr[(n >> (j * 8 + 4)) & 0x0f] + hex_chr[(n >> (j * 8)) & 0x0f]; + s += hexChr[(n >> (j * 8 + 4)) & 0x0f] + hexChr[(n >> (j * 8)) & 0x0f]; } return s; } @@ -203,13 +203,14 @@ function hexToBinary(hex: string) { const length = hex.length; for (let x = 0; x < length - 1; x += 2) { - bytes.push(parseInt(hex.substr(x, 2), 16)); + bytes.push(parseInt(hex.substring(x, x + 2), 16)); } - return String.fromCharCode.apply(String, bytes); + return String.fromCharCode(...bytes); } export function md5(s: string, encoding: 'base64' | 'hex' = 'hex') { const h = hex(md51(s)); - return encoding === 'hex' ? h : btoa(hexToBinary(h)); + // eslint-disable-next-line @typescript-eslint/no-deprecated + return encoding === 'hex' ? h : globalThis.btoa(hexToBinary(h)); } diff --git a/src/env/browser/pathMapping/repositoryWebPathMappingProvider.ts b/src/env/browser/pathMapping/repositoryWebPathMappingProvider.ts new file mode 100644 index 0000000000000..4de00a0c52f47 --- /dev/null +++ b/src/env/browser/pathMapping/repositoryWebPathMappingProvider.ts @@ -0,0 +1,21 @@ +import type { Disposable } from 'vscode'; +import type { Container } from '../../../container'; +import type { RepositoryPathMappingProvider } from '../../../pathMapping/repositoryPathMappingProvider'; + +export class RepositoryWebPathMappingProvider implements RepositoryPathMappingProvider, Disposable { + constructor(private readonly _container: Container) {} + + dispose() {} + + getLocalRepoPaths(_options: { + remoteUrl?: string; + repoInfo?: { provider?: string; owner?: string; repoName?: string }; + }): Promise { + return Promise.resolve([]); + } + + async writeLocalRepoPath( + _options: { remoteUrl?: string; repoInfo?: { provider?: string; owner?: string; repoName?: string } }, + _localPath: string, + ): Promise {} +} diff --git a/src/env/browser/pathMapping/workspacesWebPathMappingProvider.ts b/src/env/browser/pathMapping/workspacesWebPathMappingProvider.ts new file mode 100644 index 0000000000000..a8985215f618d --- /dev/null +++ b/src/env/browser/pathMapping/workspacesWebPathMappingProvider.ts @@ -0,0 +1,49 @@ +import type { Uri } from 'vscode'; +import type { LocalWorkspaceFileData, WorkspaceAutoAddSetting } from '../../../plus/workspaces/models'; +import type { WorkspacesPathMappingProvider } from '../../../plus/workspaces/workspacesPathMappingProvider'; + +export class WorkspacesWebPathMappingProvider implements WorkspacesPathMappingProvider { + getCloudWorkspaceRepoPath(_cloudWorkspaceId: string, _repoId: string): Promise { + return Promise.resolve(undefined); + } + + getCloudWorkspaceCodeWorkspacePath(_cloudWorkspaceId: string): Promise { + return Promise.resolve(undefined); + } + + async removeCloudWorkspaceCodeWorkspaceFilePath(_cloudWorkspaceId: string): Promise {} + + async writeCloudWorkspaceCodeWorkspaceFilePathToMap( + _cloudWorkspaceId: string, + _codeWorkspaceFilePath: string, + ): Promise {} + + confirmCloudWorkspaceCodeWorkspaceFilePath(_cloudWorkspaceId: string): Promise { + return Promise.resolve(false); + } + + async writeCloudWorkspaceRepoDiskPathToMap( + _cloudWorkspaceId: string, + _repoId: string, + _repoLocalPath: string, + ): Promise {} + + getLocalWorkspaceData(): Promise { + return Promise.resolve({ workspaces: {} }); + } + + writeCodeWorkspaceFile( + _uri: Uri, + _workspaceRepoFilePaths: string[], + _options?: { workspaceId?: string; workspaceAutoAddSetting?: WorkspaceAutoAddSetting }, + ): Promise { + return Promise.resolve(false); + } + + updateCodeWorkspaceFileSettings( + _uri: Uri, + _options: { workspaceAutoAddSetting?: WorkspaceAutoAddSetting }, + ): Promise { + return Promise.resolve(false); + } +} diff --git a/src/env/browser/platform.ts b/src/env/browser/platform.ts index 26eebd814808d..59f3aabc6a862 100644 --- a/src/env/browser/platform.ts +++ b/src/env/browser/platform.ts @@ -3,19 +3,17 @@ export const isWeb = true; const _platform = (navigator as any)?.userAgentData?.platform; const _userAgent = navigator.userAgent; -export const isLinux = _platform === 'Linux' || _userAgent.indexOf('Linux') >= 0; -export const isMac = _platform === 'macOS' || _userAgent.indexOf('Macintosh') >= 0; -export const isWindows = _platform === 'Windows' || _userAgent.indexOf('Windows') >= 0; +export const isLinux = _platform === 'Linux' || _userAgent.includes('Linux'); +export const isMac = _platform === 'macOS' || _userAgent.includes('Macintosh'); +export const isWindows = _platform === 'Windows' || _userAgent.includes('Windows'); export function getPlatform(): string { - if (isWindows) { - return 'web-windows'; - } - if (isMac) { - return 'web-macOS'; - } - if (isLinux) { - return 'web-linux'; - } + if (isWindows) return 'web-windows'; + if (isMac) return 'web-macOS'; + if (isLinux) return 'web-linux'; return 'web'; } + +export function getTempFile(filename: string): string { + return filename; +} diff --git a/src/env/browser/providers.ts b/src/env/browser/providers.ts index e85bc0aa75253..eea7517636b3f 100644 --- a/src/env/browser/providers.ts +++ b/src/env/browser/providers.ts @@ -1,13 +1,38 @@ -import { Container } from '../../container'; -import { GitCommandOptions } from '../../git/commandOptions'; +import type { Container } from '../../container'; +import type { GitCommandOptions } from '../../git/commandOptions'; // Force import of GitHub since dynamic imports are not supported in the WebWorker ExtensionHost -import { GitHubGitProvider } from '../../plus/github/githubGitProvider'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports import { GitProvider } from '../../git/gitProvider'; +import type { IntegrationAuthenticationService } from '../../plus/integrations/authentication/integrationAuthentication'; +import { GitHubGitProvider } from '../../plus/integrations/providers/github/githubGitProvider'; +import { RepositoryWebPathMappingProvider } from './pathMapping/repositoryWebPathMappingProvider'; +import { WorkspacesWebPathMappingProvider } from './pathMapping/workspacesWebPathMappingProvider'; export function git(_options: GitCommandOptions, ..._args: any[]): Promise { return Promise.resolve(''); } -export async function getSupportedGitProviders(container: Container): Promise { - return [new GitHubGitProvider(container)]; +export function gitLogStreamTo( + _repoPath: string, + _sha: string, + _limit: number, + _options?: { configs?: readonly string[]; stdin?: string }, + ..._args: string[] +): Promise<[data: string[], count: number]> { + return Promise.resolve([[''], 0]); +} + +export function getSupportedGitProviders( + container: Container, + authenticationService: IntegrationAuthenticationService, +): Promise { + return Promise.resolve([new GitHubGitProvider(container, authenticationService)]); +} + +export function getSupportedRepositoryPathMappingProvider(container: Container) { + return new RepositoryWebPathMappingProvider(container); +} + +export function getSupportedWorkspacesPathMappingProvider() { + return new WorkspacesWebPathMappingProvider(); } diff --git a/src/env/node/base64.ts b/src/env/node/base64.ts index 77ec85b79a56f..5c7dd626d134a 100644 --- a/src/env/node/base64.ts +++ b/src/env/node/base64.ts @@ -5,5 +5,5 @@ export function base64(data: string | Uint8Array): string { } export function fromBase64(s: string): Uint8Array { - return Buffer.from(s, 'base64'); + return Buffer.from(s, 'base64') as unknown as Uint8Array; } diff --git a/src/env/node/fetch.ts b/src/env/node/fetch.ts index bbaa4a68fefa2..0dc29826505b7 100644 --- a/src/env/node/fetch.ts +++ b/src/env/node/fetch.ts @@ -2,11 +2,11 @@ import * as process from 'process'; import * as url from 'url'; import { HttpsProxyAgent } from 'https-proxy-agent'; import fetch from 'node-fetch'; -import { configuration } from '../../configuration'; -import { Logger } from '../../logger'; +import { Logger } from '../../system/logger'; +import { configuration } from '../../system/vscode/configuration'; export { fetch }; -export type { BodyInit, RequestInfo, RequestInit, Response } from 'node-fetch'; +export type { BodyInit, HeadersInit, RequestInfo, RequestInit, Response } from 'node-fetch'; export function getProxyAgent(strictSSL?: boolean): HttpsProxyAgent | undefined { let proxyUrl: string | undefined; @@ -16,17 +16,13 @@ export function getProxyAgent(strictSSL?: boolean): HttpsProxyAgent | undefined proxyUrl = proxy.url ?? undefined; strictSSL = strictSSL ?? proxy.strictSSL; } else { - const proxySupport = configuration.getAny<'off' | 'on' | 'override' | 'fallback'>( - 'http.proxySupport', - undefined, - 'override', - ); + const proxySupport = configuration.getCore('http.proxySupport', undefined, 'override'); if (proxySupport === 'off') { strictSSL = strictSSL ?? true; } else { - strictSSL = strictSSL ?? configuration.getAny('http.proxyStrictSSL', undefined, true); - proxyUrl = configuration.getAny('http.proxy') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; + strictSSL = strictSSL ?? configuration.getCore('http.proxyStrictSSL', undefined, true); + proxyUrl = configuration.getCore('http.proxy') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; } } diff --git a/src/env/node/git/commitMessageProvider.ts b/src/env/node/git/commitMessageProvider.ts new file mode 100644 index 0000000000000..00a2fae04b42a --- /dev/null +++ b/src/env/node/git/commitMessageProvider.ts @@ -0,0 +1,85 @@ +import type { CancellationToken, ConfigurationChangeEvent, Disposable } from 'vscode'; +import { ProgressLocation, ThemeIcon, window } from 'vscode'; +import type { + CommitMessageProvider, + API as ScmGitApi, + Repository as ScmGitRepository, +} from '../../../@types/vscode.git'; +import type { Container } from '../../../container'; +import { log } from '../../../system/decorators/log'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; +import { configuration } from '../../../system/vscode/configuration'; + +class AICommitMessageProvider implements CommitMessageProvider, Disposable { + icon: ThemeIcon = new ThemeIcon('sparkle'); + title: string = 'Generate Commit Message (Experimental)'; + + private readonly _disposable: Disposable; + private _subscription: Disposable | undefined; + + constructor( + private readonly container: Container, + private readonly scmGit: ScmGitApi, + ) { + this._disposable = configuration.onDidChange(this.onConfigurationChanged, this); + + this.onConfigurationChanged(); + } + + private onConfigurationChanged(e?: ConfigurationChangeEvent) { + if (e == null || configuration.changed(e, 'ai.experimental.generateCommitMessage.enabled')) { + if (configuration.get('ai.experimental.generateCommitMessage.enabled')) { + this._subscription = this.scmGit.registerCommitMessageProvider(this); + } else { + this._subscription?.dispose(); + this._subscription = undefined; + } + } + } + + dispose() { + this._subscription?.dispose(); + this._disposable.dispose(); + } + + @log({ args: false }) + async provideCommitMessage(repository: ScmGitRepository, changes: string[], cancellation: CancellationToken) { + const scope = getLogScope(); + + const currentMessage = repository.inputBox.value; + try { + const message = await ( + await this.container.ai + )?.generateCommitMessage( + changes, + { source: 'scm-input' }, + { + cancellation: cancellation, + context: currentMessage, + progress: { + location: ProgressLocation.Notification, + title: 'Generating commit message...', + }, + }, + ); + + return currentMessage ? `${currentMessage}\n\n${message}` : message; + } catch (ex) { + Logger.error(ex, scope); + + if (ex instanceof Error && ex.message.startsWith('No changes')) { + void window.showInformationMessage('No changes to generate a commit message from.'); + return; + } + + return undefined; + } + } +} + +export function registerCommitMessageProvider(container: Container, scmGit: ScmGitApi): Disposable | undefined { + return typeof scmGit.registerCommitMessageProvider === 'function' + ? new AICommitMessageProvider(container, scmGit) + : undefined; +} diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts index 866111a1b93a2..3bbba673336bc 100644 --- a/src/env/node/git/git.ts +++ b/src/env/node/git/git.ts @@ -1,31 +1,57 @@ import type { ChildProcess, SpawnOptions } from 'child_process'; import { spawn } from 'child_process'; +import { accessSync } from 'fs'; +import { join as joinPath } from 'path'; import * as process from 'process'; -import type { CancellationToken, OutputChannel } from 'vscode'; -import { Uri, window, workspace } from 'vscode'; import { hrtime } from '@env/hrtime'; -import { GlyphChars, LogLevel, slowCallWarningThreshold } from '../../../constants'; +import type { CancellationToken, OutputChannel } from 'vscode'; +import { env, Uri, window, workspace } from 'vscode'; +import { GlyphChars } from '../../../constants'; import type { GitCommandOptions, GitSpawnOptions } from '../../../git/commandOptions'; import { GitErrorHandling } from '../../../git/commandOptions'; +import { + BlameIgnoreRevsFileBadRevisionError, + BlameIgnoreRevsFileError, + CherryPickError, + CherryPickErrorReason, + FetchError, + FetchErrorReason, + PullError, + PullErrorReason, + PushError, + PushErrorReason, + StashPushError, + StashPushErrorReason, + WorkspaceUntrustedError, +} from '../../../git/errors'; +import type { GitDir } from '../../../git/gitProvider'; import type { GitDiffFilter } from '../../../git/models/diff'; -import { GitRevision } from '../../../git/models/reference'; +import type { GitRevisionRange } from '../../../git/models/reference'; +import { isUncommitted, isUncommittedStaged, shortenRevision } from '../../../git/models/reference'; import type { GitUser } from '../../../git/models/user'; -import { GitBranchParser } from '../../../git/parsers/branchParser'; -import { GitLogParser } from '../../../git/parsers/logParser'; -import { GitReflogParser } from '../../../git/parsers/reflogParser'; -import { GitTagParser } from '../../../git/parsers/tagParser'; -import { Logger } from '../../../logger'; +import { parseGitBranchesDefaultFormat } from '../../../git/parsers/branchParser'; +import { parseGitLogAllFormat, parseGitLogDefaultFormat } from '../../../git/parsers/logParser'; +import { parseGitRemoteUrl } from '../../../git/parsers/remoteParser'; +import { parseGitTagsDefaultFormat } from '../../../git/parsers/tagParser'; +import { splitAt } from '../../../system/array'; +import { log } from '../../../system/decorators/log'; import { join } from '../../../system/iterable'; -import { dirname, isAbsolute, isFolderGlob, joinPaths, normalizePath, splitPath } from '../../../system/path'; +import { Logger } from '../../../system/logger'; +import { slowCallWarningThreshold } from '../../../system/logger.constants'; +import { getLoggableScopeBlockOverride, getLogScope } from '../../../system/logger.scope'; +import { dirname, isAbsolute, isFolderGlob, joinPaths, normalizePath } from '../../../system/path'; import { getDurationMilliseconds } from '../../../system/string'; import { compare, fromString } from '../../../system/version'; +import { configuration } from '../../../system/vscode/configuration'; +import { splitPath } from '../../../system/vscode/path'; +import { getEditorCommand } from '../../../system/vscode/utils'; +import { ensureGitTerminal } from '../../../terminal'; import type { GitLocation } from './locator'; import type { RunOptions } from './shell'; -import { fsExists, run, RunError } from './shell'; +import { fsExists, isWindows, run, RunError } from './shell'; const emptyArray = Object.freeze([]) as unknown as any[]; const emptyObj = Object.freeze({}); -const emptyStr = ''; const gitBranchDefaultConfigs = Object.freeze(['-c', 'color.branch=false']); const gitDiffDefaultConfigs = Object.freeze(['-c', 'color.diff=false']); @@ -47,14 +73,32 @@ const rootSha = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; export const GitErrors = { badRevision: /bad revision '(.*?)'/i, + cantLockRef: /cannot lock ref|unable to update local ref/i, + changesWouldBeOverwritten: /Your local changes to the following files would be overwritten/i, + commitChangesFirst: /Please, commit your changes before you can/i, + conflict: /^CONFLICT \([^)]+\): \b/m, + invalidObjectName: /invalid object name: (.*)\s/i, + invalidObjectNameList: /could not open object name list: (.*)\s/i, noFastForward: /\(non-fast-forward\)/i, noMergeBase: /no merge base/i, + noRemoteRepositorySpecified: /No remote repository specified\./i, notAValidObjectName: /Not a valid object name/i, + notAWorkingTree: /'(.*?)' is not a working tree/i, + noUserNameConfigured: /Please tell me who you are\./i, invalidLineCount: /file .+? has only \d+ lines/i, uncommittedChanges: /contains modified or untracked files/i, alreadyExists: /already exists/i, alreadyCheckedOut: /already checked out/i, mainWorkingTree: /is a main working tree/i, + noUpstream: /^fatal: The current branch .* has no upstream branch/i, + permissionDenied: /Permission.*denied/i, + pushRejected: /^error: failed to push some refs to\b/m, + rebaseMultipleBranches: /cannot rebase onto multiple branches/i, + remoteAhead: /rejected because the remote contains work/i, + remoteConnection: /Could not read from remote repository/i, + tagConflict: /! \[rejected\].*\(would clobber existing tag\)/m, + unmergedFiles: /is not possible because you have unmerged files/i, + unstagedChanges: /You have unstaged changes/i, }; const GitWarnings = { @@ -73,6 +117,7 @@ const GitWarnings = { noRemoteRepositorySpecified: /No remote repository specified\./i, remoteConnectionError: /Could not read from remote repository/i, notAGitCommand: /'.+' is not a git command/i, + tipBehind: /tip of your current branch is behind/i, }; function defaultExceptionHandler(ex: Error, cwd: string | undefined, start?: [number, number]): string { @@ -80,12 +125,12 @@ function defaultExceptionHandler(ex: Error, cwd: string | undefined, start?: [nu if (msg != null && msg.length !== 0) { for (const warning of Object.values(GitWarnings)) { if (warning.test(msg)) { - const duration = start !== undefined ? `${getDurationMilliseconds(start)} ms` : ''; + const duration = start !== undefined ? ` [${getDurationMilliseconds(start)}ms]` : ''; Logger.warn( `[${cwd}] Git ${msg .trim() .replace(/fatal: /g, '') - .replace(/\r?\n|\r/g, ` ${GlyphChars.Dot} `)} ${GlyphChars.Dot} ${duration}`, + .replace(/\r?\n|\r/g, ` ${GlyphChars.Dot} `)}${duration}`, ); return ''; } @@ -103,7 +148,16 @@ function defaultExceptionHandler(ex: Error, cwd: string | undefined, start?: [nu throw ex; } +let _uniqueCounterForStdin = 0; +function getStdinUniqueKey(): number { + if (_uniqueCounterForStdin === Number.MAX_SAFE_INTEGER) { + _uniqueCounterForStdin = 0; + } + return _uniqueCounterForStdin++; +} + type ExitCodeOnlyGitCommandOptions = GitCommandOptions & { exitCodeOnly: true }; +export type PushForceOptions = { withLease: true; ifIncludes?: boolean } | { withLease: false; ifIncludes?: never }; export class Git { /** Map of running git commands -- avoids running duplicate overlaping commands */ @@ -112,6 +166,8 @@ export class Git { async git(options: ExitCodeOnlyGitCommandOptions, ...args: any[]): Promise; async git(options: GitCommandOptions, ...args: any[]): Promise; async git(options: GitCommandOptions, ...args: any[]): Promise { + if (!workspace.isTrusted) throw new WorkspaceUntrustedError(); + const start = hrtime(); const { configs, correlationKey, errors: errorHandling, encoding, ...opts } = options; @@ -123,6 +179,7 @@ export class Git { // Shouldn't *really* be needed but better safe than sorry env: { ...process.env, + ...this._gitEnv, ...(options.env ?? emptyObj), GCM_INTERACTIVE: 'NEVER', GCM_PRESERVE_CREDS: 'TRUE', @@ -132,7 +189,9 @@ export class Git { const gitCommand = `[${runOpts.cwd}] git ${args.join(' ')}`; - const command = `${correlationKey !== undefined ? `${correlationKey}:` : ''}${gitCommand}`; + const command = `${correlationKey !== undefined ? `${correlationKey}:` : ''}${ + options?.stdin != null ? `${getStdinUniqueKey()}:` : '' + }${gitCommand}`; let waiting; let promise = this.pendingCommands.get(command); @@ -141,9 +200,7 @@ export class Git { // Fixes https://github.com/gitkraken/vscode-gitlens/issues/73 & https://github.com/gitkraken/vscode-gitlens/issues/161 // See https://stackoverflow.com/questions/4144417/how-to-handle-asian-characters-in-file-names-in-git-on-os-x - args.splice( - 0, - 0, + args.unshift( '-c', 'core.quotepath=false', '-c', @@ -152,7 +209,7 @@ export class Git { ); if (process.platform === 'win32') { - args.splice(0, 0, '-c', 'core.longpaths=true'); + args.unshift('-c', 'core.longpaths=true'); } promise = run(await this.path(), args, encoding ?? 'utf8', runOpts); @@ -160,7 +217,7 @@ export class Git { this.pendingCommands.set(command, promise); } else { waiting = true; - Logger.debug(`[GIT ] ${gitCommand} ${GlyphChars.Dot} waiting...`); + Logger.debug(`${getLoggableScopeBlockOverride('GIT')} ${gitCommand} ${GlyphChars.Dot} waiting...`); } let exception: Error | undefined; @@ -185,36 +242,13 @@ export class Git { } } finally { this.pendingCommands.delete(command); - - const duration = getDurationMilliseconds(start); - const slow = duration > slowCallWarningThreshold; - const status = - slow || waiting - ? ` (${slow ? `slow${waiting ? ', waiting' : ''}` : ''}${waiting ? 'waiting' : ''})` - : ''; - - if (exception != null) { - Logger.error( - '', - `[GIT ] ${gitCommand} ${GlyphChars.Dot} ${(exception.message || String(exception) || '') - .trim() - .replace(/fatal: /g, '') - .replace(/\r?\n|\r/g, ` ${GlyphChars.Dot} `)} ${GlyphChars.Dot} ${duration} ms${status}`, - ); - } else if (slow) { - Logger.warn(`[GIT ] ${gitCommand} ${GlyphChars.Dot} ${duration} ms${status}`); - } else { - Logger.log(`[GIT ] ${gitCommand} ${GlyphChars.Dot} ${duration} ms${status}`); - } - this.logGitCommand( - `${gitCommand}${exception != null ? ` ${GlyphChars.Dot} FAILED` : ''}${waiting ? ' (waited)' : ''}`, - duration, - exception, - ); + this.logGitCommand(gitCommand, exception, getDurationMilliseconds(start), waiting); } } async gitSpawn(options: GitSpawnOptions, ...args: any[]): Promise { + if (!workspace.isTrusted) throw new WorkspaceUntrustedError(); + const start = hrtime(); const { cancellation, configs, stdin, stdinEncoding, ...opts } = options; @@ -227,6 +261,7 @@ export class Git { // Shouldn't *really* be needed but better safe than sorry env: { ...process.env, + ...this._gitEnv, ...(options.env ?? emptyObj), GCM_INTERACTIVE: 'NEVER', GCM_PRESERVE_CREDS: 'TRUE', @@ -234,13 +269,11 @@ export class Git { }, }; - const gitCommand = `[${spawnOpts.cwd as string}] git ${args.join(' ')}`; + const gitCommand = `(spawn) [${spawnOpts.cwd as string}] git ${args.join(' ')}`; // Fixes https://github.com/gitkraken/vscode-gitlens/issues/73 & https://github.com/gitkraken/vscode-gitlens/issues/161 // See https://stackoverflow.com/questions/4144417/how-to-handle-asian-characters-in-file-names-in-git-on-os-x - args.splice( - 0, - 0, + args.unshift( '-c', 'core.quotepath=false', '-c', @@ -249,13 +282,13 @@ export class Git { ); if (process.platform === 'win32') { - args.splice(0, 0, '-c', 'core.longpaths=true'); + args.unshift('-c', 'core.longpaths=true'); } if (cancellation) { - const controller = new AbortController(); - spawnOpts.signal = controller.signal; - cancellation.onCancellationRequested(() => controller.abort()); + const aborter = new AbortController(); + spawnOpts.signal = aborter.signal; + cancellation.onCancellationRequested(() => aborter.abort()); } const proc = spawn(await this.path(), args, spawnOpts); @@ -265,44 +298,40 @@ export class Git { let exception: Error | undefined; proc.once('error', e => (exception = e)); - proc.once('exit', () => { - const duration = getDurationMilliseconds(start); - const slow = duration > slowCallWarningThreshold; - const status = slow ? ' (slow)' : ''; - - if (exception != null) { - Logger.error( - '', - `[SGIT ] ${gitCommand} ${GlyphChars.Dot} ${(exception.message || String(exception) || '') - .trim() - .replace(/fatal: /g, '') - .replace(/\r?\n|\r/g, ` ${GlyphChars.Dot} `)} ${GlyphChars.Dot} ${duration} ms${status}`, - ); - } else if (slow) { - Logger.warn(`[SGIT ] ${gitCommand} ${GlyphChars.Dot} ${duration} ms${status}`); - } else { - Logger.log(`[SGIT ] ${gitCommand} ${GlyphChars.Dot} ${duration} ms${status}`); - } - this.logGitCommand( - `${gitCommand}${exception != null ? ` ${GlyphChars.Dot} FAILED` : ''}`, - duration, - exception, - ); - }); + proc.once('exit', () => this.logGitCommand(gitCommand, exception, getDurationMilliseconds(start), false)); return proc; } - private gitLocator!: () => Promise; + private _gitLocation: GitLocation | undefined; + private _gitLocationPromise: Promise | undefined; + private async getLocation(): Promise { + if (this._gitLocation == null) { + if (this._gitLocationPromise == null) { + this._gitLocationPromise = this._gitLocator(); + } + this._gitLocation = await this._gitLocationPromise; + } + return this._gitLocation; + } + + private _gitLocator!: () => Promise; setLocator(locator: () => Promise): void { - this.gitLocator = locator; + this._gitLocator = locator; + this._gitLocationPromise = undefined; + this._gitLocation = undefined; + } + + private _gitEnv: Record | undefined; + setEnv(env: Record | undefined): void { + this._gitEnv = env; } async path(): Promise { - return (await this.gitLocator()).path; + return (await this.getLocation()).path; } async version(): Promise { - return (await this.gitLocator()).version; + return (await this.getLocation()).version; } async isAtLeastVersion(minimum: string): Promise { @@ -310,6 +339,12 @@ export class Git { return result !== -1; } + maybeIsAtLeastVersion(minimum: string): boolean | undefined { + return this._gitLocation != null + ? compare(fromString(this._gitLocation.version), fromString(minimum)) !== -1 + : undefined; + } + // Git commands add(repoPath: string | undefined, pathspec: string) { @@ -324,127 +359,183 @@ export class Git { return this.git({ cwd: repoPath, stdin: patch }, ...params); } + async apply2( + repoPath: string, + options?: { + cancellation?: CancellationToken; + configs?: readonly string[]; + errors?: GitErrorHandling; + env?: Record; + stdin?: string; + }, + ...args: string[] + ) { + return this.git( + { + cwd: repoPath, + cancellation: options?.cancellation, + configs: options?.configs ?? gitLogDefaultConfigs, + env: options?.env, + errors: options?.errors, + stdin: options?.stdin, + }, + 'apply', + ...args, + ...(options?.stdin ? ['-'] : emptyArray), + ); + } + private readonly ignoreRevsFileMap = new Map(); async blame( repoPath: string | undefined, fileName: string, - ref?: string, - options: { args?: string[] | null; ignoreWhitespace?: boolean; startLine?: number; endLine?: number } = {}, + options?: ({ ref: string | undefined; contents?: never } | { contents: string; ref?: never }) & { + args?: string[] | null; + correlationKey?: string; + ignoreWhitespace?: boolean; + startLine?: number; + endLine?: number; + }, ) { const [file, root] = splitPath(fileName, repoPath, true); const params = ['blame', '--root', '--incremental']; - if (options.ignoreWhitespace) { + if (options?.ignoreWhitespace) { params.push('-w'); } - if (options.startLine != null && options.endLine != null) { + if (options?.startLine != null && options.endLine != null) { params.push(`-L ${options.startLine},${options.endLine}`); } - if (options.args != null) { + if (options?.args != null) { + // See if the args contains a value like: `--ignore-revs-file ` or `--ignore-revs-file=` to account for user error + // If so split it up into two args + const argIndex = options.args.findIndex( + arg => arg !== '--ignore-revs-file' && arg.startsWith('--ignore-revs-file'), + ); + if (argIndex !== -1) { + const match = /^--ignore-revs-file\s*=?\s*(.*)$/.exec(options.args[argIndex]); + if (match != null) { + options.args.splice(argIndex, 1, '--ignore-revs-file', match[1]); + } + } + params.push(...options.args); + } - const index = params.indexOf('--ignore-revs-file'); - if (index !== -1) { - // Ensure the version of Git supports the --ignore-revs-file flag, otherwise the blame will fail - let supported = await this.isAtLeastVersion('2.23'); - if (supported) { - let ignoreRevsFile = params[index + 1]; - if (!isAbsolute(ignoreRevsFile)) { - ignoreRevsFile = joinPaths(repoPath ?? '', ignoreRevsFile); - } + // Ensure the version of Git supports the --ignore-revs-file flag, otherwise the blame will fail + let supportsIgnoreRevsFile = this.maybeIsAtLeastVersion('2.23'); + if (supportsIgnoreRevsFile === undefined) { + supportsIgnoreRevsFile = await this.isAtLeastVersion('2.23'); + } - const exists = this.ignoreRevsFileMap.get(ignoreRevsFile); - if (exists !== undefined) { - supported = exists; - } else { - // Ensure the specified --ignore-revs-file exists, otherwise the blame will fail - try { - supported = await fsExists(ignoreRevsFile); - } catch { - supported = false; - } + const ignoreRevsIndex = params.indexOf('--ignore-revs-file'); - this.ignoreRevsFileMap.set(ignoreRevsFile, supported); - } + if (supportsIgnoreRevsFile) { + let ignoreRevsFile; + if (ignoreRevsIndex !== -1) { + ignoreRevsFile = params[ignoreRevsIndex + 1]; + if (!isAbsolute(ignoreRevsFile)) { + ignoreRevsFile = joinPaths(root, ignoreRevsFile); } + } else { + ignoreRevsFile = joinPaths(root, '.git-blame-ignore-revs'); + } - if (!supported) { - params.splice(index, 2); + const exists = this.ignoreRevsFileMap.get(ignoreRevsFile); + if (exists !== undefined) { + supportsIgnoreRevsFile = exists; + } else { + // Ensure the specified --ignore-revs-file exists, otherwise the blame will fail + try { + supportsIgnoreRevsFile = await fsExists(ignoreRevsFile); + } catch { + supportsIgnoreRevsFile = false; } + + this.ignoreRevsFileMap.set(ignoreRevsFile, supportsIgnoreRevsFile); } } + if (!supportsIgnoreRevsFile && ignoreRevsIndex !== -1) { + params.splice(ignoreRevsIndex, 2); + } else if (supportsIgnoreRevsFile && ignoreRevsIndex === -1) { + params.push('--ignore-revs-file', '.git-blame-ignore-revs'); + } + let stdin; - if (ref) { - if (GitRevision.isUncommittedStaged(ref)) { + if (options?.contents != null) { + // Pipe the blame contents to stdin + params.push('--contents', '-'); + + stdin = options.contents; + } else if (options?.ref) { + if (isUncommittedStaged(options.ref)) { // Pipe the blame contents to stdin params.push('--contents', '-'); // Get the file contents for the staged version using `:` stdin = await this.show(repoPath, fileName, ':'); } else { - params.push(ref); + params.push(options.ref); } } - return this.git({ cwd: root, stdin: stdin }, ...params, '--', file); - } - - blame__contents( - repoPath: string | undefined, - fileName: string, - contents: string, - options: { - args?: string[] | null; - correlationKey?: string; - ignoreWhitespace?: boolean; - startLine?: number; - endLine?: number; - } = {}, - ) { - const [file, root] = splitPath(fileName, repoPath, true); + try { + const blame = await this.git( + { cwd: root, stdin: stdin, correlationKey: options?.correlationKey }, + ...params, + '--', + file, + ); + return blame; + } catch (ex) { + // Since `-c blame.ignoreRevsFile=` doesn't seem to work (unlike as the docs suggest), try to detect the error and throw a more helpful one + let match = GitErrors.invalidObjectNameList.exec(ex.message); + if (match != null) { + throw new BlameIgnoreRevsFileError(match[1], ex); + } - const params = ['blame', '--root', '--incremental']; + match = GitErrors.invalidObjectName.exec(ex.message); + if (match != null) { + throw new BlameIgnoreRevsFileBadRevisionError(match[1], ex); + } - if (options.ignoreWhitespace) { - params.push('-w'); - } - if (options.startLine != null && options.endLine != null) { - params.push(`-L ${options.startLine},${options.endLine}`); - } - if (options.args != null) { - params.push(...options.args); + throw ex; } + } - // Pipe the blame contents to stdin - params.push('--contents', '-'); - - return this.git( - { cwd: root, stdin: contents, correlationKey: options.correlationKey }, - ...params, - '--', - file, - ); + branch__set_upstream(repoPath: string, branch: string, remote: string, remoteBranch: string) { + return this.git({ cwd: repoPath }, 'branch', '--set-upstream-to', `${remote}/${remoteBranch}`, branch); } - branch__containsOrPointsAt( + branchOrTag__containsOrPointsAt( repoPath: string, - ref: string, - { - mode = 'contains', - name = undefined, - remotes = false, - }: { mode?: 'contains' | 'pointsAt'; name?: string; remotes?: boolean } = {}, + refs: string[], + options?: { + type?: 'branch' | 'tag'; + all?: boolean; + mode?: 'contains' | 'pointsAt'; + name?: string; + remotes?: boolean; + }, ) { - const params = ['branch']; - if (remotes) { + const params: string[] = [options?.type ?? 'branch']; + if (options?.all) { + params.push('-a'); + } else if (options?.remotes) { params.push('-r'); } - params.push(mode === 'pointsAt' ? `--points-at=${ref}` : `--contains=${ref}`, '--format=%(refname:short)'); - if (name != null) { - params.push(name); + + params.push('--format=%(refname:short)'); + + for (const ref of refs) { + params.push(options?.mode === 'pointsAt' ? `--points-at=${ref}` : `--contains=${ref}`); + } + + if (options?.name != null) { + params.push(options.name); } return this.git( @@ -453,6 +544,11 @@ export class Git { ); } + async cat_file__size(repoPath: string, oid: string): Promise { + const data = await this.git({ cwd: repoPath }, 'cat-file', '-s', oid); + return data.length ? parseInt(data.trim(), 10) : 0; + } + check_ignore(repoPath: string, ...files: string[]) { return this.git( { cwd: repoPath, errors: GitErrorHandling.Ignore, stdin: files.join('\0') }, @@ -503,6 +599,49 @@ export class Git { return this.git({ cwd: repoPath }, ...params); } + async cherrypick(repoPath: string, sha: string, options: { noCommit?: boolean; errors?: GitErrorHandling } = {}) { + const params = ['cherry-pick']; + if (options?.noCommit) { + params.push('-n'); + } + params.push(sha); + + try { + await this.git({ cwd: repoPath, errors: options?.errors }, ...params); + } catch (ex) { + const msg: string = ex?.toString() ?? ''; + let reason: CherryPickErrorReason = CherryPickErrorReason.Other; + if ( + GitErrors.changesWouldBeOverwritten.test(msg) || + GitErrors.changesWouldBeOverwritten.test(ex.stderr ?? '') + ) { + reason = CherryPickErrorReason.AbortedWouldOverwrite; + } else if (GitErrors.conflict.test(msg) || GitErrors.conflict.test(ex.stdout ?? '')) { + reason = CherryPickErrorReason.Conflicts; + } + + throw new CherryPickError(reason, ex, sha); + } + } + + // TODO: Expand to include options and other params + async clone(url: string, parentPath: string): Promise { + let count = 0; + const [, , remotePath] = parseGitRemoteUrl(url); + const remoteName = remotePath.split('/').pop(); + if (!remoteName) return undefined; + + let folderPath = joinPath(parentPath, remoteName); + while ((await fsExists(folderPath)) && count < 20) { + count++; + folderPath = joinPath(parentPath, `${remotePath}-${count}`); + } + + await this.git({ cwd: parentPath }, 'clone', url, folderPath); + + return folderPath; + } + async config__get(key: string, repoPath?: string, options?: { local?: boolean }) { const data = await this.git( { cwd: repoPath ?? '', errors: GitErrorHandling.Ignore, local: options?.local }, @@ -565,10 +704,10 @@ export class Git { if (ref1.endsWith('^3^')) { ref1 = rootSha; } - params.push(GitRevision.isUncommittedStaged(ref1) ? '--staged' : ref1); + params.push(isUncommittedStaged(ref1) ? '--staged' : ref1); } if (ref2) { - params.push(GitRevision.isUncommittedStaged(ref2) ? '--staged' : ref2); + params.push(isUncommittedStaged(ref2) ? '--staged' : ref2); } try { @@ -588,7 +727,7 @@ export class Git { const [, ref] = match; // If the bad ref is trying to find a parent ref, assume we hit to the last commit, so try again using the root sha - if (ref === ref1 && ref != null && ref.endsWith('^')) { + if (ref === ref1 && ref?.endsWith('^')) { return this.diff(repoPath, fileName, rootSha, ref2, options); } } @@ -597,6 +736,31 @@ export class Git { } } + async diff2( + repoPath: string, + options?: { + cancellation?: CancellationToken; + configs?: readonly string[]; + errors?: GitErrorHandling; + stdin?: string; + }, + ...args: string[] + ) { + return this.git( + { + cwd: repoPath, + cancellation: options?.cancellation, + configs: options?.configs ?? gitLogDefaultConfigs, + errors: options?.errors, + stdin: options?.stdin, + }, + 'diff', + ...(options?.stdin ? ['--stdin'] : emptyArray), + ...args, + ...(!args.includes('--') ? ['--'] : emptyArray), + ); + } + async diff__contents( repoPath: string, fileName: string, @@ -620,7 +784,7 @@ export class Git { // if (ref.endsWith('^3^')) { // ref = rootSha; // } - // params.push(GitRevision.isUncommittedStaged(ref) ? '--staged' : ref); + // params.push(isUncommittedStaged(ref) ? '--staged' : ref); params.push('--no-index'); @@ -648,7 +812,7 @@ export class Git { const [, matchedRef] = match; // If the bad ref is trying to find a parent ref, assume we hit to the last commit, so try again using the root sha - if (matchedRef === ref && matchedRef != null && matchedRef.endsWith('^')) { + if (matchedRef === ref && matchedRef?.endsWith('^')) { return this.diff__contents(repoPath, fileName, rootSha, contents, options); } } @@ -661,17 +825,17 @@ export class Git { repoPath: string, ref1?: string, ref2?: string, - { filters, similarityThreshold }: { filters?: GitDiffFilter[]; similarityThreshold?: number | null } = {}, + options?: { filters?: GitDiffFilter[]; path?: string; similarityThreshold?: number }, ) { const params = [ 'diff', '--name-status', - `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`, + `-M${options?.similarityThreshold == null ? '' : `${options?.similarityThreshold}%`}`, '--no-ext-diff', '-z', ]; - if (filters != null && filters.length !== 0) { - params.push(`--diff-filter=${filters.join('')}`); + if (options?.filters?.length) { + params.push(`--diff-filter=${options.filters.join('')}`); } if (ref1) { params.push(ref1); @@ -680,7 +844,12 @@ export class Git { params.push(ref2); } - return this.git({ cwd: repoPath, configs: gitDiffDefaultConfigs }, ...params, '--'); + params.push('--'); + if (options?.path) { + params.push(options.path); + } + + return this.git({ cwd: repoPath, configs: gitDiffDefaultConfigs }, ...params); } async diff__shortstat(repoPath: string, ref?: string) { @@ -733,7 +902,7 @@ export class Git { async fetch( repoPath: string, options: - | { all?: boolean; branch?: undefined; prune?: boolean; remote?: string } + | { all?: boolean; branch?: undefined; prune?: boolean; pull?: boolean; remote?: string } | { all?: undefined; branch: string; @@ -752,29 +921,8 @@ export class Git { if (options.branch && options.remote) { if (options.upstream && options.pull) { params.push('-u', options.remote, `${options.upstream}:${options.branch}`); - - try { - void (await this.git({ cwd: repoPath }, ...params)); - return; - } catch (ex) { - const msg: string = ex?.toString() ?? ''; - if (GitErrors.noFastForward.test(msg)) { - void window.showErrorMessage( - `Unable to pull the '${options.branch}' branch, as it can't be fast-forwarded.`, - ); - - return; - } - - throw ex; - } } else { - params.push( - options.remote, - options.upstream - ? `${options.upstream}:refs/remotes/${options.remote}/${options.branch}` - : options.branch, - ); + params.push(options.remote, options.upstream || options.branch); } } else if (options.remote) { params.push(options.remote); @@ -782,98 +930,160 @@ export class Git { params.push('--all'); } - void (await this.git({ cwd: repoPath }, ...params)); - } + try { + void (await this.git({ cwd: repoPath }, ...params)); + } catch (ex) { + const msg: string = ex?.toString() ?? ''; + let reason: FetchErrorReason = FetchErrorReason.Other; + if (GitErrors.noFastForward.test(msg) || GitErrors.noFastForward.test(ex.stderr ?? '')) { + reason = FetchErrorReason.NoFastForward; + } else if ( + GitErrors.noRemoteRepositorySpecified.test(msg) || + GitErrors.noRemoteRepositorySpecified.test(ex.stderr ?? '') + ) { + reason = FetchErrorReason.NoRemote; + } else if (GitErrors.remoteConnection.test(msg) || GitErrors.remoteConnection.test(ex.stderr ?? '')) { + reason = FetchErrorReason.RemoteConnection; + } - for_each_ref__branch(repoPath: string, options: { all: boolean } = { all: false }) { - const params = ['for-each-ref', `--format=${GitBranchParser.defaultFormat}`, 'refs/heads']; - if (options.all) { - params.push('refs/remotes'); + throw new FetchError(reason, ex, options?.branch, options?.remote); } - - return this.git({ cwd: repoPath }, ...params); } - log( + async push( repoPath: string, - ref: string | undefined, - { - all, - argsOrFormat, - authors, - limit, - merges, - ordering, - similarityThreshold, - since, - until, - }: { - all?: boolean; - argsOrFormat?: string | string[]; - authors?: GitUser[]; - limit?: number; - merges?: boolean; - ordering?: 'date' | 'author-date' | 'topo' | null; - similarityThreshold?: number | null; - since?: number | string; - until?: number | string; + options: { + branch?: string; + force?: PushForceOptions; + publish?: boolean; + remote?: string; + upstream?: string; }, - ) { - if (argsOrFormat == null) { - argsOrFormat = ['--name-status', `--format=${all ? GitLogParser.allFormat : GitLogParser.defaultFormat}`]; + ): Promise { + const params = ['push']; + + if (options.force != null) { + if (options.force.withLease) { + params.push('--force-with-lease'); + if (options.force.ifIncludes) { + if (await this.isAtLeastVersion('2.30.0')) { + params.push('--force-if-includes'); + } + } + } else { + params.push('--force'); + } } - if (typeof argsOrFormat === 'string') { - argsOrFormat = [`--format=${argsOrFormat}`]; + if (options.branch && options.remote) { + if (options.upstream) { + params.push('-u', options.remote, `${options.branch}:${options.upstream}`); + } else if (options.publish) { + params.push('--set-upstream', options.remote, options.branch); + } else { + params.push(options.remote, options.branch); + } + } else if (options.remote) { + params.push(options.remote); } - const params = [ - 'log', - ...argsOrFormat, - '--full-history', - `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`, - '-m', - ]; - - if (ordering) { - params.push(`--${ordering}-order`); - } + try { + void (await this.git({ cwd: repoPath }, ...params)); + } catch (ex) { + const msg: string = ex?.toString() ?? ''; + let reason: PushErrorReason = PushErrorReason.Other; + if (GitErrors.remoteAhead.test(msg) || GitErrors.remoteAhead.test(ex.stderr ?? '')) { + reason = PushErrorReason.RemoteAhead; + } else if (GitWarnings.tipBehind.test(msg) || GitWarnings.tipBehind.test(ex.stderr ?? '')) { + reason = PushErrorReason.TipBehind; + } else if (GitErrors.pushRejected.test(msg) || GitErrors.pushRejected.test(ex.stderr ?? '')) { + if (options?.force?.withLease) { + if (/! \[rejected\].*\(stale info\)/m.test(ex.stderr || '')) { + reason = PushErrorReason.PushRejectedWithLease; + } else if ( + options.force.ifIncludes && + /! \[rejected\].*\(remote ref updated since checkout\)/m.test(ex.stderr || '') + ) { + reason = PushErrorReason.PushRejectedWithLeaseIfIncludes; + } else { + reason = PushErrorReason.PushRejected; + } + } else { + reason = PushErrorReason.PushRejected; + } + } else if (GitErrors.permissionDenied.test(msg) || GitErrors.permissionDenied.test(ex.stderr ?? '')) { + reason = PushErrorReason.PermissionDenied; + } else if (GitErrors.remoteConnection.test(msg) || GitErrors.remoteConnection.test(ex.stderr ?? '')) { + reason = PushErrorReason.RemoteConnection; + } else if (GitErrors.noUpstream.test(msg) || GitErrors.noUpstream.test(ex.stderr ?? '')) { + reason = PushErrorReason.NoUpstream; + } - if (limit) { - params.push(`-n${limit + 1}`); + throw new PushError(reason, ex, options?.branch, options?.remote); } + } - if (since) { - params.push(`--since="${since}"`); - } + async pull(repoPath: string, options: { rebase?: boolean; tags?: boolean }): Promise { + const params = ['pull']; - if (until) { - params.push(`--until="${until}"`); + if (options.tags) { + params.push('--tags'); } - if (!merges) { - params.push('--first-parent'); + if (options.rebase) { + params.push('-r'); } - if (authors != null && authors.length !== 0) { - if (!params.includes('--use-mailmap')) { - params.push('--use-mailmap'); + try { + void (await this.git({ cwd: repoPath }, ...params)); + } catch (ex) { + const msg: string = ex?.toString() ?? ''; + let reason: PullErrorReason = PullErrorReason.Other; + if (GitErrors.conflict.test(msg) || GitErrors.conflict.test(ex.stdout ?? '')) { + reason = PullErrorReason.Conflict; + } else if ( + GitErrors.noUserNameConfigured.test(msg) || + GitErrors.noUserNameConfigured.test(ex.stderr ?? '') + ) { + reason = PullErrorReason.GitIdentity; + } else if (GitErrors.remoteConnection.test(msg) || GitErrors.remoteConnection.test(ex.stderr ?? '')) { + reason = PullErrorReason.RemoteConnection; + } else if (GitErrors.unstagedChanges.test(msg) || GitErrors.unstagedChanges.test(ex.stderr ?? '')) { + reason = PullErrorReason.UnstagedChanges; + } else if (GitErrors.unmergedFiles.test(msg) || GitErrors.unmergedFiles.test(ex.stderr ?? '')) { + reason = PullErrorReason.UnmergedFiles; + } else if (GitErrors.commitChangesFirst.test(msg) || GitErrors.commitChangesFirst.test(ex.stderr ?? '')) { + reason = PullErrorReason.UncommittedChanges; + } else if ( + GitErrors.changesWouldBeOverwritten.test(msg) || + GitErrors.changesWouldBeOverwritten.test(ex.stderr ?? '') + ) { + reason = PullErrorReason.OverwrittenChanges; + } else if (GitErrors.cantLockRef.test(msg) || GitErrors.cantLockRef.test(ex.stderr ?? '')) { + reason = PullErrorReason.RefLocked; + } else if ( + GitErrors.rebaseMultipleBranches.test(msg) || + GitErrors.rebaseMultipleBranches.test(ex.stderr ?? '') + ) { + reason = PullErrorReason.RebaseMultipleBranches; + } else if (GitErrors.tagConflict.test(msg) || GitErrors.tagConflict.test(ex.stderr ?? '')) { + reason = PullErrorReason.TagConflict; } - params.push(...authors.map(a => `--author=^${a.name} <${a.email}>$`)); - } - if (all) { - params.push('--all', '--single-worktree'); + throw new PullError(reason, ex); } + } - if (ref && !GitRevision.isUncommittedStaged(ref)) { - params.push(ref); + for_each_ref__branch(repoPath: string, options: { all: boolean } = { all: false }) { + const params = ['for-each-ref', `--format=${parseGitBranchesDefaultFormat}`, 'refs/heads']; + if (options.all) { + params.push('refs/remotes'); } - return this.git({ cwd: repoPath, configs: gitLogDefaultConfigsWithFiles }, ...params, '--'); + return this.git({ cwd: repoPath }, ...params); } - log2( + log( repoPath: string, options?: { cancellation?: CancellationToken; @@ -895,7 +1105,7 @@ export class Git { 'log', ...(options?.stdin ? ['--stdin'] : emptyArray), ...args, - ...(options?.ref && !GitRevision.isUncommittedStaged(options.ref) ? [options.ref] : emptyArray), + ...(options?.ref && !isUncommittedStaged(options.ref) ? [options.ref] : emptyArray), ...(!args.includes('--') ? ['--'] : emptyArray), ); } @@ -988,8 +1198,8 @@ export class Git { // TODO@eamodio remove this in favor of argsOrFormat fileMode = 'full', filters, - firstParent = false, limit, + merges = false, ordering, renames = true, reverse = false, @@ -1003,8 +1213,8 @@ export class Git { // TODO@eamodio remove this in favor of argsOrFormat fileMode?: 'full' | 'simple' | 'none'; filters?: GitDiffFilter[]; - firstParent?: boolean; limit?: number; + merges?: boolean; ordering?: 'date' | 'author-date' | 'topo' | null; renames?: boolean; reverse?: boolean; @@ -1017,7 +1227,7 @@ export class Git { const [file, root] = splitPath(fileName, repoPath, true); if (argsOrFormat == null) { - argsOrFormat = [`--format=${all ? GitLogParser.allFormat : GitLogParser.defaultFormat}`]; + argsOrFormat = [`--format=${all ? parseGitLogAllFormat : parseGitLogDefaultFormat}`]; } if (typeof argsOrFormat === 'string') { @@ -1046,18 +1256,17 @@ export class Git { params.push('--all', '--single-worktree'); } - // Can't allow rename detection (`--follow`) if `all` or a `startLine` is specified - if (renames && (all || startLine != null)) { + if (merges) { + params.push('--first-parent'); + } + + // Can't allow rename detection (`--follow`) if a `startLine` is specified + if (renames && startLine != null) { renames = false; } - params.push(renames ? '--follow' : '-m'); - if (/*renames ||*/ firstParent) { - params.push('--first-parent'); - // In Git >= 2.29.0 `--first-parent` implies `-m`, so lets include it for consistency - if (renames) { - params.push('-m'); - } + if (renames) { + params.push('--follow'); } if (filters != null && filters.length !== 0) { @@ -1078,7 +1287,7 @@ export class Git { } } - if (ref && !GitRevision.isUncommittedStaged(ref)) { + if (ref && !isUncommittedStaged(ref)) { // If we are reversing, we must add a range (with HEAD) because we are using --ancestry-path for better reverse walking if (reverse) { params.push('--reverse', '--ancestry-path', `${ref}..HEAD`); @@ -1136,13 +1345,13 @@ export class Git { async log__find_object( repoPath: string, - objectId: string, + oid: string, ref: string, ordering: 'date' | 'author-date' | 'topo' | null, file?: string, cancellation?: CancellationToken, ) { - const params = ['log', '-n1', '--no-renames', '--format=%H', `--find-object=${objectId}`, ref]; + const params = ['log', '-n1', '--no-renames', '--format=%H', `--find-object=${oid}`, ref]; if (ordering) { params.push(`--${ordering}-order`); @@ -1204,6 +1413,7 @@ export class Git { ordering?: 'date' | 'author-date' | 'topo' | null; skip?: number; shas?: Set; + stdin?: string; }, ) { if (options?.shas != null) { @@ -1213,29 +1423,32 @@ export class Git { 'show', '--stdin', '--name-status', - `--format=${GitLogParser.defaultFormat}`, + `--format=${parseGitLogDefaultFormat}`, '--use-mailmap', ); } + let files; + [search, files] = splitAt(search, search.indexOf('--')); + return this.git( - { cwd: repoPath, configs: ['-C', repoPath, ...gitLogDefaultConfigs] }, + { cwd: repoPath, configs: ['-C', repoPath, ...gitLogDefaultConfigs], stdin: options?.stdin }, 'log', + ...(options?.stdin ? ['--stdin'] : emptyArray), '--name-status', - `--format=${GitLogParser.defaultFormat}`, + `--format=${parseGitLogDefaultFormat}`, '--use-mailmap', - '--full-history', - '-m', + ...search, + ...(options?.ordering ? [`--${options.ordering}-order`] : emptyArray), ...(options?.limit ? [`-n${options.limit + 1}`] : emptyArray), ...(options?.skip ? [`--skip=${options.skip}`] : emptyArray), - ...(options?.ordering ? [`--${options.ordering}-order`] : emptyArray), - ...search, + ...files, ); } // log__shortstat(repoPath: string, options: { ref?: string }) { // const params = ['log', '--shortstat', '--oneline']; - // if (options.ref && !GitRevision.isUncommittedStaged(options.ref)) { + // if (options.ref && !isUncommittedStaged(options.ref)) { // params.push(options.ref); // } // return this.git({ cwd: repoPath, configs: gitLogDefaultConfigs }, ...params, '--'); @@ -1244,14 +1457,18 @@ export class Git { async ls_files( repoPath: string, fileName: string, - { ref, untracked }: { ref?: string; untracked?: boolean } = {}, + options?: { ref?: string; untracked?: boolean }, ): Promise { const params = ['ls-files']; - if (ref && !GitRevision.isUncommitted(ref)) { - params.push(`--with-tree=${ref}`); + if (options?.ref) { + if (!isUncommitted(options.ref)) { + params.push(`--with-tree=${options.ref}`); + } else if (isUncommittedStaged(options.ref)) { + params.push('--stage'); + } } - if (!ref && untracked) { + if (!options?.ref && options?.untracked) { params.push('-o'); } @@ -1300,43 +1517,29 @@ export class Git { reflog( repoPath: string, - { - all, - branch, - limit, - ordering, - skip, - }: { - all?: boolean; - branch?: string; - limit?: number; - ordering?: 'date' | 'author-date' | 'topo' | null; - skip?: number; - } = {}, + options?: { + cancellation?: CancellationToken; + configs?: readonly string[]; + ref?: string; + errors?: GitErrorHandling; + stdin?: string; + }, + ...args: string[] ): Promise { - const params = ['log', '--walk-reflogs', `--format=${GitReflogParser.defaultFormat}`, '--date=iso8601']; - - if (ordering) { - params.push(`--${ordering}-order`); - } - - if (all) { - params.push('--all'); - } - - if (limit) { - params.push(`-n${limit}`); - } - - if (skip) { - params.push(`--skip=${skip}`); - } - - if (branch) { - params.push(branch); - } - - return this.git({ cwd: repoPath, configs: gitLogDefaultConfigs }, ...params, '--'); + return this.git( + { + cwd: repoPath, + cancellation: options?.cancellation, + configs: options?.configs ?? gitLogDefaultConfigs, + errors: options?.errors, + stdin: options?.stdin, + }, + 'reflog', + ...(options?.stdin ? ['--stdin'] : emptyArray), + ...args, + ...(options?.ref && !isUncommittedStaged(options.ref) ? [options.ref] : emptyArray), + ...(!args.includes('--') ? ['--'] : emptyArray), + ); } remote(repoPath: string): Promise { @@ -1409,28 +1612,33 @@ export class Git { async rev_list__left_right( repoPath: string, - refs: string[], - ): Promise<{ ahead: number; behind: number } | undefined> { - const data = await this.git( - { cwd: repoPath, errors: GitErrorHandling.Ignore }, - 'rev-list', - '--left-right', - '--count', - ...refs, - '--', - ); + range: GitRevisionRange, + authors?: GitUser[] | undefined, + excludeMerges?: boolean, + ): Promise<{ left: number; right: number } | undefined> { + const params = ['rev-list', '--left-right', '--count']; + + if (authors?.length) { + params.push(...authors.map(a => `--author=^${a.name} <${a.email}>$`)); + } + + if (excludeMerges) { + params.push('--no-merges'); + } + + const data = await this.git({ cwd: repoPath, errors: GitErrorHandling.Ignore }, ...params, range, '--'); if (data.length === 0) return undefined; const parts = data.split('\t'); if (parts.length !== 2) return undefined; - const [ahead, behind] = parts; + const [left, right] = parts; const result = { - ahead: parseInt(ahead, 10), - behind: parseInt(behind, 10), + left: parseInt(left, 10), + right: parseInt(right, 10), }; - if (isNaN(result.ahead) || isNaN(result.behind)) return undefined; + if (isNaN(result.left) || isNaN(result.right)) return undefined; return result; } @@ -1469,7 +1677,7 @@ export class Git { try { const data = await this.symbolic_ref(repoPath, 'refs/remotes/origin/HEAD'); - if (data != null) return [data.trim().substr('origin/'.length), undefined]; + if (data != null) return [data.trim().substring('origin/'.length), undefined]; } catch (ex) { if (/is not a symbolic ref/.test(ex.stderr)) { try { @@ -1478,7 +1686,7 @@ export class Git { const match = /ref:\s(\S+)\s+HEAD/m.exec(data); if (match != null) { const [, branch] = match; - return [branch.substr('refs/heads/'.length), undefined]; + return [branch.substring('refs/heads/'.length), undefined]; } } } catch {} @@ -1511,7 +1719,7 @@ export class Git { const sha = await this.log__recent(repoPath, ordering); if (sha === undefined) return undefined; - return [`(HEAD detached at ${GitRevision.shorten(sha)})`, sha]; + return [`(HEAD detached at ${shortenRevision(sha)})`, sha]; } defaultExceptionHandler(ex, repoPath); @@ -1550,25 +1758,77 @@ export class Git { return { path: dotGitPath }; } - async rev_parse__show_toplevel(cwd: string): Promise { + async rev_parse__show_toplevel(cwd: string): Promise<[safe: true, repoPath: string] | [safe: false] | []> { + let data; + + if (!workspace.isTrusted) { + // Check if the folder is a bare clone: if it has a file named HEAD && `rev-parse --show-cdup` is empty + try { + accessSync(joinPaths(cwd, 'HEAD')); + data = await this.git( + { cwd: cwd, errors: GitErrorHandling.Throw, configs: ['-C', cwd] }, + 'rev-parse', + '--show-cdup', + ); + if (data.trim() === '') { + Logger.log(`Skipping (untrusted workspace); bare clone repository detected in '${cwd}'`); + return emptyArray as []; + } + } catch { + // If this throw, we should be good to open the repo (e.g. HEAD doesn't exist) + } + } + try { - const data = await this.git( - { cwd: cwd, errors: GitErrorHandling.Throw }, - 'rev-parse', - '--show-toplevel', - ); + data = await this.git({ cwd: cwd, errors: GitErrorHandling.Throw }, 'rev-parse', '--show-toplevel'); // Make sure to normalize: https://github.com/git-for-windows/git/issues/2478 // Keep trailing spaces which are part of the directory name - return data.length === 0 ? undefined : normalizePath(data.trimLeft().replace(/[\r|\n]+$/, '')); + return data.length === 0 + ? (emptyArray as []) + : [true, normalizePath(data.trimStart().replace(/[\r|\n]+$/, ''))]; } catch (ex) { + if (ex instanceof WorkspaceUntrustedError) return emptyArray as []; + + const unsafeMatch = + /^fatal: detected dubious ownership in repository at '([^']+)'[\s\S]*git config --global --add safe\.directory '?([^'\n]+)'?$/m.exec( + ex.stderr, + ); + if (unsafeMatch?.length === 3) { + Logger.log( + `Skipping; unsafe repository detected in '${unsafeMatch[1]}'; run 'git config --global --add safe.directory ${unsafeMatch[2]}' to allow it`, + ); + return [false]; + } + const inDotGit = /this operation must be run in a work tree/.test(ex.stderr); + // Check if we are in a bare clone + if (inDotGit && workspace.isTrusted) { + data = await this.git( + { cwd: cwd, errors: GitErrorHandling.Ignore }, + 'rev-parse', + '--is-bare-repository', + ); + if (data.trim() === 'true') { + // If we are in a bare clone, then the common dir is the git dir + data = await this.git( + { cwd: cwd, errors: GitErrorHandling.Ignore }, + 'rev-parse', + '--git-common-dir', + ); + data = data.trim(); + if (data.length) { + return [true, normalizePath((data === '.' ? cwd : data).trimStart().replace(/[\r|\n]+$/, ''))]; + } + } + } + if (inDotGit || ex.code === 'ENOENT') { // If the `cwd` doesn't exist, walk backward to see if any parent folder exists let exists = inDotGit ? false : await fsExists(cwd); if (!exists) { do { const parent = dirname(cwd); - if (parent === cwd || parent.length === 0) return undefined; + if (parent === cwd || parent.length === 0) return emptyArray as []; cwd = parent; exists = await fsExists(cwd); @@ -1577,7 +1837,7 @@ export class Git { return this.rev_parse__show_toplevel(cwd); } } - return undefined; + return emptyArray as []; } } @@ -1606,10 +1866,10 @@ export class Git { ): Promise { const [file, root] = splitPath(fileName, repoPath, true); - if (GitRevision.isUncommittedStaged(ref)) { + if (isUncommittedStaged(ref)) { ref = ':'; } - if (GitRevision.isUncommitted(ref)) throw new Error(`ref=${ref} is uncommitted`); + if (isUncommitted(ref)) throw new Error(`ref=${ref} is uncommitted`); const opts: GitCommandOptions = { configs: gitLogDefaultConfigs, @@ -1694,6 +1954,18 @@ export class Git { return this.git({ cwd: repoPath }, 'stash', deleteAfter ? 'pop' : 'apply', stashName); } + async stash__rename(repoPath: string, stashName: string, ref: string, message: string, stashOnRef?: string) { + await this.stash__delete(repoPath, stashName, ref); + return this.git( + { cwd: repoPath }, + 'stash', + 'store', + '-m', + stashOnRef ? `On ${stashOnRef}: ${message}` : message, + ref, + ); + } + async stash__delete(repoPath: string, stashName: string, ref?: string) { if (!stashName) return undefined; @@ -1730,53 +2002,92 @@ export class Git { ); } + async stash__create(repoPath: string): Promise { + const params = ['stash', 'create']; + + const data = await this.git({ cwd: repoPath }, ...params); + return data?.trim() || undefined; + } + + async stash__store(repoPath: string, sha: string, message?: string): Promise { + const params = ['stash', 'store']; + + if (message) { + params.push('-m', message); + } + + params.push(sha); + + await this.git({ cwd: repoPath }, ...params); + } + async stash__push( repoPath: string, message?: string, - { - includeUntracked, - keepIndex, - pathspecs, - stdin, - }: { includeUntracked?: boolean; keepIndex?: boolean; pathspecs?: string[]; stdin?: boolean } = {}, + options?: { + includeUntracked?: boolean; + keepIndex?: boolean; + onlyStaged?: boolean; + pathspecs?: string[]; + stdin?: boolean; + }, ): Promise { const params = ['stash', 'push']; - if (includeUntracked || (pathspecs != null && pathspecs.length !== 0)) { - params.push('-u'); + if ((options?.includeUntracked || options?.pathspecs?.length) && !options?.onlyStaged) { + params.push('--include-untracked'); } - if (keepIndex) { - params.push('-k'); + if (options?.keepIndex && !options?.includeUntracked) { + params.push('--keep-index'); + } + + if (options?.onlyStaged) { + if (await this.isAtLeastVersion('2.35')) { + params.push('--staged'); + } else { + throw new Error('Git version 2.35 or higher is required for --staged'); + } } if (message) { params.push('-m', message); } - if (stdin && pathspecs != null && pathspecs.length !== 0) { - void (await this.git( - { cwd: repoPath, stdin: pathspecs.join('\0') }, - ...params, - '--pathspec-from-file=-', - '--pathspec-file-nul', - )); - - return; + let stdin; + if (options?.pathspecs?.length) { + if (options.stdin) { + stdin = options.pathspecs.join('\0'); + params.push('--pathspec-from-file=-', '--pathspec-file-nul', '--'); + } else { + params.push('--', ...options.pathspecs); + } + } else { + params.push('--'); } - params.push('--'); - if (pathspecs != null && pathspecs.length !== 0) { - params.push(...pathspecs); + try { + const data = await this.git({ cwd: repoPath, stdin: stdin }, ...params); + if (data.includes('No local changes to save')) { + throw new StashPushError(StashPushErrorReason.NothingToSave); + return; + } + } catch (ex) { + if ( + ex instanceof RunError && + ex.stdout.includes('Saved working directory and index state') && + ex.stderr.includes('Cannot remove worktree changes') + ) { + throw new StashPushError(StashPushErrorReason.ConflictingStagedAndUnstagedLines); + } + throw ex; } - - void (await this.git({ cwd: repoPath }, ...params)); } async status( repoPath: string, porcelainVersion: number = 1, - { similarityThreshold }: { similarityThreshold?: number | null } = {}, + options?: { similarityThreshold?: number }, ): Promise { const params = [ 'status', @@ -1785,7 +2096,9 @@ export class Git { '-u', ]; if (await this.isAtLeastVersion('2.18')) { - params.push(`--find-renames${similarityThreshold == null ? '' : `=${similarityThreshold}%`}`); + params.push( + `--find-renames${options?.similarityThreshold == null ? '' : `=${options.similarityThreshold}%`}`, + ); } return this.git( @@ -1795,33 +2108,12 @@ export class Git { ); } - async status__file( - repoPath: string, - fileName: string, - porcelainVersion: number = 1, - { similarityThreshold }: { similarityThreshold?: number | null } = {}, - ): Promise { - const [file, root] = splitPath(fileName, repoPath, true); - - const params = ['status', porcelainVersion >= 2 ? `--porcelain=v${porcelainVersion}` : '--porcelain']; - if (await this.isAtLeastVersion('2.18')) { - params.push(`--find-renames${similarityThreshold == null ? '' : `=${similarityThreshold}%`}`); - } - - return this.git( - { cwd: root, configs: gitStatusDefaultConfigs, env: { GIT_OPTIONAL_LOCKS: '0' } }, - ...params, - '--', - file, - ); - } - symbolic_ref(repoPath: string, ref: string) { return this.git({ cwd: repoPath }, 'symbolic-ref', '--short', ref); } tag(repoPath: string) { - return this.git({ cwd: repoPath }, 'tag', '-l', `--format=${GitTagParser.defaultFormat}`); + return this.git({ cwd: repoPath }, 'tag', '-l', `--format=${parseGitTagsDefaultFormat}`); } worktree__add( @@ -1866,22 +2158,22 @@ export class Git { } async readDotGitFile( - repoPath: string, - paths: string[], + gitDir: GitDir, + pathParts: string[], options?: { numeric?: false; throw?: boolean; trim?: boolean }, ): Promise; async readDotGitFile( - repoPath: string, - path: string[], + gitDir: GitDir, + pathParts: string[], options?: { numeric: true; throw?: boolean; trim?: boolean }, ): Promise; async readDotGitFile( - repoPath: string, + gitDir: GitDir, pathParts: string[], options?: { numeric?: boolean; throw?: boolean; trim?: boolean }, ): Promise { try { - const bytes = await workspace.fs.readFile(Uri.file(joinPaths(repoPath, '.git', ...pathParts))); + const bytes = await workspace.fs.readFile(Uri.joinPath(gitDir.uri, ...pathParts)); let contents = textDecoder.decode(bytes); contents = options?.trim ?? true ? contents.trim() : contents; @@ -1897,32 +2189,75 @@ export class Git { return undefined; } } + @log() + async runGitCommandViaTerminal(cwd: string, command: string, args: string[], options?: { execute?: boolean }) { + const scope = getLogScope(); - private _gitOutput: OutputChannel | undefined; + const location = await this.getLocation(); + const git = normalizePath(location.path ?? 'git'); - private logGitCommand(command: string, duration: number, ex?: Error): void { - if (Logger.enabled(LogLevel.Debug) && !Logger.isDebugging) return; + const coreEditorConfig = configuration.get('terminal.overrideGitEditor') + ? `-c "core.editor=${getEditorCommand()}" ` + : ''; - const slow = duration > slowCallWarningThreshold; + const parsedArgs = args.map(arg => (arg.startsWith('#') || /['();$|>&<]/.test(arg) ? `"${arg}"` : arg)); - if (Logger.isDebugging) { - if (ex != null) { - console.error(Logger.timestamp, '[GitLens (Git)]', command ?? emptyStr, ex); - } else if (slow) { - console.warn(Logger.timestamp, '[GitLens (Git)]', command ?? emptyStr); - } else { - console.log(Logger.timestamp, '[GitLens (Git)]', command ?? emptyStr); - } + let text; + if (git.includes(' ')) { + const shell = env.shell; + Logger.debug(scope, `\u2022 git path '${git}' contains spaces, detected shell: '${shell}'`); + + text = `${ + (isWindows ? /(pwsh|powershell)\.exe/i : /pwsh/i).test(shell) ? '&' : '' + } "${git}" -C "${cwd}" ${coreEditorConfig}${command} ${parsedArgs.join(' ')}`; + } else { + text = `${git} -C "${cwd}" ${coreEditorConfig}${command} ${parsedArgs.join(' ')}`; + } + + Logger.log(scope, `\u2022 '${text}'`); + this.logCore(`${getLoggableScopeBlockOverride('TERMINAL')} ${text}`); + + const terminal = ensureGitTerminal(); + terminal.show(false); + // Removing this as this doesn't seem to work on bash + // // Sends ansi codes to remove any text on the current input line + // terminal.sendText('\x1b[2K\x1b', false); + terminal.sendText(text, options?.execute ?? false); + } + + private logGitCommand(command: string, ex: Error | undefined, duration: number, waiting: boolean): void { + const slow = duration > slowCallWarningThreshold; + const status = slow && waiting ? ' (slow, waiting)' : waiting ? ' (waiting)' : slow ? ' (slow)' : ''; + + if (ex != null) { + Logger.error( + '', + `${getLoggableScopeBlockOverride('GIT')} ${command} ${GlyphChars.Dot} ${(ex.message || String(ex) || '') + .trim() + .replace(/fatal: /g, '') + .replace(/\r?\n|\r/g, ` ${GlyphChars.Dot} `)} [${duration}ms]${status}`, + ); + } else if (slow) { + Logger.warn( + `${getLoggableScopeBlockOverride('GIT', `*${duration}ms`)} ${command} [*${duration}ms]${status}`, + ); + } else { + Logger.log(`${getLoggableScopeBlockOverride('GIT', `${duration}ms`)} ${command} [${duration}ms]${status}`); } - if (this._gitOutput == null) { - this._gitOutput = window.createOutputChannel('GitLens (Git)'); + this.logCore(`${getLoggableScopeBlockOverride(slow ? '*' : '', `${duration}ms`)} ${command}${status}`, ex); + } + + private _gitOutput: OutputChannel | undefined; + + private logCore(message: string, ex?: Error | undefined): void { + if (!Logger.enabled(ex != null ? 'error' : 'debug')) return; + + this._gitOutput ??= window.createOutputChannel('GitLens (Git)'); + this._gitOutput.appendLine(`${Logger.timestamp} ${message}${ex != null ? ` ${GlyphChars.Dot} FAILED` : ''}`); + if (ex != null) { + this._gitOutput.appendLine(`\n${String(ex)}\n`); } - this._gitOutput.appendLine( - `${Logger.timestamp} [${slow ? '*' : ' '}${duration.toString().padStart(6)}ms] ${command}${ - ex != null ? `\n\n${ex.toString()}` : emptyStr - }`, - ); } } diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index c1dc90602d608..e3acc10cca753 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -1,30 +1,38 @@ -import { readdir, realpath } from 'fs'; -import { homedir, hostname, userInfo } from 'os'; -import { resolve as resolvePath } from 'path'; +import { promises as fs, readdir, realpath } from 'fs'; +import { homedir, hostname, tmpdir, userInfo } from 'os'; +import path, { resolve as resolvePath } from 'path'; import { env as process_env } from 'process'; -import { encodingExists } from 'iconv-lite'; -import type { CancellationToken, Event, TextDocument, WorkspaceFolder } from 'vscode'; -import { Disposable, env, EventEmitter, extensions, FileType, Range, Uri, window, workspace } from 'vscode'; import { md5 } from '@env/crypto'; import { fetch, getProxyAgent } from '@env/fetch'; import { hrtime } from '@env/hrtime'; import { isLinux, isWindows } from '@env/platform'; -import type { - API as BuiltInGitApi, - Repository as BuiltInGitRepository, - GitExtension, -} from '../../../@types/vscode.git'; +import type { CancellationToken, Event, TextDocument, WorkspaceFolder } from 'vscode'; +import { Disposable, env, EventEmitter, extensions, FileType, Range, Uri, window, workspace } from 'vscode'; +import type { GitExtension, API as ScmGitApi } from '../../../@types/vscode.git'; import { getCachedAvatarUri } from '../../../avatars'; -import { configuration } from '../../../configuration'; -import { CoreGitConfiguration, GlyphChars, Schemes } from '../../../constants'; +import type { GitConfigKeys } from '../../../constants'; +import { GlyphChars, Schemes } from '../../../constants'; +import type { SearchQuery } from '../../../constants.search'; import type { Container } from '../../../container'; import { emojify } from '../../../emojis'; +import { CancellationError } from '../../../errors'; import { Features } from '../../../features'; import { GitErrorHandling } from '../../../git/commandOptions'; import { + ApplyPatchCommitError, + ApplyPatchCommitErrorReason, + BlameIgnoreRevsFileBadRevisionError, + BlameIgnoreRevsFileError, + CherryPickError, + CherryPickErrorReason, + FetchError, GitSearchError, + PullError, + PushError, + PushErrorReason, StashApplyError, StashApplyErrorReason, + StashPushError, WorktreeCreateError, WorktreeCreateErrorReason, WorktreeDeleteError, @@ -35,22 +43,26 @@ import type { GitDir, GitProvider, GitProviderDescriptor, + LeftRightCommitCountResult, NextComparisonUrisResult, PagedResult, + PagingOptions, PreviousComparisonUrisResult, PreviousLineComparisonUrisResult, RepositoryCloseEvent, RepositoryInitWatcher, RepositoryOpenEvent, + RepositoryVisibility, RevisionUriData, ScmRepository, } from '../../../git/gitProvider'; -import { GitProviderId, RepositoryVisibility } from '../../../git/gitProvider'; -import { encodeGitLensRevisionUriAuthority, GitUri } from '../../../git/gitUri'; +import { GitUri, isGitUri } from '../../../git/gitUri'; +import { encodeGitLensRevisionUriAuthority } from '../../../git/gitUri.authority'; import type { GitBlame, GitBlameAuthor, GitBlameLine, GitBlameLines } from '../../../git/models/blame'; import type { BranchSortOptions } from '../../../git/models/branch'; import { getBranchId, + getBranchNameAndRemote, getBranchNameWithoutRemote, getRemoteNameFromBranchName, GitBranch, @@ -59,8 +71,16 @@ import { } from '../../../git/models/branch'; import type { GitStashCommit } from '../../../git/models/commit'; import { GitCommit, GitCommitIdentity } from '../../../git/models/commit'; +import { deletedOrMissing, uncommitted, uncommittedStaged } from '../../../git/models/constants'; import { GitContributor } from '../../../git/models/contributor'; -import type { GitDiff, GitDiffFilter, GitDiffHunkLine, GitDiffShortStat } from '../../../git/models/diff'; +import type { + GitDiff, + GitDiffFile, + GitDiffFiles, + GitDiffFilter, + GitDiffLine, + GitDiffShortStat, +} from '../../../git/models/diff'; import type { GitFile, GitFileStatus } from '../../../git/models/file'; import { GitFileChange } from '../../../git/models/file'; import type { @@ -69,16 +89,34 @@ import type { GitGraphRowContexts, GitGraphRowHead, GitGraphRowRemoteHead, + GitGraphRowsStats, + GitGraphRowStats, GitGraphRowTag, } from '../../../git/models/graph'; -import { GitGraphRowType } from '../../../git/models/graph'; import type { GitLog } from '../../../git/models/log'; import type { GitMergeStatus } from '../../../git/models/merge'; import type { GitRebaseStatus } from '../../../git/models/rebase'; -import type { GitBranchReference } from '../../../git/models/reference'; -import { GitReference, GitRevision } from '../../../git/models/reference'; +import type { + GitBranchReference, + GitReference, + GitRevisionRange, + GitTagReference, +} from '../../../git/models/reference'; +import { + createReference, + getBranchTrackingWithoutRemote, + getReferenceFromBranch, + isBranchReference, + isRevisionRange, + isSha, + isShaLike, + isUncommitted, + isUncommittedStaged, + shortenRevision, +} from '../../../git/models/reference'; import type { GitReflog } from '../../../git/models/reflog'; -import { getRemoteIconUri, GitRemote } from '../../../git/models/remote'; +import type { GitRemote } from '../../../git/models/remote'; +import { getRemoteIconUri, getVisibilityCacheKey, sortRemotes } from '../../../git/models/remote'; import { RemoteResourceType } from '../../../git/models/remoteResource'; import type { RepositoryChangeEvent } from '../../../git/models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; @@ -91,35 +129,42 @@ import type { GitTreeEntry } from '../../../git/models/tree'; import type { GitUser } from '../../../git/models/user'; import { isUserMatch } from '../../../git/models/user'; import type { GitWorktree } from '../../../git/models/worktree'; -import { GitBlameParser } from '../../../git/parsers/blameParser'; -import { GitBranchParser } from '../../../git/parsers/branchParser'; -import { GitDiffParser } from '../../../git/parsers/diffParser'; +import { getWorktreeId, groupWorktreesByBranch } from '../../../git/models/worktree'; +import { parseGitBlame } from '../../../git/parsers/blameParser'; +import { parseGitBranches } from '../../../git/parsers/branchParser'; +import { + parseGitApplyFiles, + parseGitDiffNameStatusFiles, + parseGitDiffShortStat, + parseGitFileDiff, +} from '../../../git/parsers/diffParser'; import { createLogParserSingle, createLogParserWithFiles, getContributorsParser, getGraphParser, + getGraphStatsParser, getRefAndDateParser, getRefParser, - GitLogParser, LogType, + parseGitLog, + parseGitLogAllFormat, + parseGitLogDefaultFormat, + parseGitLogSimple, + parseGitLogSimpleFormat, + parseGitLogSimpleRenamed, } from '../../../git/parsers/logParser'; -import { GitReflogParser } from '../../../git/parsers/reflogParser'; -import { GitRemoteParser } from '../../../git/parsers/remoteParser'; -import { GitStatusParser } from '../../../git/parsers/statusParser'; -import { GitTagParser } from '../../../git/parsers/tagParser'; -import { GitTreeParser } from '../../../git/parsers/treeParser'; -import { GitWorktreeParser } from '../../../git/parsers/worktreeParser'; -import type { RemoteProvider } from '../../../git/remotes/remoteProvider'; -import type { RemoteProviders } from '../../../git/remotes/remoteProviders'; +import { parseGitRefLog, parseGitRefLogDefaultFormat } from '../../../git/parsers/reflogParser'; +import { parseGitRemotes } from '../../../git/parsers/remoteParser'; +import { parseGitStatus } from '../../../git/parsers/statusParser'; +import { parseGitTags } from '../../../git/parsers/tagParser'; +import { parseGitLsFiles, parseGitTree } from '../../../git/parsers/treeParser'; +import { parseGitWorktrees } from '../../../git/parsers/worktreeParser'; import { getRemoteProviderMatcher, loadRemoteProviders } from '../../../git/remotes/remoteProviders'; -import type { RichRemoteProvider } from '../../../git/remotes/richRemoteProvider'; -import type { GitSearch, GitSearchResultData, GitSearchResults, SearchQuery } from '../../../git/search'; +import type { GitSearch, GitSearchResultData, GitSearchResults } from '../../../git/search'; import { getGitArgsFromSearchQuery, getSearchQueryComparisonKey } from '../../../git/search'; -import { Logger } from '../../../logger'; -import type { LogScope } from '../../../logScope'; -import { getLogScope } from '../../../logScope'; import { + showBlameInvalidIgnoreRevsFileWarningMessage, showGenericErrorMessage, showGitDisabledErrorMessage, showGitInvalidConfigErrorMessage, @@ -132,33 +177,38 @@ import type { GraphItemRefContext, GraphItemRefGroupContext, GraphTagContextValue, -} from '../../../plus/webviews/graph/graphWebview'; +} from '../../../plus/webviews/graph/protocol'; import { countStringLength, filterMap } from '../../../system/array'; -import { TimedCancellationSource } from '../../../system/cancellation'; import { gate } from '../../../system/decorators/gate'; import { debug, log } from '../../../system/decorators/log'; -import { filterMap as filterMapIterable, find, first, join, last, map, some } from '../../../system/iterable'; +import { debounce } from '../../../system/function'; +import { filterMap as filterMapIterable, find, first, join, last, map, skip, some } from '../../../system/iterable'; +import { Logger } from '../../../system/logger'; +import type { LogScope } from '../../../system/logger.scope'; +import { getLogScope, setLogScopeExit } from '../../../system/logger.scope'; import { commonBaseIndex, dirname, - getBestPath, isAbsolute, isFolderGlob, joinPaths, maybeUri, normalizePath, - relative, - splitPath, + pathEquals, } from '../../../system/path'; import type { PromiseOrValue } from '../../../system/promise'; -import { any, fastestSettled, getSettledValue } from '../../../system/promise'; +import { any, asSettled, getSettledValue } from '../../../system/promise'; import { equalsIgnoreCase, getDurationMilliseconds, interpolate, splitSingle } from '../../../system/string'; import { PathTrie } from '../../../system/trie'; import { compare, fromString } from '../../../system/version'; +import { TimedCancellationSource } from '../../../system/vscode/cancellation'; +import { configuration } from '../../../system/vscode/configuration'; +import { getBestPath, relative, splitPath } from '../../../system/vscode/path'; import { serializeWebviewItemContext } from '../../../system/webview'; -import type { CachedBlame, CachedDiff, CachedLog, TrackedDocument } from '../../../trackers/gitDocumentTracker'; -import { GitDocumentState } from '../../../trackers/gitDocumentTracker'; -import type { Git } from './git'; +import type { CachedBlame, CachedDiff, CachedLog, TrackedGitDocument } from '../../../trackers/trackedDocument'; +import { GitDocumentState } from '../../../trackers/trackedDocument'; +import { registerCommitMessageProvider } from './commitMessageProvider'; +import type { Git, PushForceOptions } from './git'; import { getShaInLogRegex, GitErrors, @@ -171,7 +221,7 @@ import { findGitPath, InvalidGitConfigError, UnableToFindGitError } from './loca import { CancelledRunError, fsExists, RunError } from './shell'; const emptyArray = Object.freeze([]) as unknown as any[]; -const emptyPromise: Promise = Promise.resolve(undefined); +const emptyPromise: Promise = Promise.resolve(undefined); const emptyPagedResult: PagedResult = Object.freeze({ values: [] }); const slash = 47; @@ -183,7 +233,8 @@ const driveLetterRegex = /(?<=^\/?)([a-zA-Z])(?=:\/)/; const userConfigRegex = /^user\.(name|email) (.*)$/gm; const mappedAuthorRegex = /(.+)\s<(.+)>/; const stashSummaryRegex = - /(?:(?:(?WIP) on|On) (?[^/](?!.*\/\.)(?!.*\.\.)(?!.*\/\/)(?!.*@\{)[^\000-\037\177 ~^:?*[\\]+[^./]):\s*)?(?

.*)$/s; + // eslint-disable-next-line no-control-regex + /(?:(?:(?WIP) on|On) (?[^/](?!.*\/\.)(?!.*\.\.)(?!.*\/\/)(?!.*@\{)[^\x00-\x1F\x7F ~^:?*[\\]+[^./]):\s*)?(?.*)$/s; const reflogCommands = ['merge', 'pull']; @@ -193,8 +244,8 @@ interface RepositoryInfo { } export class LocalGitProvider implements GitProvider, Disposable { - readonly descriptor: GitProviderDescriptor = { id: GitProviderId.Git, name: 'Git', virtual: false }; - readonly supportedSchemes: Set = new Set([ + readonly descriptor: GitProviderDescriptor = { id: 'git', name: 'Git', virtual: false }; + readonly supportedSchemes = new Set([ Schemes.File, Schemes.Git, Schemes.GitLens, @@ -202,6 +253,11 @@ export class LocalGitProvider implements GitProvider, Disposable { // DocumentSchemes.Vsls, ]); + private _onDidChange = new EventEmitter(); + get onDidChange(): Event { + return this._onDidChange.event; + } + private _onDidChangeRepository = new EventEmitter(); get onDidChangeRepository(): Event { return this._onDidChangeRepository.event; @@ -218,24 +274,32 @@ export class LocalGitProvider implements GitProvider, Disposable { } private readonly _branchesCache = new Map>>(); - private readonly _contributorsCache = new Map>(); - private readonly _mergeStatusCache = new Map(); - private readonly _rebaseStatusCache = new Map(); + private readonly _contributorsCache = new Map>>(); + private readonly _mergeStatusCache = new Map>(); + private readonly _rebaseStatusCache = new Map>(); + private readonly _remotesCache = new Map>(); private readonly _repoInfoCache = new Map(); private readonly _stashesCache = new Map(); private readonly _tagsCache = new Map>>(); private readonly _trackedPaths = new PathTrie>(); + private readonly _worktreesCache = new Map>(); private _disposables: Disposable[] = []; - constructor(protected readonly container: Container, protected readonly git: Git) { + constructor( + protected readonly container: Container, + protected readonly git: Git, + ) { this.git.setLocator(this.ensureGit.bind(this)); this._disposables.push( + configuration.onDidChange(e => { + if (configuration.changed(e, 'remotes')) { + this.resetCaches(undefined, 'remotes'); + } + }, this), this.container.events.on('git:cache:reset', e => - e.data.repoPath - ? this.resetCache(e.data.repoPath, ...(e.data.caches ?? emptyArray)) - : this.resetCaches(...(e.data.caches ?? emptyArray)), + this.resetCaches(e.data.repoPath, ...(e.data.caches ?? emptyArray)), ), ); } @@ -256,7 +320,11 @@ export class LocalGitProvider implements GitProvider, Disposable { if (e.changed(RepositoryChange.Heads, RepositoryChange.Remotes, RepositoryChangeComparisonMode.Any)) { this._branchesCache.delete(repo.path); this._contributorsCache.delete(repo.path); - this._contributorsCache.delete(`stats|${repo.path}`); + this._worktreesCache.delete(repo.path); + } + + if (e.changed(RepositoryChange.Remotes, RepositoryChange.RemoteProviders, RepositoryChangeComparisonMode.Any)) { + this._remotesCache.delete(repo.path); } if (e.changed(RepositoryChange.Index, RepositoryChange.Unknown, RepositoryChangeComparisonMode.Any)) { @@ -279,6 +347,10 @@ export class LocalGitProvider implements GitProvider, Disposable { this._tagsCache.delete(repo.path); } + if (e.changed(RepositoryChange.Worktrees, RepositoryChangeComparisonMode.Any)) { + this._worktreesCache.delete(repo.path); + } + this._onDidChangeRepository.fire(e); } @@ -295,7 +367,7 @@ export class LocalGitProvider implements GitProvider, Disposable { private async findGit(): Promise { const scope = getLogScope(); - if (!configuration.getAny('git.enabled', null, true)) { + if (!configuration.getCore('git.enabled', null, true)) { Logger.log(scope, 'Built-in Git is disabled ("git.enabled": false)'); void showGitDisabledErrorMessage(); @@ -308,8 +380,40 @@ export class LocalGitProvider implements GitProvider, Disposable { const scmGit = await scmGitPromise; if (scmGit == null) return; + registerCommitMessageProvider(this.container, scmGit); + + // Find env to pass to Git + for (const v of Object.values(scmGit.git)) { + if (v != null && typeof v === 'object' && 'git' in v) { + for (const vv of Object.values(v.git)) { + if (vv != null && typeof vv === 'object' && 'GIT_ASKPASS' in vv) { + Logger.debug(scope, 'Found built-in Git env'); + + this.git.setEnv(vv); + break; + } + } + } + } + + const closing = new Set(); + const fireRepositoryClosed = debounce(() => { + if (this.container.deactivating) return; + + for (const uri of closing) { + this._onDidCloseRepository.fire({ uri: uri }); + } + closing.clear(); + }, 1000); + this._disposables.push( - scmGit.onDidCloseRepository(e => this._onDidCloseRepository.fire({ uri: e.rootUri })), + // Since we will get "close" events for repos when vscode is shutting down, debounce the event so ensure we aren't shutting down + scmGit.onDidCloseRepository(e => { + if (this.container.deactivating) return; + + closing.add(e.rootUri); + fireRepositoryClosed(); + }), scmGit.onDidOpenRepository(e => this._onDidOpenRepository.fire({ uri: e.rootUri })), ); @@ -319,8 +423,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } void subscribeToScmOpenCloseRepository.call(this); - const potentialGitPaths = - configuration.getAny('git.path') ?? this.container.storage.getWorkspace('gitPath'); + const potentialGitPaths = configuration.getCore('git.path') ?? this.container.storage.getWorkspace('gitPath'); const start = hrtime(); @@ -346,15 +449,18 @@ export class LocalGitProvider implements GitProvider, Disposable { setTimeout(() => void this.container.storage.storeWorkspace('gitPath', location.path), 1000); if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} Git (${location.version}) found in ${ - location.path === 'git' ? 'PATH' : location.path - }`; + setLogScopeExit( + scope, + ` ${GlyphChars.Dot} Git (${location.version}) found in ${ + location.path === 'git' ? 'PATH' : location.path + }`, + ); } else { Logger.log( scope, - `Git (${location.version}) found in ${location.path === 'git' ? 'PATH' : location.path} ${ - GlyphChars.Dot - } ${getDurationMilliseconds(start)} ms`, + `Git (${location.version}) found in ${ + location.path === 'git' ? 'PATH' : location.path + } [${getDurationMilliseconds(start)}ms]`, ); } @@ -367,32 +473,38 @@ export class LocalGitProvider implements GitProvider, Disposable { return location; } - async discoverRepositories(uri: Uri): Promise { + @debug({ exit: true }) + async discoverRepositories( + uri: Uri, + options?: { cancellation?: CancellationToken; depth?: number; silent?: boolean }, + ): Promise { if (uri.scheme !== Schemes.File) return []; try { - void (await this.ensureGit()); - - const autoRepositoryDetection = - configuration.getAny( - CoreGitConfiguration.AutoRepositoryDetection, - ) ?? true; + const autoRepositoryDetection = configuration.getCore('git.autoRepositoryDetection') ?? true; const folder = workspace.getWorkspaceFolder(uri); - if (folder == null) return []; + if (folder == null && !options?.silent) return []; + + void (await this.ensureGit()); + + if (options?.cancellation?.isCancellationRequested) return []; const repositories = await this.repositorySearch( - folder, - autoRepositoryDetection === false || autoRepositoryDetection === 'openEditors' ? 0 : undefined, + folder ?? uri, + options?.depth ?? + (autoRepositoryDetection === false || autoRepositoryDetection === 'openEditors' ? 0 : undefined), + options?.cancellation, + options?.silent, ); - if (autoRepositoryDetection === true || autoRepositoryDetection === 'subFolders') { + if (!options?.silent && (autoRepositoryDetection === true || autoRepositoryDetection === 'subFolders')) { for (const repository of repositories) { - void this.openScmRepository(repository.uri); + void this.getOrOpenScmRepository(repository.uri); } } - if (repositories.length > 0) { + if (!options?.silent && repositories.length > 0) { this._trackedPaths.clear(); } @@ -404,7 +516,7 @@ export class LocalGitProvider implements GitProvider, Disposable { void showGitMissingErrorMessage(); } else { const msg: string = ex?.message ?? ''; - if (msg) { + if (msg && !options?.silent) { void window.showErrorMessage(`Unable to initialize Git; ${msg}`); } } @@ -413,6 +525,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } } + @debug({ exit: true }) openRepository( folder: WorkspaceFolder | undefined, uri: Uri, @@ -421,52 +534,43 @@ export class LocalGitProvider implements GitProvider, Disposable { closed?: boolean, ): Repository[] { if (!closed) { - void this.openScmRepository(uri); + void this.getOrOpenScmRepository(uri); } - // Add a closed (hidden) repository for the canonical version + const opened = [ + new Repository( + this.container, + this.onRepositoryChanged.bind(this), + this.descriptor, + folder ?? workspace.getWorkspaceFolder(uri), + uri, + root, + suspended ?? !window.state.focused, + closed, + ), + ]; + + // Add a closed (hidden) repository for the canonical version if not already opened const canonicalUri = this.toCanonicalMap.get(getBestPath(uri)); - if (canonicalUri != null) { - return [ + if (canonicalUri != null && this.container.git.getRepository(canonicalUri) == null) { + opened.push( new Repository( this.container, this.onRepositoryChanged.bind(this), this.descriptor, - folder, - uri, - root, - suspended ?? !window.state.focused, - closed, - // canonicalUri, - ), - new Repository( - this.container, - this.onRepositoryChanged.bind(this), - this.descriptor, - folder, + folder ?? workspace.getWorkspaceFolder(canonicalUri), canonicalUri, root, suspended ?? !window.state.focused, true, - // uri, ), - ]; + ); } - return [ - new Repository( - this.container, - this.onRepositoryChanged.bind(this), - this.descriptor, - folder, - uri, - root, - suspended ?? !window.state.focused, - closed, - ), - ]; + return opened; } + @debug({ singleLine: true }) openRepositoryInitWatcher(): RepositoryInitWatcher { const watcher = workspace.createFileSystemWatcher('**/.git', false, true, true); return { @@ -485,34 +589,47 @@ export class LocalGitProvider implements GitProvider, Disposable { supported = await this.git.isAtLeastVersion('2.17.0'); this._supportedFeatures.set(feature, supported); return supported; + case Features.StashOnlyStaged: + supported = await this.git.isAtLeastVersion('2.35.0'); + this._supportedFeatures.set(feature, supported); + return supported; + case Features.ForceIfIncludes: + supported = await this.git.isAtLeastVersion('2.30.0'); + this._supportedFeatures.set(feature, supported); + return supported; default: return true; } } - async visibility(repoPath: string): Promise { + @debug({ exit: r => `returned ${r[0]}` }) + async visibility(repoPath: string): Promise<[visibility: RepositoryVisibility, cacheKey: string | undefined]> { const remotes = await this.getRemotes(repoPath, { sort: true }); - if (remotes.length === 0) return RepositoryVisibility.Local; + if (remotes.length === 0) return ['local', undefined]; let local = true; - for await (const result of fastestSettled(remotes.map(r => this.getRemoteVisibility(r)))) { + for await (const result of asSettled(remotes.map(r => this.getRemoteVisibility(r)))) { if (result.status !== 'fulfilled') continue; - if (result.value === RepositoryVisibility.Public) return RepositoryVisibility.Public; - if (result.value !== RepositoryVisibility.Local) { + if (result.value[0] === 'public') { + return ['public', getVisibilityCacheKey(result.value[1])]; + } + if (result.value[0] !== 'local') { local = false; } } - return local ? RepositoryVisibility.Local : RepositoryVisibility.Private; + return local ? ['local', undefined] : ['private', getVisibilityCacheKey(remotes)]; } - @debug({ args: { 0: r => r.url } }) + private _pendingRemoteVisibility = new Map>(); + @debug({ args: { 0: r => r.url }, exit: r => `returned ${r[0]}` }) private async getRemoteVisibility( - remote: GitRemote, - ): Promise { + remote: GitRemote, + ): Promise<[visibility: RepositoryVisibility, remote: GitRemote]> { const scope = getLogScope(); + let url; switch (remote.provider?.id) { case 'github': case 'gitlab': @@ -520,42 +637,77 @@ export class LocalGitProvider implements GitProvider, Disposable { case 'azure-devops': case 'gitea': case 'gerrit': - case 'google-source': { - const url = remote.provider.url({ type: RemoteResourceType.Repo }); - if (url == null) return RepositoryVisibility.Private; + case 'google-source': + url = remote.provider.url({ type: RemoteResourceType.Repo }); + if (url == null) return ['private', remote]; - // Check if the url returns a 200 status code - try { - const rsp = await fetch(url, { method: 'HEAD', agent: getProxyAgent() }); - if (rsp.ok) return RepositoryVisibility.Public; - - Logger.debug(scope, `Response=${rsp.status}`); - } catch (ex) { - debugger; - Logger.error(ex, scope); + break; + default: { + url = remote.url; + if (!url.includes('git@')) { + return maybeUri(url) ? ['private', remote] : ['local', remote]; } - return RepositoryVisibility.Private; + + const [host, repo] = url.split('@')[1].split(':'); + if (!host || !repo) return ['private', remote]; + + url = `https://${host}/${repo}`; } - default: - return maybeUri(remote.url) ? RepositoryVisibility.Private : RepositoryVisibility.Local; } + + // Check if the url returns a 200 status code + let promise = this._pendingRemoteVisibility.get(url); + if (promise == null) { + const aborter = new AbortController(); + const timer = setTimeout(() => aborter.abort(), 30000); + + promise = fetch(url, { method: 'HEAD', agent: getProxyAgent(), signal: aborter.signal }); + void promise.finally(() => clearTimeout(timer)); + + this._pendingRemoteVisibility.set(url, promise); + } + + try { + const rsp = await promise; + if (rsp.ok) return ['public', remote]; + + Logger.debug(scope, `Response=${rsp.status}`); + } catch (ex) { + debugger; + Logger.error(ex, scope); + } finally { + this._pendingRemoteVisibility.delete(url); + } + return ['private', remote]; } @log({ args: false, singleLine: true, - prefix: (context, folder) => `${context.prefix}(${folder.uri.fsPath})`, - exit: result => - `returned ${result.length} repositories${ - result.length !== 0 ? ` (${result.map(r => r.path).join(', ')})` : '' - }`, + prefix: (context, folder) => `${context.prefix}(${(folder instanceof Uri ? folder : folder.uri).fsPath})`, + exit: r => `returned ${r.length} repositories ${r.length !== 0 ? Logger.toLoggable(r) : ''}`, }) - private async repositorySearch(folder: WorkspaceFolder, depth?: number): Promise { + private async repositorySearch( + folderOrUri: Uri | WorkspaceFolder, + depth?: number, + cancellation?: CancellationToken, + silent?: boolean | undefined, + ): Promise { const scope = getLogScope(); + + let folder; + let rootUri; + if (folderOrUri instanceof Uri) { + rootUri = folderOrUri; + folder = workspace.getWorkspaceFolder(rootUri); + } else { + rootUri = folderOrUri.uri; + } + depth = depth ?? - configuration.get('advanced.repositorySearchDepth', folder.uri) ?? - configuration.getAny(CoreGitConfiguration.RepositoryScanMaxDepth, folder.uri, 1); + configuration.get('advanced.repositorySearchDepth', rootUri) ?? + configuration.getCore('git.repositoryScanMaxDepth', rootUri, 1); Logger.log(scope, `searching (depth=${depth})...`); @@ -564,7 +716,7 @@ export class LocalGitProvider implements GitProvider, Disposable { let rootPath; let canonicalRootPath; - const uri = await this.findRepositoryUri(folder.uri, true); + const uri = await this.findRepositoryUri(rootUri, true); if (uri != null) { rootPath = normalizePath(uri.fsPath); @@ -574,33 +726,29 @@ export class LocalGitProvider implements GitProvider, Disposable { } Logger.log(scope, `found root repository in '${uri.fsPath}'`); - repositories.push(...this.openRepository(folder, uri, true)); + repositories.push(...this.openRepository(folder, uri, true, undefined, silent)); } - if (depth <= 0) return repositories; + if (depth <= 0 || cancellation?.isCancellationRequested) return repositories; // Get any specified excludes -- this is a total hack, but works for some simple cases and something is better than nothing :) - const excludedConfig = { - ...configuration.getAny>('files.exclude', folder.uri, {}), - ...configuration.getAny>('search.exclude', folder.uri, {}), - }; - - const excludedPaths = [ - ...filterMapIterable(Object.entries(excludedConfig), ([key, value]) => { - if (!value) return undefined; - if (key.startsWith('**/')) return key.substring(3); - return key; - }), - ]; - - const excludes = excludedPaths.reduce((accumulator, current) => { - accumulator.add(current); - return accumulator; - }, new Set()); + const excludes = new Set(configuration.getCore('git.repositoryScanIgnoredFolders', rootUri, [])); + for (let [key, value] of Object.entries({ + ...configuration.getCore('files.exclude', rootUri, {}), + ...configuration.getCore('search.exclude', rootUri, {}), + })) { + if (!value) continue; + if (key.includes('*.')) continue; + + if (key.startsWith('**/')) { + key = key.substring(3); + } + excludes.add(key); + } let repoPaths; try { - repoPaths = await this.repositorySearchCore(folder.uri.fsPath, depth, excludes); + repoPaths = await this.repositorySearchCore(rootUri.fsPath, depth, excludes, cancellation); } catch (ex) { const msg: string = ex?.toString() ?? ''; if (RepoSearchWarnings.doesNotExist.test(msg)) { @@ -636,21 +784,24 @@ export class LocalGitProvider implements GitProvider, Disposable { if (rp == null) continue; Logger.log(scope, `found repository in '${rp.fsPath}'`); - repositories.push(...this.openRepository(folder, rp, false)); + repositories.push(...this.openRepository(folder, rp, false, undefined, silent)); } return repositories; } - @debug({ args: { 2: false, 3: false } }) + @debug({ args: { 2: false, 3: false }, exit: true }) private repositorySearchCore( root: string, depth: number, excludes: Set, + cancellation?: CancellationToken, repositories: string[] = [], ): Promise { const scope = getLogScope(); + if (cancellation?.isCancellationRequested) return Promise.resolve(repositories); + return new Promise((resolve, reject) => { readdir(root, { withFileTypes: true }, async (err, files) => { if (err != null) { @@ -667,11 +818,19 @@ export class LocalGitProvider implements GitProvider, Disposable { let f; for (f of files) { + if (cancellation?.isCancellationRequested) break; + if (f.name === '.git') { repositories.push(resolvePath(root, f.name)); } else if (depth >= 0 && f.isDirectory() && !excludes.has(f.name)) { try { - await this.repositorySearchCore(resolvePath(root, f.name), depth, excludes, repositories); + await this.repositorySearchCore( + resolvePath(root, f.name), + depth, + excludes, + cancellation, + repositories, + ); } catch (ex) { Logger.error(ex, scope, 'FAILED'); } @@ -719,12 +878,12 @@ export class LocalGitProvider implements GitProvider, Disposable { return Uri.joinPath(base, relativePath); } - @log() + @log({ exit: true }) async getBestRevisionUri(repoPath: string, path: string, ref: string | undefined): Promise { - if (ref === GitRevision.deletedOrMissing) return undefined; + if (ref === deletedOrMissing) return undefined; // TODO@eamodio Align this with isTrackedCore? - if (!ref || (GitRevision.isUncommitted(ref) && !GitRevision.isUncommittedStaged(ref))) { + if (!ref || (isUncommitted(ref) && !isUncommittedStaged(ref))) { // Make sure the file exists in the repo let data = await this.git.ls_files(repoPath, path); if (data != null) return this.getAbsoluteUri(path, repoPath); @@ -736,7 +895,21 @@ export class LocalGitProvider implements GitProvider, Disposable { return undefined; } - if (GitRevision.isUncommittedStaged(ref)) return this.getScmGitUri(path, repoPath); + // If the ref is the index, then try to create a Uri using the Git extension, but if we can't find a repo for it, then generate our own Uri + if (isUncommittedStaged(ref)) { + let scmRepo = await this.getScmRepository(repoPath); + if (scmRepo == null) { + // If the repoPath is a canonical path, then we need to remap it to the real path, because the vscode.git extension always uses the real path + const realUri = this.fromCanonicalMap.get(repoPath); + if (realUri != null) { + scmRepo = await this.getScmRepository(realUri.fsPath); + } + } + + if (scmRepo != null) { + return this.getScmGitUri(path, repoPath); + } + } return this.getRevisionUri(repoPath, path, ref); } @@ -778,13 +951,18 @@ export class LocalGitProvider implements GitProvider, Disposable { } getRevisionUri(repoPath: string, path: string, ref: string): Uri { - if (GitRevision.isUncommitted(ref)) { - return GitRevision.isUncommittedStaged(ref) - ? this.getScmGitUri(path, repoPath) - : this.getAbsoluteUri(path, repoPath); - } + if (isUncommitted(ref) && !isUncommittedStaged(ref)) return this.getAbsoluteUri(path, repoPath); + + let uncPath; path = normalizePath(this.getAbsoluteUri(path, repoPath).fsPath); + if (path.startsWith('//')) { + // save the UNC part of the path so we can re-add it later + const index = path.indexOf('/', 2); + uncPath = path.substring(0, index); + path = path.substring(index); + } + if (path.charCodeAt(0) !== slash) { path = `/${path}`; } @@ -792,18 +970,22 @@ export class LocalGitProvider implements GitProvider, Disposable { const metadata: RevisionUriData = { ref: ref, repoPath: normalizePath(repoPath), + uncPath: uncPath, }; const uri = Uri.from({ scheme: Schemes.GitLens, authority: encodeGitLensRevisionUriAuthority(metadata), path: path, - query: ref ? JSON.stringify({ ref: GitRevision.shorten(ref) }) : undefined, + // Replace `/` with `\u2009\u2215\u2009` so that it doesn't get treated as part of the path of the file + query: ref + ? JSON.stringify({ ref: shortenRevision(ref).replaceAll('/', '\u2009\u2215\u2009') }) + : undefined, }); return uri; } - @log() + @log({ exit: true }) async getWorkingUri(repoPath: string, uri: Uri) { let relativePath = this.getRelativePath(uri, repoPath); @@ -829,7 +1011,7 @@ export class LocalGitProvider implements GitProvider, Disposable { // Now check if that commit had any renames data = await this.git.log__file(repoPath, '.', ref, { - argsOrFormat: GitLogParser.simpleFormat, + argsOrFormat: parseGitLogSimpleFormat, fileMode: 'simple', filters: ['R', 'C', 'D'], limit: 1, @@ -837,7 +1019,7 @@ export class LocalGitProvider implements GitProvider, Disposable { }); if (data == null || data.length === 0) break; - const [foundRef, foundFile, foundStatus] = GitLogParser.parseSimpleRenamed(data, relativePath); + const [foundRef, foundFile, foundStatus] = parseGitLogSimpleRenamed(data, relativePath); if (foundStatus === 'D' && foundFile != null) return undefined; if (foundRef == null || foundFile == null) break; @@ -912,6 +1094,138 @@ export class LocalGitProvider implements GitProvider, Disposable { } } + @log() + async applyUnreachableCommitForPatch( + repoPath: string, + ref: string, + options?: { + branchName?: string; + createBranchIfNeeded?: boolean; + createWorktreePath?: string; + stash?: boolean | 'prompt'; + }, + ): Promise { + const scope = getLogScope(); + + if (options?.stash) { + // Stash any changes first + const status = await this.getStatusForRepo(repoPath); + if (status?.files?.length) { + if (options.stash === 'prompt') { + const confirm = { title: 'Stash Changes' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + 'You have changes in your working tree.\nDo you want to stash them before applying the patch?', + { modal: true }, + confirm, + cancel, + ); + + if (result !== confirm) throw new CancellationError(); + } + + try { + await this.git.stash__push(repoPath, undefined, { includeUntracked: true }); + } catch (ex) { + Logger.error(ex, scope); + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.StashFailed, + `Unable to apply patch; failed stashing working changes changes${ + ex instanceof StashPushError ? `: ${ex.message}` : '' + }`, + ex, + ); + } + } + } + + let targetPath = repoPath; + const currentBranch = await this.getBranch(repoPath); + const branchExists = + options?.branchName == null || + currentBranch?.name === options.branchName || + (await this.getBranches(repoPath, { filter: b => b.name === options.branchName }))?.values?.length > 0; + const shouldCreate = options?.branchName != null && !branchExists && options.createBranchIfNeeded; + + // TODO: Worktree creation should ideally be handled before calling this, and then + // applyPatchCommit should be pointing to the worktree path. If done here, the newly created + // worktree cannot be opened and we cannot handle issues elegantly. + if (options?.createWorktreePath != null) { + if (options?.branchName === null || options.branchName === currentBranch?.name) { + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.CreateWorktreeFailed, + 'Unable to apply patch; failed creating worktree', + ); + } + + try { + await this.createWorktree(repoPath, options.createWorktreePath, { + commitish: options?.branchName != null && branchExists ? options.branchName : currentBranch?.name, + createBranch: shouldCreate ? options.branchName : undefined, + }); + } catch (ex) { + Logger.error(ex, scope); + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.CreateWorktreeFailed, + `Unable to apply patch; failed creating worktree${ + ex instanceof WorktreeCreateError ? `: ${ex.message}` : '' + }`, + ex, + ); + } + + const worktree = await this.container.git.getWorktree( + repoPath, + w => normalizePath(w.uri.fsPath) === normalizePath(options.createWorktreePath!), + ); + if (worktree == null) { + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.CreateWorktreeFailed, + 'Unable to apply patch; failed creating worktree', + ); + } + + targetPath = worktree.uri.fsPath; + } + + if (options?.branchName != null && currentBranch?.name !== options.branchName) { + const checkoutRef = shouldCreate ? currentBranch?.ref ?? 'HEAD' : options.branchName; + await this.checkout(targetPath, checkoutRef, { + createBranch: shouldCreate ? options.branchName : undefined, + }); + } + + // Apply the patch using a cherry pick without committing + try { + await this.git.cherrypick(targetPath, ref, { noCommit: true, errors: GitErrorHandling.Throw }); + } catch (ex) { + Logger.error(ex, scope); + if (ex instanceof CherryPickError) { + if (ex.reason === CherryPickErrorReason.Conflicts) { + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.AppliedWithConflicts, + `Patch applied with conflicts`, + ex, + ); + } + + if (ex.reason === CherryPickErrorReason.AbortedWouldOverwrite) { + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.ApplyAbortedWouldOverwrite, + `Unable to apply patch as some local changes would be overwritten`, + ex, + ); + } + } + + throw new ApplyPatchCommitError( + ApplyPatchCommitErrorReason.ApplyFailed, + `Unable to apply patch${ex instanceof CherryPickError ? `: ${ex.message}` : ''}`, + ex, + ); + } + } + @log() async checkout( repoPath: string, @@ -937,64 +1251,156 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @log({ singleLine: true }) - private resetCache( - repoPath: string, - ...caches: ('branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags')[] - ) { - if (caches.length === 0 || caches.includes('branches')) { - this._branchesCache.delete(repoPath); + @log() + async clone(url: string, parentPath: string): Promise { + const scope = getLogScope(); + + try { + return await this.git.clone(url, parentPath); + } catch (ex) { + Logger.error(ex, scope); + void showGenericErrorMessage(`Unable to clone '${url}'`); } - if (caches.length === 0 || caches.includes('contributors')) { - this._contributorsCache.delete(repoPath); + return undefined; + } + + @log({ args: { 1: '', 3: '' } }) + async createUnreachableCommitForPatch( + repoPath: string, + contents: string, + baseRef: string, + message: string, + ): Promise { + const scope = getLogScope(); + + if (!contents.endsWith('\n')) { + contents += '\n'; } - if (caches.length === 0 || caches.includes('stashes')) { - this._stashesCache.delete(repoPath); + // Create a temporary index file + const tempDir = await fs.mkdtemp(path.join(tmpdir(), 'gl-')); + const tempIndex = joinPaths(tempDir, 'index'); + + try { + // Tell Git to use our soon to be created index file + const env = { GIT_INDEX_FILE: tempIndex }; + + // Create the temp index file from a base ref/sha + + // Get the tree of the base + const newIndex = await this.git.git( + { + cwd: repoPath, + env: env, + }, + 'ls-tree', + '-z', + '-r', + '--full-name', + baseRef, + ); + + // Write the tree to our temp index + await this.git.git( + { + cwd: repoPath, + env: env, + stdin: newIndex, + }, + 'update-index', + '-z', + '--index-info', + ); + + // Apply the patch to our temp index, without touching the working directory + await this.git.apply2(repoPath, { env: env, stdin: contents }, '--cached'); + + // Create a new tree from our patched index + const tree = ( + await this.git.git( + { + cwd: repoPath, + env: env, + }, + 'write-tree', + ) + )?.trim(); + + // Create new commit from the tree + const sha = ( + await this.git.git( + { + cwd: repoPath, + env: env, + }, + 'commit-tree', + tree, + '-p', + baseRef, + '-m', + message, + ) + )?.trim(); + + return await this.getCommit(repoPath, sha); + } catch (ex) { + Logger.error(ex, scope); + debugger; + + throw ex; + } finally { + // Delete the temporary index file + try { + await fs.rm(tempDir, { recursive: true }); + } catch (_ex) { + debugger; + } } + } - if (caches.length === 0 || caches.includes('status')) { - this._mergeStatusCache.delete(repoPath); - this._rebaseStatusCache.delete(repoPath); + @log({ singleLine: true }) + private resetCaches(repoPath: string | undefined, ...caches: GitCaches[]) { + const cachesToClear = []; + + if (!caches.length || caches.includes('branches')) { + cachesToClear.push(this._branchesCache); } - if (caches.length === 0 || caches.includes('tags')) { - this._tagsCache.delete(repoPath); + if (!caches.length || caches.includes('contributors')) { + cachesToClear.push(this._contributorsCache); } - if (caches.length === 0) { - this._trackedPaths.delete(repoPath); - this._repoInfoCache.delete(repoPath); + if (!caches.length || caches.includes('remotes')) { + cachesToClear.push(this._remotesCache); } - } - @log({ singleLine: true }) - private resetCaches(...caches: GitCaches[]) { - if (caches.length === 0 || caches.includes('branches')) { - this._branchesCache.clear(); + if (!caches.length || caches.includes('stashes')) { + cachesToClear.push(this._stashesCache); } - if (caches.length === 0 || caches.includes('contributors')) { - this._contributorsCache.clear(); + if (!caches.length || caches.includes('status')) { + cachesToClear.push(this._mergeStatusCache, this._rebaseStatusCache); } - if (caches.length === 0 || caches.includes('stashes')) { - this._stashesCache.clear(); + if (!caches.length || caches.includes('tags')) { + cachesToClear.push(this._tagsCache); } - if (caches.length === 0 || caches.includes('status')) { - this._mergeStatusCache.clear(); - this._rebaseStatusCache.clear(); + if (!caches.length || caches.includes('worktrees')) { + cachesToClear.push(this._worktreesCache); } - if (caches.length === 0 || caches.includes('tags')) { - this._tagsCache.clear(); + if (!caches.length) { + cachesToClear.push(this._trackedPaths, this._repoInfoCache); } - if (caches.length === 0) { - this._trackedPaths.clear(); - this._repoInfoCache.clear(); + for (const cache of cachesToClear) { + if (repoPath != null) { + cache.delete(repoPath); + } else { + cache.clear(); + } } } @@ -1021,29 +1427,157 @@ export class LocalGitProvider implements GitProvider, Disposable { repoPath: string, options?: { all?: boolean; branch?: GitBranchReference; prune?: boolean; pull?: boolean; remote?: string }, ): Promise { - const { branch: branchRef, ...opts } = options ?? {}; - if (GitReference.isBranch(branchRef)) { - const repo = this.container.git.getRepository(repoPath); - const branch = await repo?.getBranch(branchRef?.name); - if (!branch?.remote && branch?.upstream == null) return undefined; - - await this.git.fetch(repoPath, { - branch: branch.getNameWithoutRemote(), - remote: branch.getRemoteName()!, - upstream: branch.getTrackingWithoutRemote()!, - pull: options?.pull, - }); + const scope = getLogScope(); + + const { branch, ...opts } = options ?? {}; + try { + if (isBranchReference(branch)) { + const [branchName, remoteName] = getBranchNameAndRemote(branch); + if (remoteName == null) return undefined; + + await this.git.fetch(repoPath, { + branch: branchName, + remote: remoteName, + upstream: getBranchTrackingWithoutRemote(branch)!, + pull: options?.pull, + }); + } else { + await this.git.fetch(repoPath, opts); + } + + this.container.events.fire('git:cache:reset', { repoPath: repoPath }); + } catch (ex) { + Logger.error(ex, scope); + if (!FetchError.is(ex)) throw ex; + + void window.showErrorMessage(ex.message); + } + } + + @gate() + @log() + async push( + repoPath: string, + options?: { reference?: GitReference; force?: boolean; publish?: { remote: string } }, + ): Promise { + const scope = getLogScope(); + + let branchName: string; + let remoteName: string | undefined; + let upstreamName: string | undefined; + let setUpstream: + | { + branch: string; + remote: string; + remoteBranch: string; + } + | undefined; + + if (isBranchReference(options?.reference)) { + if (options.publish != null) { + branchName = options.reference.name; + remoteName = options.publish.remote; + } else { + [branchName, remoteName] = getBranchNameAndRemote(options.reference); + } + upstreamName = getBranchTrackingWithoutRemote(options.reference); } else { - await this.git.fetch(repoPath, opts); + const branch = await this.getBranch(repoPath); + if (branch == null) return; + + branchName = + options?.reference != null + ? `${options.reference.ref}:${ + options?.publish != null ? 'refs/heads/' : '' + }${branch.getNameWithoutRemote()}` + : branch.name; + remoteName = branch.getRemoteName() ?? options?.publish?.remote; + upstreamName = options?.reference == null && options?.publish != null ? branch.name : undefined; + + // Git can't setup remote tracking when publishing a new branch to a specific commit, so we'll need to do it after the push + if (options?.publish?.remote != null && options?.reference != null) { + setUpstream = { + branch: branch.getNameWithoutRemote(), + remote: remoteName!, + remoteBranch: branch.getNameWithoutRemote(), + }; + } + } + + if (options?.publish == null && remoteName == null && upstreamName == null) { + debugger; + throw new PushError(PushErrorReason.Other); + } + + let forceOpts: PushForceOptions | undefined; + if (options?.force) { + const withLease = configuration.getCore('git.useForcePushWithLease') ?? true; + if (withLease) { + forceOpts = { + withLease: withLease, + ifIncludes: configuration.getCore('git.useForcePushIfIncludes') ?? true, + }; + } else { + forceOpts = { + withLease: withLease, + }; + } + } + + try { + await this.git.push(repoPath, { + branch: branchName, + remote: remoteName, + upstream: upstreamName, + force: forceOpts, + publish: options?.publish != null, + }); + + // Since Git can't setup remote tracking when publishing a new branch to a specific commit, do it now + if (setUpstream != null) { + await this.git.branch__set_upstream( + repoPath, + setUpstream.branch, + setUpstream.remote, + setUpstream.remoteBranch, + ); + } + + this.container.events.fire('git:cache:reset', { repoPath: repoPath }); + } catch (ex) { + Logger.error(ex, scope); + if (!PushError.is(ex)) throw ex; + + void window.showErrorMessage(ex.message); + } + } + + @gate() + @log() + async pull(repoPath: string, options?: { rebase?: boolean; tags?: boolean }): Promise { + const scope = getLogScope(); + + try { + await this.git.pull(repoPath, { + rebase: options?.rebase, + tags: options?.tags, + }); + + this.container.events.fire('git:cache:reset', { repoPath: repoPath }); + } catch (ex) { + Logger.error(ex, scope); + if (!PullError.is(ex)) throw ex; + + void window.showErrorMessage(ex.message); } - this.container.events.fire('git:cache:reset', { repoPath: repoPath }); } private readonly toCanonicalMap = new Map(); private readonly fromCanonicalMap = new Map(); + protected readonly unsafePaths = new Set(); @gate() - @debug() + @debug({ exit: true }) async findRepositoryUri(uri: Uri, isDirectory?: boolean): Promise { const scope = getLogScope(); @@ -1059,7 +1593,13 @@ export class LocalGitProvider implements GitProvider, Disposable { uri = Uri.joinPath(uri, '..'); } - repoPath = await this.git.rev_parse__show_toplevel(uri.fsPath); + let safe; + [safe, repoPath] = await this.git.rev_parse__show_toplevel(uri.fsPath); + if (safe) { + this.unsafePaths.delete(uri.fsPath); + } else if (safe === false) { + this.unsafePaths.add(uri.fsPath); + } if (!repoPath) return undefined; const repoUri = Uri.file(repoPath); @@ -1078,10 +1618,14 @@ export class LocalGitProvider implements GitProvider, Disposable { ), ); if (networkPath != null) { + // If the repository is at the root of the mapped drive then we + // have to append `\` (ex: D:\) otherwise the path is not valid. + const isDriveRoot = pathEquals(repoUri.fsPath, networkPath); + repoPath = normalizePath( repoUri.fsPath.replace( networkPath, - `${letter.toLowerCase()}:${networkPath.endsWith('\\') ? '\\' : ''}`, + `${letter.toLowerCase()}:${isDriveRoot || networkPath.endsWith('\\') ? '\\' : ''}`, ), ); return Uri.file(repoPath); @@ -1105,7 +1649,7 @@ export class LocalGitProvider implements GitProvider, Disposable { return; } - if (equalsIgnoreCase(uri.fsPath, resolvedPath)) { + if (pathEquals(uri.fsPath, resolvedPath)) { Logger.debug(scope, `No symlink detected; repoPath=${repoPath}`); resolve([repoPath!, undefined]); return; @@ -1142,12 +1686,13 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @log({ args: { 1: refs => refs.join(',') } }) - getAheadBehindCommitCount( + @log() + getLeftRightCommitCount( repoPath: string, - refs: string[], - ): Promise<{ ahead: number; behind: number } | undefined> { - return this.git.rev_list__left_right(repoPath, refs); + range: GitRevisionRange, + options?: { authors?: GitUser[] | undefined; excludeMerges?: boolean }, + ): Promise { + return this.git.rev_list__left_right(repoPath, range, options?.authors, options?.excludeMerges); } @gate((u, d) => `${u.toString()}|${d?.isDirty}`) @@ -1162,7 +1707,7 @@ export class LocalGitProvider implements GitProvider, Disposable { key += `:${uri.sha}`; } - const doc = await this.container.tracker.getOrAdd(document ?? uri); + const doc = await this.container.documentTracker.getOrAdd(document ?? uri); if (this.useCaching) { if (doc.state != null) { const cachedBlame = doc.state.getBlame(key); @@ -1174,9 +1719,7 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache miss: '${key}'`); - if (doc.state == null) { - doc.state = new GitDocumentState(); - } + doc.state ??= new GitDocumentState(); } const promise = this.getBlameCore(uri, doc, key, scope); @@ -1195,11 +1738,11 @@ export class LocalGitProvider implements GitProvider, Disposable { private async getBlameCore( uri: GitUri, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, ): Promise { - const paths = await this.isTrackedPrivate(uri); + const paths = await this.isTrackedWithDetails(uri); if (paths == null) { Logger.log(scope, `Skipping blame; '${uri.fsPath}' is not tracked`); return emptyPromise as Promise; @@ -1208,25 +1751,42 @@ export class LocalGitProvider implements GitProvider, Disposable { const [relativePath, root] = paths; try { - const data = await this.git.blame(root, relativePath, uri.sha, { - args: configuration.get('advanced.blame.customArguments'), - ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), - }); - const blame = GitBlameParser.parse(this.container, data, root, await this.getCurrentUser(root)); + const [dataResult, userResult, statResult] = await Promise.allSettled([ + this.git.blame(root, relativePath, { + ref: uri.sha, + args: configuration.get('advanced.blame.customArguments'), + ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), + }), + this.getCurrentUser(root), + workspace.fs.stat(uri), + ]); + + const blame = parseGitBlame( + this.container, + root, + getSettledValue(dataResult), + getSettledValue(userResult), + getSettledValue(statResult)?.mtime, + ); return blame; } catch (ex) { + Logger.error(ex, scope); + // Trap and cache expected blame errors if (document.state != null) { const msg = ex?.toString() ?? ''; - Logger.debug(scope, `Cache replace (with empty promise): '${key}'`); + Logger.debug(scope, `Cache replace (with empty promise): '${key}'; reason=${msg}`); const value: CachedBlame = { item: emptyPromise as Promise, errorMessage: msg, }; document.state.setBlame(key, value); + document.setBlameFailure(ex); - document.setBlameFailure(); + if (ex instanceof BlameIgnoreRevsFileError || ex instanceof BlameIgnoreRevsFileBadRevisionError) { + void showBlameInvalidIgnoreRevsFileWarningMessage(ex); + } return emptyPromise as Promise; } @@ -1241,7 +1801,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const key = `blame:${md5(contents)}`; - const doc = await this.container.tracker.getOrAdd(uri); + const doc = await this.container.documentTracker.getOrAdd(uri); if (this.useCaching) { if (doc.state != null) { const cachedBlame = doc.state.getBlame(key); @@ -1253,9 +1813,7 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache miss: ${key}`); - if (doc.state == null) { - doc.state = new GitDocumentState(); - } + doc.state ??= new GitDocumentState(); } const promise = this.getBlameContentsCore(uri, contents, doc, key, scope); @@ -1275,11 +1833,11 @@ export class LocalGitProvider implements GitProvider, Disposable { private async getBlameContentsCore( uri: GitUri, contents: string, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, ): Promise { - const paths = await this.isTrackedPrivate(uri); + const paths = await this.isTrackedWithDetails(uri); if (paths == null) { Logger.log(scope, `Skipping blame; '${uri.fsPath}' is not tracked`); return emptyPromise as Promise; @@ -1288,26 +1846,44 @@ export class LocalGitProvider implements GitProvider, Disposable { const [relativePath, root] = paths; try { - const data = await this.git.blame__contents(root, relativePath, contents, { - args: configuration.get('advanced.blame.customArguments'), - correlationKey: `:${key}`, - ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), - }); - const blame = GitBlameParser.parse(this.container, data, root, await this.getCurrentUser(root)); + const [dataResult, userResult, statResult] = await Promise.allSettled([ + this.git.blame(root, relativePath, { + contents: contents, + args: configuration.get('advanced.blame.customArguments'), + correlationKey: `:${key}`, + ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), + }), + this.getCurrentUser(root), + workspace.fs.stat(uri), + ]); + + const blame = parseGitBlame( + this.container, + root, + getSettledValue(dataResult), + getSettledValue(userResult), + getSettledValue(statResult)?.mtime, + ); return blame; } catch (ex) { + Logger.error(ex, scope); + // Trap and cache expected blame errors if (document.state != null) { const msg = ex?.toString() ?? ''; - Logger.debug(scope, `Cache replace (with empty promise): '${key}'`); + Logger.debug(scope, `Cache replace (with empty promise): '${key}'; reason=${msg}`); const value: CachedBlame = { item: emptyPromise as Promise, errorMessage: msg, }; document.state.setBlame(key, value); + document.setBlameFailure(ex); + + if (ex instanceof BlameIgnoreRevsFileError || ex instanceof BlameIgnoreRevsFileBadRevisionError) { + void showBlameInvalidIgnoreRevsFileWarningMessage(ex); + } - document.setBlameFailure(); return emptyPromise as Promise; } @@ -1327,6 +1903,8 @@ export class LocalGitProvider implements GitProvider, Disposable { ): Promise { if (document?.isDirty) return this.getBlameForLineContents(uri, editorLine, document.getText(), options); + const scope = getLogScope(); + if (!options?.forceSingleLine && this.useCaching) { const blame = await this.getBlame(uri, document); if (blame == null) return undefined; @@ -1352,13 +1930,25 @@ export class LocalGitProvider implements GitProvider, Disposable { const [relativePath, root] = splitPath(uri, uri.repoPath); try { - const data = await this.git.blame(root, relativePath, uri.sha, { - args: configuration.get('advanced.blame.customArguments'), - ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), - startLine: lineToBlame, - endLine: lineToBlame, - }); - const blame = GitBlameParser.parse(this.container, data, root, await this.getCurrentUser(root)); + const [dataResult, userResult, statResult] = await Promise.allSettled([ + this.git.blame(root, relativePath, { + ref: uri.sha, + args: configuration.get('advanced.blame.customArguments'), + ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), + startLine: lineToBlame, + endLine: lineToBlame, + }), + this.getCurrentUser(root), + workspace.fs.stat(uri), + ]); + + const blame = parseGitBlame( + this.container, + root, + getSettledValue(dataResult), + getSettledValue(userResult), + getSettledValue(statResult)?.mtime, + ); if (blame == null) return undefined; return { @@ -1366,7 +1956,12 @@ export class LocalGitProvider implements GitProvider, Disposable { commit: first(blame.commits.values())!, line: blame.lines[editorLine], }; - } catch { + } catch (ex) { + Logger.error(ex, scope); + if (ex instanceof BlameIgnoreRevsFileError || ex instanceof BlameIgnoreRevsFileBadRevisionError) { + void showBlameInvalidIgnoreRevsFileWarningMessage(ex); + } + return undefined; } } @@ -1403,13 +1998,25 @@ export class LocalGitProvider implements GitProvider, Disposable { const [relativePath, root] = splitPath(uri, uri.repoPath); try { - const data = await this.git.blame__contents(root, relativePath, contents, { - args: configuration.get('advanced.blame.customArguments'), - ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), - startLine: lineToBlame, - endLine: lineToBlame, - }); - const blame = GitBlameParser.parse(this.container, data, root, await this.getCurrentUser(root)); + const [dataResult, userResult, statResult] = await Promise.allSettled([ + this.git.blame(root, relativePath, { + contents: contents, + args: configuration.get('advanced.blame.customArguments'), + ignoreWhitespace: configuration.get('blame.ignoreWhitespace'), + startLine: lineToBlame, + endLine: lineToBlame, + }), + this.getCurrentUser(root), + workspace.fs.stat(uri), + ]); + + const blame = parseGitBlame( + this.container, + root, + getSettledValue(dataResult), + getSettledValue(userResult), + getSettledValue(statResult)?.mtime, + ); if (blame == null) return undefined; return { @@ -1486,6 +2093,7 @@ export class LocalGitProvider implements GitProvider, Disposable { }; } + @gate() @log() async getBranch(repoPath: string): Promise { let { @@ -1500,13 +2108,16 @@ export class LocalGitProvider implements GitProvider, Disposable { const [name, upstream] = data[0].split('\n'); if (isDetachedHead(name)) { - const [rebaseStatus, committerDate] = await Promise.all([ + const [rebaseStatusResult, committerDateResult] = await Promise.allSettled([ this.getRebaseStatus(repoPath), - this.git.log__recent_committerdate(repoPath, commitOrdering), ]); + const committerDate = getSettledValue(committerDateResult); + const rebaseStatus = getSettledValue(rebaseStatusResult); + branch = new GitBranch( + this.container, repoPath, rebaseStatus?.incoming.name ?? name, false, @@ -1528,8 +2139,8 @@ export class LocalGitProvider implements GitProvider, Disposable { async getBranches( repoPath: string | undefined, options?: { - cursor?: string; filter?: (b: GitBranch) => boolean; + paging?: PagingOptions; sort?: boolean | BranchSortOptions; }, ): Promise> { @@ -1549,12 +2160,17 @@ export class LocalGitProvider implements GitProvider, Disposable { const data = await this.git.rev_parse__currentBranch(repoPath!, commitOrdering); if (data != null) { const [name, upstream] = data[0].split('\n'); - const [rebaseStatus, committerDate] = await Promise.all([ + + const [rebaseStatusResult, committerDateResult] = await Promise.allSettled([ isDetachedHead(name) ? this.getRebaseStatus(repoPath!) : undefined, this.git.log__recent_committerdate(repoPath!, commitOrdering), ]); + const committerDate = getSettledValue(committerDateResult); + const rebaseStatus = getSettledValue(rebaseStatusResult); + current = new GitBranch( + this.container, repoPath!, rebaseStatus?.incoming.name ?? name, false, @@ -1572,8 +2188,8 @@ export class LocalGitProvider implements GitProvider, Disposable { return current != null ? { values: [current] } : emptyPagedResult; } - return { values: GitBranchParser.parse(data, repoPath!) }; - } catch (ex) { + return { values: parseGitBranches(this.container, data, repoPath!) }; + } catch (_ex) { this._branchesCache.delete(repoPath!); return emptyPagedResult; @@ -1582,10 +2198,8 @@ export class LocalGitProvider implements GitProvider, Disposable { resultsPromise = load.call(this); - if (this.useCaching) { - if (options?.cursor == null) { - this._branchesCache.set(repoPath, resultsPromise); - } + if (this.useCaching && options?.paging?.cursor == null) { + this._branchesCache.set(repoPath, resultsPromise); } } @@ -1609,7 +2223,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const data = await this.git.diff__shortstat(repoPath, ref); if (!data) return undefined; - return GitDiffParser.parseShortStat(data); + return parseGitDiffShortStat(data); } @log() @@ -1623,26 +2237,28 @@ export class LocalGitProvider implements GitProvider, Disposable { @log() async getCommitBranches( repoPath: string, - ref: string, - options?: { branch?: string; commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, + refs: string[], + branch?: string | undefined, + options?: + | { all?: boolean; commitDate?: Date; mode?: 'contains' | 'pointsAt' } + | { commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, ): Promise { - if (options?.branch) { - const data = await this.git.branch__containsOrPointsAt(repoPath, ref, { + if (branch != null) { + const data = await this.git.branchOrTag__containsOrPointsAt(repoPath, refs, { + type: 'branch', mode: 'contains', - name: options.branch, + name: branch, }); - if (!data) return []; - - return [data?.trim()]; + return data ? [data?.trim()] : []; } - const data = await this.git.branch__containsOrPointsAt(repoPath, ref, options); + const data = await this.git.branchOrTag__containsOrPointsAt(repoPath, refs, { type: 'branch', ...options }); if (!data) return []; return filterMap(data.split('\n'), b => b.trim() || undefined); } - @log() + @log({ exit: true }) getCommitCount(repoPath: string, ref: string): Promise { return this.git.rev_list__count(repoPath, ref); } @@ -1670,7 +2286,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const commit = log.commits.get(options.ref); if (commit == null && !options?.firstIfNotFound) { // If the ref isn't a valid sha we will never find it, so let it fall through so we return the first - if (GitRevision.isSha(options.ref) || GitRevision.isUncommitted(options.ref)) return undefined; + if (isSha(options.ref) || isUncommitted(options.ref)) return undefined; } } @@ -1686,31 +2302,49 @@ export class LocalGitProvider implements GitProvider, Disposable { repoPath: string, asWebviewUri: (uri: Uri) => Uri, options?: { - branch?: string; include?: { stats?: boolean }; limit?: number; ref?: string; }, ): Promise { - const parser = getGraphParser(options?.include?.stats); - const refParser = getRefParser(); - const defaultLimit = options?.limit ?? configuration.get('graph.defaultItemLimit') ?? 5000; const defaultPageLimit = configuration.get('graph.pageItemLimit') ?? 1000; const ordering = configuration.get('graph.commitOrdering', undefined, 'date'); + const onlyFollowFirstParent = configuration.get('graph.onlyFollowFirstParent', undefined, false); - const [refResult, stashResult, branchesResult, remotesResult, currentUserResult] = await Promise.allSettled([ - this.git.log2(repoPath, undefined, ...refParser.arguments, '-n1', options?.ref ?? 'HEAD'), - this.getStash(repoPath), - this.getBranches(repoPath), - this.getRemotes(repoPath), - this.getCurrentUser(repoPath), - ]); + const deferStats = options?.include?.stats; // && defaultLimit > 1000; + + const parser = getGraphParser(options?.include?.stats && !deferStats); + const refParser = getRefParser(); + const statsParser = getGraphStatsParser(); + + const [refResult, stashResult, branchesResult, remotesResult, currentUserResult, worktreesResult] = + await Promise.allSettled([ + this.git.log(repoPath, undefined, ...refParser.arguments, '-n1', options?.ref ?? 'HEAD'), + this.getStash(repoPath), + this.getBranches(repoPath), + this.getRemotes(repoPath), + this.getCurrentUser(repoPath), + this.container.git + .getWorktrees(repoPath) + .then(w => [w, groupWorktreesByBranch(w, { includeDefault: true })]) satisfies Promise< + [GitWorktree[], Map] + >, + ]); const branches = getSettledValue(branchesResult)?.values; const branchMap = branches != null ? new Map(branches.map(r => [r.name, r])) : new Map(); const headBranch = branches?.find(b => b.current); const headRefUpstreamName = headBranch?.upstream?.name; + const [worktrees, worktreesByBranch] = getSettledValue(worktreesResult) ?? [[], new Map()]; + + let branchIdOfMainWorktree: string | undefined; + if (worktreesByBranch != null) { + branchIdOfMainWorktree = find(worktreesByBranch, ([, wt]) => wt.isDefault)?.[0]; + if (branchIdOfMainWorktree != null) { + worktreesByBranch.delete(branchIdOfMainWorktree); + } + } const currentUser = getSettledValue(currentUserResult); @@ -1718,12 +2352,17 @@ export class LocalGitProvider implements GitProvider, Disposable { const remoteMap = remotes != null ? new Map(remotes.map(r => [r.name, r])) : new Map(); const selectSha = first(refParser.parse(getSettledValue(refResult) ?? '')); + const downstreamMap = new Map(); + + let stashes: Map | undefined; let stdin: string | undefined; + // TODO@eamodio this is insanity -- there *HAS* to be a better way to get git log to return stashes const stash = getSettledValue(stashResult); - if (stash != null && stash.commits.size !== 0) { + if (stash?.commits.size) { + stashes = new Map(stash.commits); stdin = join( - map(stash.commits.values(), c => c.sha.substring(0, 9)), + map(stashes.values(), c => c.sha.substring(0, 9)), '\n', ); } @@ -1733,9 +2372,10 @@ export class LocalGitProvider implements GitProvider, Disposable { const avatars = new Map(); const ids = new Set(); const reachableFromHEAD = new Set(); - const skippedIds = new Set(); + const remappedIds = new Map(); let total = 0; let iterations = 0; + let pendingRowsStatsCount = 0; async function getCommitsForGraphCore( this: LocalGitProvider, @@ -1743,6 +2383,8 @@ export class LocalGitProvider implements GitProvider, Disposable { sha?: string, cursor?: { sha: string; skip: number }, ): Promise { + const startTotal = total; + iterations++; let log: string | string[] | undefined; @@ -1751,6 +2393,9 @@ export class LocalGitProvider implements GitProvider, Disposable { do { const args = [...parser.arguments, `--${ordering}-order`, '--all']; + if (onlyFollowFirstParent) { + args.push('--first-parent'); + } if (cursor?.skip) { args.push(`--skip=${cursor.skip}`); } @@ -1767,7 +2412,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } else { args.push(`-n${nextPageLimit + 1}`); - data = await this.git.log2(repoPath, stdin ? { stdin: stdin } : undefined, ...args); + data = await this.git.log(repoPath, stdin ? { stdin: stdin } : undefined, ...args); if (cursor) { if (!getShaInLogRegex(cursor.sha).test(data)) { @@ -1780,6 +2425,10 @@ export class LocalGitProvider implements GitProvider, Disposable { includes: options?.include, branches: branchMap, remotes: remoteMap, + downstreams: downstreamMap, + stashes: stashes, + worktrees: worktrees, + worktreesByBranch: worktreesByBranch, rows: [], }; } @@ -1801,6 +2450,10 @@ export class LocalGitProvider implements GitProvider, Disposable { includes: options?.include, branches: branchMap, remotes: remoteMap, + downstreams: downstreamMap, + stashes: stashes, + worktrees: worktrees, + worktreesByBranch: worktreesByBranch, rows: [], }; } @@ -1841,10 +2494,11 @@ export class LocalGitProvider implements GitProvider, Disposable { let refTags: GitGraphRowTag[]; let parent: string; let parents: string[]; - let remote: GitRemote | undefined; + let remote: GitRemote | undefined; let remoteBranchId: string; let remoteName: string; let stashCommit: GitStashCommit | undefined; + let stats: GitGraphRowsStats | undefined; let tagId: string; let tagName: string; let tip: string; @@ -1857,7 +2511,7 @@ export class LocalGitProvider implements GitProvider, Disposable { if (ids.has(commit.sha)) continue; total++; - if (skippedIds.has(commit.sha)) continue; + if (remappedIds.has(commit.sha)) continue; ids.add(commit.sha); @@ -1865,12 +2519,12 @@ export class LocalGitProvider implements GitProvider, Disposable { refRemoteHeads = []; refTags = []; contexts = {}; - head = false; if (commit.tips) { groupedRefs.clear(); for (tip of commit.tips.split(', ')) { + head = false; if (tip === 'refs/stash') continue; if (tip.startsWith('tag: ')) { @@ -1880,7 +2534,7 @@ export class LocalGitProvider implements GitProvider, Disposable { webviewItem: 'gitlens:tag', webviewItemValue: { type: 'tag', - ref: GitReference.create(tagName, repoPath, { + ref: createReference(tagName, repoPath, { id: tagId, refType: 'tag', name: tagName, @@ -1893,15 +2547,16 @@ export class LocalGitProvider implements GitProvider, Disposable { name: tagName, // Not currently used, so don't bother looking it up annotated: true, - context: serializeWebviewItemContext(context), + context: + serializeWebviewItemContext>(context), }; refTags.push(refTag); continue; } - head = tip.startsWith('HEAD'); - if (head) { + if (tip.startsWith('HEAD')) { + head = true; reachableFromHEAD.add(commit.sha); if (tip !== 'HEAD') { @@ -1925,7 +2580,7 @@ export class LocalGitProvider implements GitProvider, Disposable { webviewItem: 'gitlens:branch+remote', webviewItemValue: { type: 'branch', - ref: GitReference.create(tip, repoPath, { + ref: createReference(tip, repoPath, { id: remoteBranchId, refType: 'branch', name: tip, @@ -1941,8 +2596,12 @@ export class LocalGitProvider implements GitProvider, Disposable { owner: remote.name, url: remote.url, avatarUrl: avatarUrl, - context: serializeWebviewItemContext(context), + context: + serializeWebviewItemContext>( + context, + ), current: tip === headRefUpstreamName, + hostingServiceType: remote.provider?.gkProviderId, }; refRemoteHeads.push(refRemoteHead); @@ -1965,10 +2624,16 @@ export class LocalGitProvider implements GitProvider, Disposable { context = { webviewItem: `gitlens:branch${head ? '+current' : ''}${ branch?.upstream != null ? '+tracking' : '' + }${ + worktreesByBranch?.has(branchId) + ? '+worktree' + : branchIdOfMainWorktree === branchId + ? '+checkedout' + : '' }`, webviewItemValue: { type: 'branch', - ref: GitReference.create(tip, repoPath, { + ref: createReference(tip, repoPath, { id: branchId, refType: 'branch', name: tip, @@ -1978,14 +2643,32 @@ export class LocalGitProvider implements GitProvider, Disposable { }, }; + const worktree = worktreesByBranch?.get(branchId); refHead = { id: branchId, name: tip, isCurrentHead: head, - context: serializeWebviewItemContext(context), - upstream: branch?.upstream?.name, + context: serializeWebviewItemContext>(context), + upstream: + branch?.upstream != null + ? { + name: branch.upstream.name, + id: getBranchId(repoPath, true, branch.upstream.name), + } + : undefined, + worktreeId: worktree != null ? getWorktreeId(repoPath, worktree.name) : undefined, }; refHeads.push(refHead); + if (branch?.upstream?.name != null) { + // Add the branch name (tip) to the upstream name entry in the downstreams map + let downstreams = downstreamMap.get(branch.upstream.name); + if (downstreams == null) { + downstreams = []; + downstreamMap.set(branch.upstream.name, downstreams); + } + + downstreams.push(tip); + } group = groupedRefs.get(tip); if (group == null) { @@ -2029,10 +2712,10 @@ export class LocalGitProvider implements GitProvider, Disposable { // Remove the second & third parent, if exists, from each stash commit as it is a Git implementation for the index and untracked files if (stashCommit != null && parents.length > 1) { - // Skip the "index commit" (e.g. contains staged files) of the stash - skippedIds.add(parents[1]); - // Skip the "untracked commit" (e.g. contains untracked files) of the stash - skippedIds.add(parents[2]); + // Remap the "index commit" (e.g. contains staged files) of the stash + remappedIds.set(parents[1], commit.sha); + // Remap the "untracked commit" (e.g. contains untracked files) of the stash + remappedIds.set(parents[2], commit.sha); parents.splice(1, 2); } @@ -2050,9 +2733,10 @@ export class LocalGitProvider implements GitProvider, Disposable { webviewItem: 'gitlens:stash', webviewItemValue: { type: 'stash', - ref: GitReference.create(commit.sha, repoPath, { + ref: createReference(commit.sha, repoPath, { refType: 'stash', name: stashCommit.name, + message: stashCommit.message, number: stashCommit.number, }), }, @@ -2064,7 +2748,7 @@ export class LocalGitProvider implements GitProvider, Disposable { }`, webviewItemValue: { type: 'commit', - ref: GitReference.create(commit.sha, repoPath, { + ref: createReference(commit.sha, repoPath, { refType: 'revision', message: commit.message, }), @@ -2085,24 +2769,25 @@ export class LocalGitProvider implements GitProvider, Disposable { rows.push({ sha: commit.sha, - parents: parents, + parents: onlyFollowFirstParent ? [parents[0]] : parents, author: isCurrentUser ? 'You' : commit.author, email: commit.authorEmail, date: Number(ordering === 'author-date' ? commit.authorDate : commit.committerDate) * 1000, message: emojify(commit.message.trim()), // TODO: review logic for stash, wip, etc - type: - stashCommit != null - ? GitGraphRowType.Stash - : parents.length > 1 - ? GitGraphRowType.MergeCommit - : GitGraphRowType.Commit, + type: stashCommit != null ? 'stash-node' : parents.length > 1 ? 'merge-node' : 'commit-node', heads: refHeads, remotes: refRemoteHeads, tags: refTags, contexts: contexts, - stats: commit.stats, }); + + if (commit.stats != null) { + if (stats == null) { + stats = new Map(); + } + stats.set(commit.sha, commit.stats); + } } const startingCursor = cursor?.sha; @@ -2115,16 +2800,60 @@ export class LocalGitProvider implements GitProvider, Disposable { } : undefined; + let rowsStatsDeferred: GitGraph['rowsStatsDeferred']; + + if (deferStats) { + if (stats == null) { + stats = new Map(); + } + pendingRowsStatsCount++; + + // eslint-disable-next-line no-async-promise-executor + const promise = new Promise(async resolve => { + try { + const args = [...statsParser.arguments]; + if (startTotal === 0) { + args.push(`-n${total}`); + } else { + args.push(`-n${total - startTotal}`, `--skip=${startTotal}`); + } + args.push(`--${ordering}-order`, '--all'); + + const statsData = await this.git.log(repoPath, stdin ? { stdin: stdin } : undefined, ...args); + if (statsData) { + const commitStats = statsParser.parse(statsData); + for (const stat of commitStats) { + stats!.set(stat.sha, stat.stats); + } + } + } finally { + pendingRowsStatsCount--; + resolve(); + } + }); + + rowsStatsDeferred = { + isLoaded: () => pendingRowsStatsCount === 0, + promise: promise, + }; + } + return { repoPath: repoPath, avatars: avatars, ids: ids, includes: options?.include, - skippedIds: skippedIds, + remappedIds: remappedIds, branches: branchMap, remotes: remoteMap, + downstreams: downstreamMap, + stashes: stashes, + worktrees: worktrees, + worktreesByBranch: worktreesByBranch, rows: rows, id: sha, + rowsStats: stats, + rowsStatsDeferred: rowsStatsDeferred, paging: { limit: limit === 0 ? count : limit, @@ -2139,24 +2868,47 @@ export class LocalGitProvider implements GitProvider, Disposable { return getCommitsForGraphCore.call(this, defaultLimit, selectSha); } - getConfig(repoPath: string, key: string): Promise { + @log() + async getCommitTags( + repoPath: string, + ref: string, + options?: { commitDate?: Date; mode?: 'contains' | 'pointsAt' }, + ): Promise { + const data = await this.git.branchOrTag__containsOrPointsAt(repoPath, [ref], { type: 'tag', ...options }); + if (!data) return []; + + return filterMap(data.split('\n'), b => b.trim() || undefined); + } + + getConfig(repoPath: string, key: GitConfigKeys): Promise { return this.git.config__get(key, repoPath); } - setConfig(repoPath: string, key: string, value: string | undefined): Promise { + setConfig(repoPath: string, key: GitConfigKeys, value: string | undefined): Promise { return this.git.config__set(key, value, repoPath); } @log() async getContributors( repoPath: string, - options?: { all?: boolean; ref?: string; stats?: boolean }, + options?: { all?: boolean; merges?: boolean | 'first-parent'; ref?: string; stats?: boolean }, ): Promise { if (repoPath == null) return []; - const key = options?.stats ? `stats|${repoPath}` : repoPath; + let key = options?.ref ?? ''; + if (options?.all) { + key += ':all'; + } + if (options?.merges) { + key += `:merges:${options.merges}`; + } + if (options?.stats) { + key += ':stats'; + } + + const contributorsCache = this.useCaching ? this._contributorsCache.get(repoPath) : undefined; - let contributors = this.useCaching ? this._contributorsCache.get(key) : undefined; + let contributors = contributorsCache?.get(key); if (contributors == null) { async function load(this: LocalGitProvider) { try { @@ -2164,10 +2916,20 @@ export class LocalGitProvider implements GitProvider, Disposable { const currentUser = await this.getCurrentUser(repoPath); const parser = getContributorsParser(options?.stats); - const data = await this.git.log(repoPath, options?.ref, { - all: options?.all, - argsOrFormat: parser.arguments, - }); + const args = [...parser.arguments, '--full-history', '--use-mailmap']; + + const merges = options?.merges ?? true; + if (merges) { + args.push(merges === 'first-parent' ? '--first-parent' : '--no-min-parents'); + } else { + args.push('--no-merges'); + } + + if (options?.all) { + args.push('--all', '--single-worktree'); + } + + const data = await this.git.log(repoPath, { ref: options?.ref }, ...args); const contributors = new Map(); @@ -2196,8 +2958,8 @@ export class LocalGitProvider implements GitProvider, Disposable { } return [...contributors.values()]; - } catch (ex) { - this._contributorsCache.delete(key); + } catch (_ex) { + contributorsCache?.delete(key); return []; } @@ -2206,7 +2968,11 @@ export class LocalGitProvider implements GitProvider, Disposable { contributors = load.call(this); if (this.useCaching) { - this._contributorsCache.set(key, contributors); + if (contributorsCache == null) { + this._contributorsCache.set(repoPath, new Map([[key, contributors]])); + } else { + contributorsCache.set(key, contributors); + } } } @@ -2242,7 +3008,7 @@ export class LocalGitProvider implements GitProvider, Disposable { [, key, value] = match; // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - user[key as 'name' | 'email'] = ` ${value}`.substr(1); + user[key as 'name' | 'email'] = ` ${value}`.substring(1); } while (true); } else { user.name = @@ -2282,34 +3048,188 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @log() + @log({ exit: true }) + async getBaseBranchName(repoPath: string, ref: string): Promise { + const mergeBaseConfigKey: GitConfigKeys = `branch.${ref}.gk-merge-base`; + + try { + const pattern = `^branch\\.${ref}\\.`; + const data = await this.git.config__get_regex(pattern, repoPath); + if (data) { + const regex = new RegExp(`${pattern}(.+) (.+)$`, 'gm'); + + let mergeBase: string | undefined; + let update = false; + while (true) { + const match = regex.exec(data); + if (match == null) break; + + const [, key, value] = match; + if (key === 'gk-merge-base') { + mergeBase = value; + update = false; + break; + } else if (key === 'vscode-merge-base') { + mergeBase = value; + update = true; + continue; + } + } + + if (mergeBase != null) { + const [branch] = (await this.getBranches(repoPath, { filter: b => b.name === mergeBase })).values; + if (branch != null) { + if (update) { + void this.setConfig(repoPath, mergeBaseConfigKey, branch.name); + } + return branch.name; + } + } + } + } catch {} + + const branch = await this.getBaseBranchFromReflog(repoPath, ref); + if (branch?.upstream != null) { + void this.setConfig(repoPath, mergeBaseConfigKey, branch.upstream.name); + return branch.upstream.name; + } + + return undefined; + } + + private async getBaseBranchFromReflog(repoPath: string, ref: string): Promise { + try { + let data = await this.git.reflog(repoPath, undefined, ref, '--grep-reflog=branch: Created from *.'); + + let entries = data.split('\n').filter(entry => Boolean(entry)); + if (entries.length !== 1) return undefined; + + // Check if branch created from an explicit branch + let match = entries[0].match(/branch: Created from (.*)$/); + if (match != null && match.length === 2) { + const name = match[1]; + if (name !== 'HEAD') { + const [branch] = (await this.getBranches(repoPath, { filter: b => b.name === name })).values; + return branch; + } + } + + // Check if branch was created from HEAD + data = await this.git.reflog( + repoPath, + undefined, + 'HEAD', + `--grep-reflog=checkout: moving from .* to ${ref.replace('refs/heads/', '')}`, + ); + entries = data.split('\n').filter(entry => Boolean(entry)); + + if (!entries.length) return undefined; + + match = entries[entries.length - 1].match(/checkout: moving from ([^\s]+)\s/); + if (match != null && match.length === 2) { + const name = match[1]; + const [branch] = (await this.getBranches(repoPath, { filter: b => b.name === name })).values; + return branch; + } + } catch {} + + return undefined; + } + + @log({ exit: true }) async getDefaultBranchName(repoPath: string | undefined, remote?: string): Promise { if (repoPath == null) return undefined; - if (!remote) { + if (remote) { try { - const data = await this.git.symbolic_ref(repoPath, 'HEAD'); - if (data != null) return data.trim(); + const data = await this.git.ls_remote__HEAD(repoPath, remote); + if (data == null) return undefined; + + const match = /ref:\s(\S+)\s+HEAD/m.exec(data); + if (match == null) return undefined; + + const [, branch] = match; + return `${remote}/${branch.substring('refs/heads/'.length)}`; } catch {} } - remote = remote ?? 'origin'; try { - const data = await this.git.ls_remote__HEAD(repoPath, remote); - if (data == null) return undefined; + const data = await this.git.symbolic_ref(repoPath, `refs/remotes/origin/HEAD`); + if (data != null) return data.trim(); + } catch {} - const match = /ref:\s(\S+)\s+HEAD/m.exec(data); - if (match == null) return undefined; + return undefined; + } - const [, branch] = match; - return branch.substr('refs/heads/'.length); - } catch { + @log() + async getDiff( + repoPath: string, + to: string, + from?: string, + options?: { context?: number; uris?: Uri[] }, + ): Promise { + const scope = getLogScope(); + const params = [`-U${options?.context ?? 3}`]; + + if (to === uncommitted) { + if (from != null) { + params.push(from); + } else { + // Get only unstaged changes + from = 'HEAD'; + } + } else if (to === uncommittedStaged) { + params.push('--staged'); + if (from != null) { + params.push(from); + } else { + // Get only staged changes + from = 'HEAD'; + } + } else if (from == null) { + if (to === '' || to.toUpperCase() === 'HEAD') { + from = 'HEAD'; + params.push(from); + } else { + from = `${to}^`; + params.push(from, to); + } + } else if (to === '') { + params.push(from); + } else { + params.push(from, to); + } + + if (options?.uris) { + params.push('--', ...options.uris.map(u => u.fsPath)); + } + + let data; + try { + data = await this.git.diff2(repoPath, { errors: GitErrorHandling.Throw }, ...params); + } catch (ex) { + debugger; + Logger.error(ex, scope); return undefined; } + + const diff: GitDiff = { contents: data, from: from, to: to }; + return diff; + } + + @log({ args: { 1: false } }) + async getDiffFiles(repoPath: string, contents: string): Promise { + const data = await this.git.apply2(repoPath, { stdin: contents }, '--numstat', '--summary', '-z'); + if (!data) return undefined; + + const files = parseGitApplyFiles(data, repoPath); + return { + files: files, + }; } @log() - async getDiffForFile(uri: GitUri, ref1: string | undefined, ref2?: string): Promise { + async getDiffForFile(uri: GitUri, ref1: string | undefined, ref2?: string): Promise { const scope = getLogScope(); let key = 'diff'; @@ -2320,7 +3240,7 @@ export class LocalGitProvider implements GitProvider, Disposable { key += `:${ref2}`; } - const doc = await this.container.tracker.getOrAdd(uri); + const doc = await this.container.documentTracker.getOrAdd(uri); if (this.useCaching) { if (doc.state != null) { const cachedDiff = doc.state.getDiff(key); @@ -2332,17 +3252,16 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache miss: '${key}'`); - if (doc.state == null) { - doc.state = new GitDocumentState(); - } + doc.state ??= new GitDocumentState(); } + const encoding = await getEncoding(uri); const promise = this.getDiffForFileCore( uri.repoPath, uri.fsPath, ref1, ref2, - { encoding: getEncoding(uri) }, + { encoding: encoding }, doc, key, scope, @@ -2352,7 +3271,7 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache add: '${key}'`); const value: CachedDiff = { - item: promise as Promise, + item: promise as Promise, }; doc.state.setDiff(key, value); } @@ -2366,10 +3285,10 @@ export class LocalGitProvider implements GitProvider, Disposable { ref1: string | undefined, ref2: string | undefined, options: { encoding?: string }, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, - ): Promise { + ): Promise { const [relativePath, root] = splitPath(path, repoPath); try { @@ -2381,7 +3300,7 @@ export class LocalGitProvider implements GitProvider, Disposable { similarityThreshold: configuration.get('advanced.similarityThreshold'), }); - const diff = GitDiffParser.parse(data); + const diff = parseGitFileDiff(data); return diff; } catch (ex) { // Trap and cache expected diff errors @@ -2390,12 +3309,12 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache replace (with empty promise): '${key}'`); const value: CachedDiff = { - item: emptyPromise as Promise, + item: emptyPromise as Promise, errorMessage: msg, }; document.state.setDiff(key, value); - return emptyPromise as Promise; + return emptyPromise as Promise; } return undefined; @@ -2403,12 +3322,12 @@ export class LocalGitProvider implements GitProvider, Disposable { } @log({ args: { 1: '' } }) - async getDiffForFileContents(uri: GitUri, ref: string, contents: string): Promise { + async getDiffForFileContents(uri: GitUri, ref: string, contents: string): Promise { const scope = getLogScope(); const key = `diff:${md5(contents)}`; - const doc = await this.container.tracker.getOrAdd(uri); + const doc = await this.container.documentTracker.getOrAdd(uri); if (this.useCaching) { if (doc.state != null) { const cachedDiff = doc.state.getDiff(key); @@ -2420,17 +3339,16 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache miss: ${key}`); - if (doc.state == null) { - doc.state = new GitDocumentState(); - } + doc.state ??= new GitDocumentState(); } + const encoding = await getEncoding(uri); const promise = this.getDiffForFileContentsCore( uri.repoPath, uri.fsPath, ref, contents, - { encoding: getEncoding(uri) }, + { encoding: encoding }, doc, key, scope, @@ -2440,7 +3358,7 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache add: '${key}'`); const value: CachedDiff = { - item: promise as Promise, + item: promise as Promise, }; doc.state.setDiff(key, value); } @@ -2454,10 +3372,10 @@ export class LocalGitProvider implements GitProvider, Disposable { ref: string, contents: string, options: { encoding?: string }, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, - ): Promise { + ): Promise { const [relativePath, root] = splitPath(path, repoPath); try { @@ -2467,7 +3385,7 @@ export class LocalGitProvider implements GitProvider, Disposable { similarityThreshold: configuration.get('advanced.similarityThreshold'), }); - const diff = GitDiffParser.parse(data); + const diff = parseGitFileDiff(data); return diff; } catch (ex) { // Trap and cache expected diff errors @@ -2476,12 +3394,12 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache replace (with empty promise): '${key}'`); const value: CachedDiff = { - item: emptyPromise as Promise, + item: emptyPromise as Promise, errorMessage: msg, }; document.state.setDiff(key, value); - return emptyPromise as Promise; + return emptyPromise as Promise; } return undefined; @@ -2494,7 +3412,7 @@ export class LocalGitProvider implements GitProvider, Disposable { editorLine: number, // 0-based, Git is 1-based ref1: string | undefined, ref2?: string, - ): Promise { + ): Promise { try { const diff = await this.getDiffForFile(uri, ref1, ref2); if (diff == null) return undefined; @@ -2503,8 +3421,14 @@ export class LocalGitProvider implements GitProvider, Disposable { const hunk = diff.hunks.find(c => c.current.position.start <= line && c.current.position.end >= line); if (hunk == null) return undefined; - return hunk.lines[line - Math.min(hunk.current.position.start, hunk.previous.position.start)]; - } catch (ex) { + const hunkLine = hunk.lines.get(line); + if (hunkLine == null) return undefined; + + return { + hunk: hunk, + line: hunkLine, + }; + } catch (_ex) { return undefined; } } @@ -2512,40 +3436,49 @@ export class LocalGitProvider implements GitProvider, Disposable { @log() async getDiffStatus( repoPath: string, - ref1?: string, + ref1OrRange: string | GitRevisionRange, ref2?: string, - options?: { filters?: GitDiffFilter[]; similarityThreshold?: number }, + options?: { filters?: GitDiffFilter[]; path?: string; similarityThreshold?: number }, ): Promise { try { - const data = await this.git.diff__name_status(repoPath, ref1, ref2, { - similarityThreshold: configuration.get('advanced.similarityThreshold'), + const data = await this.git.diff__name_status(repoPath, ref1OrRange, ref2, { + similarityThreshold: configuration.get('advanced.similarityThreshold') ?? undefined, ...options, }); if (!data) return undefined; - const files = GitDiffParser.parseNameStatus(data, repoPath); + const files = parseGitDiffNameStatusFiles(data, repoPath); return files == null || files.length === 0 ? undefined : files; - } catch (ex) { + } catch (_ex) { return undefined; } } @log() async getFileStatusForCommit(repoPath: string, uri: Uri, ref: string): Promise { - if (ref === GitRevision.deletedOrMissing || GitRevision.isUncommitted(ref)) return undefined; + if (ref === deletedOrMissing || isUncommitted(ref)) return undefined; const [relativePath, root] = splitPath(uri, repoPath); const data = await this.git.show__name_status(root, relativePath, ref); if (!data) return undefined; - const files = GitDiffParser.parseNameStatus(data, repoPath); + const files = parseGitDiffNameStatusFiles(data, repoPath); if (files == null || files.length === 0) return undefined; return files[0]; } - @debug() + @log({ exit: true }) + async getFirstCommitSha(repoPath: string): Promise { + const data = await this.git.rev_list(repoPath, 'HEAD', { maxParents: 0 }); + return data?.[0]; + } + + @gate() + @debug({ + exit: r => `returned ${r.uri.toString(true)}, commonUri=${r.commonUri?.toString(true)}`, + }) async getGitDir(repoPath: string): Promise { const repo = this._repoInfoCache.get(repoPath); if (repo?.gitDir != null) return repo.gitDir; @@ -2588,7 +3521,7 @@ export class LocalGitProvider implements GitProvider, Disposable { authors?: GitUser[]; cursor?: string; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; status?: null | 'name-status' | 'numstat' | 'stat'; @@ -2602,14 +3535,10 @@ export class LocalGitProvider implements GitProvider, Disposable { try { const limit = options?.limit ?? configuration.get('advanced.maxListItems') ?? 0; - const merges = options?.merges == null ? true : options.merges; - const ordering = options?.ordering ?? configuration.get('advanced.commitOrdering'); const similarityThreshold = configuration.get('advanced.similarityThreshold'); - const args = [ - `--format=${options?.all ? GitLogParser.allFormat : GitLogParser.defaultFormat}`, + `--format=${options?.all ? parseGitLogAllFormat : parseGitLogDefaultFormat}`, `-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`, - '-m', ]; if (options?.status !== null) { @@ -2618,9 +3547,19 @@ export class LocalGitProvider implements GitProvider, Disposable { if (options?.all) { args.push('--all'); } - if (!merges) { - args.push('--first-parent'); + + const merges = options?.merges ?? true; + if (merges) { + if (limit <= 2) { + // Ensure we return the merge commit files when we are asking for a specific ref + args.push('-m'); + } + args.push(merges === 'first-parent' ? '--first-parent' : '--no-min-parents'); + } else { + args.push('--no-merges'); } + + const ordering = options?.ordering ?? configuration.get('advanced.commitOrdering'); if (ordering) { args.push(`--${ordering}-order`); } @@ -2654,7 +3593,7 @@ export class LocalGitProvider implements GitProvider, Disposable { args.push(`-n${limit + 1}`); } - const data = await this.git.log2( + const data = await this.git.log( repoPath, { configs: gitLogDefaultConfigsWithFiles, ref: options?.ref, stdin: options?.stdin }, ...args, @@ -2697,7 +3636,7 @@ export class LocalGitProvider implements GitProvider, Disposable { // ); // } - const log = GitLogParser.parse( + const log = parseGitLog( this.container, data, LogType.Log, @@ -2708,6 +3647,8 @@ export class LocalGitProvider implements GitProvider, Disposable { limit, false, undefined, + undefined, + undefined, hasMoreOverride, ); @@ -2716,8 +3657,8 @@ export class LocalGitProvider implements GitProvider, Disposable { if (log.hasMore) { let opts; if (options != null) { - let extraArgs; - ({ extraArgs, ...opts } = options); + let _; + ({ extraArgs: _, ...opts } = options); } log.more = this.getLogMoreFn(log, opts); } @@ -2738,7 +3679,7 @@ export class LocalGitProvider implements GitProvider, Disposable { authors?: GitUser[]; cursor?: string; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; since?: string; @@ -2750,16 +3691,36 @@ export class LocalGitProvider implements GitProvider, Disposable { try { const parser = createLogParserSingle('%H'); + const args = [...parser.arguments, '--full-history']; + + const ordering = options?.ordering ?? configuration.get('advanced.commitOrdering'); + if (ordering) { + args.push(`--${ordering}-order`); + } + + if (limit) { + args.push(`-n${limit + 1}`); + } + + if (options?.since) { + args.push(`--since="${options.since}"`); + } + + const merges = options?.merges ?? true; + if (merges) { + args.push(merges === 'first-parent' ? '--first-parent' : '--no-min-parents'); + } else { + args.push('--no-merges'); + } - const data = await this.git.log(repoPath, options?.ref, { - authors: options?.authors, - argsOrFormat: parser.arguments, - limit: limit, - merges: options?.merges == null ? true : options.merges, - similarityThreshold: configuration.get('advanced.similarityThreshold'), - since: options?.since, - ordering: options?.ordering ?? configuration.get('advanced.commitOrdering'), - }); + if (options?.authors?.length) { + if (!args.includes('--use-mailmap')) { + args.push('--use-mailmap'); + } + args.push(...options.authors.map(a => `--author=^${a.name} <${a.email}>$`)); + } + + const data = await this.git.log(repoPath, { ref: options?.ref }, ...args); const commits = new Set(parser.parse(data)); return commits; @@ -2792,7 +3753,7 @@ export class LocalGitProvider implements GitProvider, Disposable { moreLimit = moreLimit ?? configuration.get('advanced.maxSearchItems') ?? 0; // If the log is for a range, then just get everything prior + more - if (GitRevision.isRange(log.sha)) { + if (isRevisionRange(log.sha)) { const moreLog = await this.getLog(log.repoPath, { ...options, limit: moreLimit === 0 ? 0 : (options?.limit ?? 0) + moreLimit, @@ -2904,47 +3865,58 @@ export class LocalGitProvider implements GitProvider, Disposable { throw new Error(`File name cannot match the repository path; path=${relativePath}`); } - options = { reverse: false, ...options }; + const opts: typeof options & Parameters[2] = { + reverse: false, + ...options, + }; + + if (opts.renames == null) { + opts.renames = configuration.get('advanced.fileHistoryFollowsRenames'); + } - if (options.renames == null) { - options.renames = configuration.get('advanced.fileHistoryFollowsRenames'); + if (opts.merges == null) { + opts.merges = configuration.get('advanced.fileHistoryShowMergeCommits'); } let key = 'log'; - if (options.ref != null) { - key += `:${options.ref}`; + if (opts.ref != null) { + key += `:${opts.ref}`; } - if (options.all == null) { - options.all = configuration.get('advanced.fileHistoryShowAllBranches'); + if (opts.all == null) { + opts.all = configuration.get('advanced.fileHistoryShowAllBranches'); } - if (options.all) { + if (opts.all) { key += ':all'; } - options.limit = options.limit ?? configuration.get('advanced.maxListItems') ?? 0; - if (options.limit) { - key += `:n${options.limit}`; + opts.limit = opts.limit ?? configuration.get('advanced.maxListItems') ?? 0; + if (opts.limit) { + key += `:n${opts.limit}`; + } + + if (opts.merges) { + key += ':merges'; } - if (options.renames) { + if (opts.renames) { key += ':follow'; } - if (options.reverse) { + if (opts.reverse) { key += ':reverse'; } - if (options.since) { - key += `:since=${options.since}`; + if (opts.since) { + key += `:since=${opts.since}`; } - if (options.skip) { - key += `:skip${options.skip}`; + if (opts.skip) { + key += `:skip${opts.skip}`; } - const doc = await this.container.tracker.getOrAdd(GitUri.fromFile(relativePath, repoPath, options.ref)); - if (!options.force && this.useCaching && options.range == null) { + const doc = await this.container.documentTracker.getOrAdd(GitUri.fromFile(relativePath, repoPath, opts.ref)); + if (!opts.force && this.useCaching && opts.range == null) { if (doc.state != null) { const cachedLog = doc.state.getLog(key); if (cachedLog != null) { @@ -2952,20 +3924,20 @@ export class LocalGitProvider implements GitProvider, Disposable { return cachedLog.item; } - if (options.ref != null || options.limit != null) { + if (opts.ref != null || (opts.limit != null && opts.limit !== 0)) { // Since we are looking for partial log, see if we have the log of the whole file const cachedLog = doc.state.getLog( - `log${options.renames ? ':follow' : ''}${options.reverse ? ':reverse' : ''}`, + `log${opts.renames ? ':follow' : ''}${opts.reverse ? ':reverse' : ''}`, ); if (cachedLog != null) { - if (options.ref == null) { + if (opts.ref == null) { Logger.debug(scope, `Cache hit: ~'${key}'`); return cachedLog.item; } Logger.debug(scope, `Cache ?: '${key}'`); let log = await cachedLog.item; - if (log != null && !log.hasMore && log.commits.has(options.ref)) { + if (log != null && !log.hasMore && log.commits.has(opts.ref)) { Logger.debug(scope, `Cache hit: '${key}'`); // Create a copy of the log starting at the requested commit @@ -2976,12 +3948,12 @@ export class LocalGitProvider implements GitProvider, Disposable { log.commits.entries(), ([ref, c]) => { if (skip) { - if (ref !== options?.ref) return undefined; + if (ref !== opts?.ref) return undefined; skip = false; } i++; - if (options?.limit != null && i > options.limit) { + if (opts?.limit != null && i > opts.limit) { return undefined; } @@ -2990,14 +3962,14 @@ export class LocalGitProvider implements GitProvider, Disposable { ), ); - const opts = { ...options }; + const optsCopy = { ...opts }; log = { ...log, - limit: options.limit, + limit: optsCopy.limit, count: commits.size, commits: commits, query: (limit: number | undefined) => - this.getLogForFile(repoPath, pathOrUri, { ...opts, limit: limit }), + this.getLogForFile(repoPath, pathOrUri, { ...optsCopy, limit: limit }), }; return log; @@ -3008,14 +3980,12 @@ export class LocalGitProvider implements GitProvider, Disposable { Logger.debug(scope, `Cache miss: '${key}'`); - if (doc.state == null) { - doc.state = new GitDocumentState(); - } + doc.state ??= new GitDocumentState(); } - const promise = this.getLogForFileCore(repoPath, relativePath, options, doc, key, scope); + const promise = this.getLogForFileCore(repoPath, relativePath, opts, doc, key, scope); - if (doc.state != null && options.range == null) { + if (doc.state != null && opts.range == null) { Logger.debug(scope, `Cache add: '${key}'`); const value: CachedLog = { @@ -3038,6 +4008,7 @@ export class LocalGitProvider implements GitProvider, Disposable { all?: boolean; cursor?: string; limit?: number; + merges?: boolean; ordering?: 'date' | 'author-date' | 'topo' | null; range?: Range; ref?: string; @@ -3046,13 +4017,13 @@ export class LocalGitProvider implements GitProvider, Disposable { since?: string; skip?: number; }, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, ): Promise { - const paths = await this.isTrackedPrivate(path, repoPath, ref); + const paths = await this.isTrackedWithDetails(path, repoPath, ref); if (paths == null) { - Logger.log(scope, `Skipping blame; '${path}' is not tracked`); + Logger.log(scope, `Skipping log; '${path}' is not tracked`); return emptyPromise as Promise; } @@ -3063,14 +4034,27 @@ export class LocalGitProvider implements GitProvider, Disposable { range = new Range(range.end, range.start); } - const data = await this.git.log__file(root, relativePath, ref, { + let data = await this.git.log__file(root, relativePath, ref, { ordering: configuration.get('advanced.commitOrdering'), ...options, - firstParent: options.renames, startLine: range == null ? undefined : range.start.line + 1, endLine: range == null ? undefined : range.end.line + 1, }); - const log = GitLogParser.parse( + + // If we didn't find any history from the working tree, check to see if the file was renamed + if (!data && ref == null) { + const status = await this.getStatusForFile(root, relativePath); + if (status?.originalPath != null) { + data = await this.git.log__file(root, status.originalPath, ref, { + ordering: configuration.get('advanced.commitOrdering'), + ...options, + startLine: range == null ? undefined : range.start.line + 1, + endLine: range == null ? undefined : range.end.line + 1, + }); + } + } + + const log = parseGitLog( this.container, data, // If this is the log of a folder, parse it as a normal log rather than a file log @@ -3136,7 +4120,18 @@ export class LocalGitProvider implements GitProvider, Disposable { moreLimit = moreLimit ?? configuration.get('advanced.maxSearchItems') ?? 0; - const ref = last(log.commits.values())?.ref; + const commit = last(log.commits.values()); + let ref; + if (commit != null) { + ref = commit.ref; + // Check to make sure the filename hasn't changed and if it has use the previous + if (commit.file != null) { + const path = commit.file.originalPath ?? commit.file.path; + if (path !== relativePath) { + relativePath = path; + } + } + } const moreLog = await this.getLogForFile(log.repoPath, relativePath, { ...options, limit: moreUntil == null ? moreLimit : 0, @@ -3191,117 +4186,170 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @gate() @log() async getMergeStatus(repoPath: string): Promise { let status = this.useCaching ? this._mergeStatusCache.get(repoPath) : undefined; - if (status === undefined) { - const merge = await this.git.rev_parse__verify(repoPath, 'MERGE_HEAD'); - if (merge != null) { - const [branch, mergeBase, possibleSourceBranches] = await Promise.all([ + if (status == null) { + async function getCore(this: LocalGitProvider): Promise { + const merge = await this.git.rev_parse__verify(repoPath, 'MERGE_HEAD'); + if (merge == null) return undefined; + + const [branchResult, mergeBaseResult, possibleSourceBranchesResult] = await Promise.allSettled([ this.getBranch(repoPath), this.getMergeBase(repoPath, 'MERGE_HEAD', 'HEAD'), - this.getCommitBranches(repoPath, 'MERGE_HEAD', { mode: 'pointsAt' }), + this.getCommitBranches(repoPath, ['MERGE_HEAD'], undefined, { all: true, mode: 'pointsAt' }), ]); - status = { + const branch = getSettledValue(branchResult); + const mergeBase = getSettledValue(mergeBaseResult); + const possibleSourceBranches = getSettledValue(possibleSourceBranchesResult); + + return { type: 'merge', repoPath: repoPath, mergeBase: mergeBase, - HEAD: GitReference.create(merge, repoPath, { refType: 'revision' }), - current: GitReference.fromBranch(branch!), + HEAD: createReference(merge, repoPath, { refType: 'revision' }), + current: getReferenceFromBranch(branch!), incoming: possibleSourceBranches?.length === 1 - ? GitReference.create(possibleSourceBranches[0], repoPath, { + ? createReference(possibleSourceBranches[0], repoPath, { refType: 'branch', name: possibleSourceBranches[0], remote: false, }) : undefined, - }; + } satisfies GitMergeStatus; } + status = getCore.call(this); if (this.useCaching) { - this._mergeStatusCache.set(repoPath, status ?? null); + this._mergeStatusCache.set(repoPath, status); } } - return status ?? undefined; + return status; } - @gate() @log() async getRebaseStatus(repoPath: string): Promise { let status = this.useCaching ? this._rebaseStatusCache.get(repoPath) : undefined; - if (status === undefined) { - const rebase = await this.git.rev_parse__verify(repoPath, 'REBASE_HEAD'); - if (rebase != null) { - let [mergeBase, branch, onto, stepsNumber, stepsMessage, stepsTotal] = await Promise.all([ - this.getMergeBase(repoPath, 'REBASE_HEAD', 'HEAD'), - this.git.readDotGitFile(repoPath, ['rebase-merge', 'head-name']), - this.git.readDotGitFile(repoPath, ['rebase-merge', 'onto']), - this.git.readDotGitFile(repoPath, ['rebase-merge', 'msgnum'], { numeric: true }), + if (status == null) { + async function getCore(this: LocalGitProvider): Promise { + const gitDir = await this.getGitDir(repoPath); + const [rebaseMergeHeadResult, rebaseApplyHeadResult] = await Promise.allSettled([ + this.git.readDotGitFile(gitDir, ['rebase-merge', 'head-name']), + this.git.readDotGitFile(gitDir, ['rebase-apply', 'head-name']), + ]); + const rebaseMergeHead = getSettledValue(rebaseMergeHeadResult); + const rebaseApplyHead = getSettledValue(rebaseApplyHeadResult); + + let branch = rebaseApplyHead ?? rebaseMergeHead; + if (branch == null) return undefined; + + const path = rebaseApplyHead != null ? 'rebase-apply' : 'rebase-merge'; + + const [ + rebaseHeadResult, + origHeadResult, + ontoResult, + stepsNumberResult, + stepsTotalResult, + stepsMessageResult, + ] = await Promise.allSettled([ + this.git.rev_parse__verify(repoPath, 'REBASE_HEAD'), + this.git.readDotGitFile(gitDir, [path, 'orig-head']), + this.git.readDotGitFile(gitDir, [path, 'onto']), + this.git.readDotGitFile(gitDir, [path, 'msgnum'], { numeric: true }), + this.git.readDotGitFile(gitDir, [path, 'end'], { numeric: true }), this.git - .readDotGitFile(repoPath, ['rebase-merge', 'message'], { throw: true }) - .catch(() => this.git.readDotGitFile(repoPath, ['rebase-merge', 'message-squashed'])), - this.git.readDotGitFile(repoPath, ['rebase-merge', 'end'], { numeric: true }), + .readDotGitFile(gitDir, [path, 'message'], { throw: true }) + .catch(() => this.git.readDotGitFile(gitDir, [path, 'message-squashed'])), ]); - if (branch == null || onto == null) return undefined; + const origHead = getSettledValue(origHeadResult); + const onto = getSettledValue(ontoResult); + if (origHead == null || onto == null) return undefined; + + let mergeBase; + const rebaseHead = getSettledValue(rebaseHeadResult); + if (rebaseHead != null) { + mergeBase = await this.getMergeBase(repoPath, rebaseHead, 'HEAD'); + } else { + mergeBase = await this.getMergeBase(repoPath, onto, origHead); + } if (branch.startsWith('refs/heads/')) { - branch = branch.substr(11).trim(); + branch = branch.substring(11).trim(); } - const possibleSourceBranches = await this.getCommitBranches(repoPath, onto, { mode: 'pointsAt' }); + const [branchTipsResult, tagTipsResult] = await Promise.allSettled([ + this.getCommitBranches(repoPath, [onto], undefined, { all: true, mode: 'pointsAt' }), + this.getCommitTags(repoPath, onto, { mode: 'pointsAt' }), + ]); + + const branchTips = getSettledValue(branchTipsResult); + const tagTips = getSettledValue(tagTipsResult); - let possibleSourceBranch: string | undefined; - for (const b of possibleSourceBranches) { - if (b.startsWith('(no branch, rebasing')) continue; + let ontoRef: GitBranchReference | GitTagReference | undefined; + if (branchTips != null) { + for (const ref of branchTips) { + if (ref.startsWith('(no branch, rebasing')) continue; + + ontoRef = createReference(ref, repoPath, { + refType: 'branch', + name: ref, + remote: false, + }); + break; + } + } + if (ontoRef == null && tagTips != null) { + for (const ref of tagTips) { + if (ref.startsWith('(no branch, rebasing')) continue; - possibleSourceBranch = b; - break; + ontoRef = createReference(ref, repoPath, { + refType: 'tag', + name: ref, + }); + break; + } } - status = { + return { type: 'rebase', repoPath: repoPath, mergeBase: mergeBase, - HEAD: GitReference.create(rebase, repoPath, { refType: 'revision' }), - onto: GitReference.create(onto, repoPath, { refType: 'revision' }), - current: - possibleSourceBranch != null - ? GitReference.create(possibleSourceBranch, repoPath, { - refType: 'branch', - name: possibleSourceBranch, - remote: false, - }) - : undefined, - - incoming: GitReference.create(branch, repoPath, { + HEAD: createReference(rebaseHead ?? origHead, repoPath, { refType: 'revision' }), + onto: createReference(onto, repoPath, { refType: 'revision' }), + current: ontoRef, + incoming: createReference(branch, repoPath, { refType: 'branch', name: branch, remote: false, }), steps: { current: { - number: stepsNumber ?? 0, - commit: GitReference.create(rebase, repoPath, { - refType: 'revision', - message: stepsMessage, - }), + number: getSettledValue(stepsNumberResult) ?? 0, + commit: + rebaseHead != null + ? createReference(rebaseHead, repoPath, { + refType: 'revision', + message: getSettledValue(stepsMessageResult), + }) + : undefined, }, - total: stepsTotal ?? 0, + total: getSettledValue(stepsTotalResult) ?? 0, }, - }; + } satisfies GitRebaseStatus; } + status = getCore.call(this); if (this.useCaching) { - this._rebaseStatusCache.set(repoPath, status ?? null); + this._rebaseStatusCache.set(repoPath, status); } } - return status ?? undefined; + return status; } @log() @@ -3316,7 +4364,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const relativePath = this.getRelativePath(uri, repoPath); - if (GitRevision.isUncommittedStaged(ref)) { + if (isUncommittedStaged(ref)) { return { current: GitUri.fromFile(relativePath, repoPath, ref), next: GitUri.fromFile(relativePath, repoPath, undefined), @@ -3331,7 +4379,7 @@ export class LocalGitProvider implements GitProvider, Disposable { if (status.indexStatus != null) { return { current: GitUri.fromFile(relativePath, repoPath, ref), - next: GitUri.fromFile(relativePath, repoPath, GitRevision.uncommittedStaged), + next: GitUri.fromFile(relativePath, repoPath, uncommittedStaged), }; } } @@ -3360,10 +4408,10 @@ export class LocalGitProvider implements GitProvider, Disposable { // editorLine?: number ): Promise { // If we have no ref (or staged ref) there is no next commit - if (!ref || GitRevision.isUncommittedStaged(ref)) return undefined; + if (!ref || isUncommittedStaged(ref)) return undefined; let filters: GitDiffFilter[] | undefined; - if (ref === GitRevision.deletedOrMissing) { + if (ref === deletedOrMissing) { // If we are trying to move next from a deleted or missing ref then get the first commit ref = undefined; filters = ['A']; @@ -3371,7 +4419,7 @@ export class LocalGitProvider implements GitProvider, Disposable { const relativePath = this.getRelativePath(uri, repoPath); let data = await this.git.log__file(repoPath, relativePath, ref, { - argsOrFormat: GitLogParser.simpleFormat, + argsOrFormat: parseGitLogSimpleFormat, fileMode: 'simple', filters: filters, limit: skip + 1, @@ -3381,11 +4429,11 @@ export class LocalGitProvider implements GitProvider, Disposable { }); if (data == null || data.length === 0) return undefined; - const [nextRef, file, status] = GitLogParser.parseSimple(data, skip); + const [nextRef, file, status] = parseGitLogSimple(data, skip); // If the file was deleted, check for a possible rename if (status === 'D') { data = await this.git.log__file(repoPath, '.', nextRef, { - argsOrFormat: GitLogParser.simpleFormat, + argsOrFormat: parseGitLogSimpleFormat, fileMode: 'simple', filters: ['R', 'C'], limit: 1, @@ -3396,11 +4444,11 @@ export class LocalGitProvider implements GitProvider, Disposable { return GitUri.fromFile(file ?? relativePath, repoPath, nextRef); } - const [nextRenamedRef, renamedFile] = GitLogParser.parseSimpleRenamed(data, file ?? relativePath); + const [nextRenamedRef, renamedFile] = parseGitLogSimpleRenamed(data, file ?? relativePath); return GitUri.fromFile( renamedFile ?? file ?? relativePath, repoPath, - nextRenamedRef ?? nextRef ?? GitRevision.deletedOrMissing, + nextRenamedRef ?? nextRef ?? deletedOrMissing, ); } @@ -3411,7 +4459,7 @@ export class LocalGitProvider implements GitProvider, Disposable { async getOldestUnpushedRefForFile(repoPath: string, uri: Uri): Promise { const [relativePath, root] = splitPath(uri, repoPath); - const data = await this.git.log__file(root, relativePath, '@{push}..', { + const data = await this.git.log__file(root, relativePath, '@{u}..', { argsOrFormat: ['-z', '--format=%H'], fileMode: 'none', ordering: configuration.get('advanced.commitOrdering'), @@ -3430,9 +4478,8 @@ export class LocalGitProvider implements GitProvider, Disposable { uri: Uri, ref: string | undefined, skip: number = 0, - firstParent: boolean = false, ): Promise { - if (ref === GitRevision.deletedOrMissing) return undefined; + if (ref === deletedOrMissing) return undefined; const relativePath = this.getRelativePath(uri, repoPath); @@ -3453,20 +4500,20 @@ export class LocalGitProvider implements GitProvider, Disposable { // Diff working with staged return { current: GitUri.fromFile(relativePath, repoPath, undefined), - previous: GitUri.fromFile(relativePath, repoPath, GitRevision.uncommittedStaged), + previous: GitUri.fromFile(relativePath, repoPath, uncommittedStaged), }; } return { // Diff staged with HEAD (or prior if more skips) - current: GitUri.fromFile(relativePath, repoPath, GitRevision.uncommittedStaged), - previous: await this.getPreviousUri(repoPath, uri, ref, skip - 1, undefined, firstParent), + current: GitUri.fromFile(relativePath, repoPath, uncommittedStaged), + previous: await this.getPreviousUri(repoPath, uri, ref, skip - 1), }; } else if (status.workingTreeStatus != null) { if (skip === 0) { return { current: GitUri.fromFile(relativePath, repoPath, undefined), - previous: await this.getPreviousUri(repoPath, uri, undefined, skip, undefined, firstParent), + previous: await this.getPreviousUri(repoPath, uri, undefined, skip), }; } } @@ -3475,16 +4522,16 @@ export class LocalGitProvider implements GitProvider, Disposable { } } // If we are at the index (staged), diff staged with HEAD - else if (GitRevision.isUncommittedStaged(ref)) { + else if (isUncommittedStaged(ref)) { const current = skip === 0 ? GitUri.fromFile(relativePath, repoPath, ref) - : (await this.getPreviousUri(repoPath, uri, undefined, skip - 1, undefined, firstParent))!; - if (current == null || current.sha === GitRevision.deletedOrMissing) return undefined; + : (await this.getPreviousUri(repoPath, uri, undefined, skip - 1))!; + if (current == null || current.sha === deletedOrMissing) return undefined; return { current: current, - previous: await this.getPreviousUri(repoPath, uri, undefined, skip, undefined, firstParent), + previous: await this.getPreviousUri(repoPath, uri, undefined, skip), }; } @@ -3492,12 +4539,12 @@ export class LocalGitProvider implements GitProvider, Disposable { const current = skip === 0 ? GitUri.fromFile(relativePath, repoPath, ref) - : (await this.getPreviousUri(repoPath, uri, ref, skip - 1, undefined, firstParent))!; - if (current == null || current.sha === GitRevision.deletedOrMissing) return undefined; + : (await this.getPreviousUri(repoPath, uri, ref, skip - 1))!; + if (current == null || current.sha === deletedOrMissing) return undefined; return { current: current, - previous: await this.getPreviousUri(repoPath, uri, ref, skip, undefined, firstParent), + previous: await this.getPreviousUri(repoPath, uri, ref, skip), }; } @@ -3509,7 +4556,7 @@ export class LocalGitProvider implements GitProvider, Disposable { ref: string | undefined, skip: number = 0, ): Promise { - if (ref === GitRevision.deletedOrMissing) return undefined; + if (ref === deletedOrMissing) return undefined; let relativePath = this.getRelativePath(uri, repoPath); @@ -3528,62 +4575,45 @@ export class LocalGitProvider implements GitProvider, Disposable { // If line is uncommitted, we need to dig deeper to figure out where to go (because blame can't be trusted) if (blameLine.commit.isUncommitted) { - // If the document is dirty (unsaved), use the status to determine where to go - if (document.isDirty) { - // Check the file status to see if there is anything staged - const status = await this.getStatusForFile(repoPath, uri); - if (status != null) { - // If the file is staged, diff working with staged (index) - // If the file is not staged, diff working with HEAD - if (status.indexStatus != null) { - // Diff working with staged - return { - current: GitUri.fromFile(relativePath, repoPath, undefined), - previous: GitUri.fromFile(relativePath, repoPath, GitRevision.uncommittedStaged), - line: editorLine, - }; - } + // Check the file status to see if there is anything staged + const status = await this.getStatusForFile(repoPath, uri); + if (status != null) { + // If the file is staged, diff working with staged (index) + // If the file is not staged, diff working with HEAD + if (status.indexStatus != null) { + // Diff working with staged + return { + current: GitUri.fromFile(relativePath, repoPath, undefined), + previous: GitUri.fromFile(relativePath, repoPath, uncommittedStaged), + line: editorLine, + }; } - - // Diff working with HEAD (or prior if more skips) - return { - current: GitUri.fromFile(relativePath, repoPath, undefined), - previous: await this.getPreviousUri(repoPath, uri, undefined, skip, editorLine), - line: editorLine, - }; } - // First, check if we have a diff in the working tree - let hunkLine = await this.getDiffForLine(gitUri, editorLine, undefined); - if (hunkLine == null) { - // Next, check if we have a diff in the index (staged) - hunkLine = await this.getDiffForLine(gitUri, editorLine, undefined, GitRevision.uncommittedStaged); - - if (hunkLine != null) { - ref = GitRevision.uncommittedStaged; - } else { - skip++; - } - } + // Diff working with HEAD (or prior if more skips) + return { + current: GitUri.fromFile(relativePath, repoPath, undefined), + previous: await this.getPreviousUri(repoPath, uri, undefined, skip, editorLine), + line: editorLine, + }; } + // If line is committed, diff with line ref with previous - else { - ref = blameLine.commit.sha; - relativePath = blameLine.commit.file?.path ?? blameLine.commit.file?.originalPath ?? relativePath; - uri = this.getAbsoluteUri(relativePath, repoPath); - editorLine = blameLine.line.originalLine - 1; - - if (skip === 0 && blameLine.commit.file?.previousSha) { - previous = GitUri.fromFile(relativePath, repoPath, blameLine.commit.file.previousSha); - } + ref = blameLine.commit.sha; + relativePath = blameLine.commit.file?.path ?? blameLine.commit.file?.originalPath ?? relativePath; + uri = this.getAbsoluteUri(relativePath, repoPath); + editorLine = blameLine.line.originalLine - 1; + + if (skip === 0 && blameLine.commit.file?.previousSha) { + previous = GitUri.fromFile(relativePath, repoPath, blameLine.commit.file.previousSha); } } else { - if (GitRevision.isUncommittedStaged(ref)) { + if (isUncommittedStaged(ref)) { const current = skip === 0 ? GitUri.fromFile(relativePath, repoPath, ref) : (await this.getPreviousUri(repoPath, uri, undefined, skip - 1, editorLine))!; - if (current.sha === GitRevision.deletedOrMissing) return undefined; + if (current.sha === deletedOrMissing) return undefined; return { current: current, @@ -3611,7 +4641,7 @@ export class LocalGitProvider implements GitProvider, Disposable { skip === 0 ? GitUri.fromFile(relativePath, repoPath, ref) : (await this.getPreviousUri(repoPath, uri, ref, skip - 1, editorLine))!; - if (current.sha === GitRevision.deletedOrMissing) return undefined; + if (current.sha === deletedOrMissing) return undefined; return { current: current, @@ -3627,13 +4657,12 @@ export class LocalGitProvider implements GitProvider, Disposable { ref?: string, skip: number = 0, editorLine?: number, - firstParent: boolean = false, ): Promise { - if (ref === GitRevision.deletedOrMissing) return undefined; + if (ref === deletedOrMissing) return undefined; const scope = getLogScope(); - if (ref === GitRevision.uncommitted) { + if (ref === uncommitted) { ref = undefined; } @@ -3643,9 +4672,8 @@ export class LocalGitProvider implements GitProvider, Disposable { let data; try { data = await this.git.log__file(repoPath, relativePath, ref, { - argsOrFormat: GitLogParser.simpleFormat, + argsOrFormat: parseGitLogSimpleFormat, fileMode: 'simple', - firstParent: firstParent, limit: skip + 2, ordering: configuration.get('advanced.commitOrdering'), startLine: editorLine != null ? editorLine + 1 : undefined, @@ -3653,18 +4681,18 @@ export class LocalGitProvider implements GitProvider, Disposable { } catch (ex) { const msg: string = ex?.toString() ?? ''; // If the line count is invalid just fallback to the most recent commit - if ((ref == null || GitRevision.isUncommittedStaged(ref)) && GitErrors.invalidLineCount.test(msg)) { + if ((ref == null || isUncommittedStaged(ref)) && GitErrors.invalidLineCount.test(msg)) { if (ref == null) { const status = await this.getStatusForFile(repoPath, uri); if (status?.indexStatus != null) { - return GitUri.fromFile(relativePath, repoPath, GitRevision.uncommittedStaged); + return GitUri.fromFile(relativePath, repoPath, uncommittedStaged); } } ref = await this.git.log__file_recent(repoPath, relativePath, { ordering: configuration.get('advanced.commitOrdering'), }); - return GitUri.fromFile(relativePath, repoPath, ref ?? GitRevision.deletedOrMissing); + return GitUri.fromFile(relativePath, repoPath, ref ?? deletedOrMissing); } Logger.error(ex, scope); @@ -3672,11 +4700,11 @@ export class LocalGitProvider implements GitProvider, Disposable { } if (data == null || data.length === 0) return undefined; - const [previousRef, file] = GitLogParser.parseSimple(data, skip, ref); + const [previousRef, file] = parseGitLogSimple(data, skip, ref); // If the previous ref matches the ref we asked for assume we are at the end of the history if (ref != null && ref === previousRef) return undefined; - return GitUri.fromFile(file ?? relativePath, repoPath, previousRef ?? GitRevision.deletedOrMissing); + return GitUri.fromFile(file ?? relativePath, repoPath, previousRef ?? deletedOrMissing); } @log() @@ -3692,17 +4720,32 @@ export class LocalGitProvider implements GitProvider, Disposable { ): Promise { const scope = getLogScope(); - const limit = options?.limit ?? configuration.get('advanced.maxListItems') ?? 0; + const args = ['--walk-reflogs', `--format=${parseGitRefLogDefaultFormat}`, '--date=iso8601']; + + const ordering = options?.ordering ?? configuration.get('advanced.commitOrdering'); + if (ordering) { + args.push(`--${ordering}-order`); + } + + if (options?.all) { + args.push('--all'); + } + + // Pass a much larger limit to reflog, because we aggregate the data and we won't know how many lines we'll need + const limit = (options?.limit ?? configuration.get('advanced.maxListItems') ?? 0) * 100; + if (limit) { + args.push(`-n${limit}`); + } + + if (options?.skip) { + args.push(`--skip=${options.skip}`); + } + try { - // Pass a much larger limit to reflog, because we aggregate the data and we won't know how many lines we'll need - const data = await this.git.reflog(repoPath, { - ordering: configuration.get('advanced.commitOrdering'), - ...options, - limit: limit * 100, - }); + const data = await this.git.log(repoPath, undefined, ...args); if (data == null) return undefined; - const reflog = GitReflogParser.parse(data, repoPath, reflogCommands, limit, limit * 100); + const reflog = parseGitRefLog(data, repoPath, reflogCommands, limit, limit * 100); if (reflog?.hasMore) { reflog.more = this.getReflogMoreFn(reflog, options); } @@ -3754,28 +4797,47 @@ export class LocalGitProvider implements GitProvider, Disposable { } @log({ args: { 1: false } }) - async getRemotes( - repoPath: string | undefined, - options?: { providers?: RemoteProviders; sort?: boolean }, - ): Promise[]> { + async getRemotes(repoPath: string | undefined, options?: { sort?: boolean }): Promise { if (repoPath == null) return []; - const providers = options?.providers ?? loadRemoteProviders(configuration.get('remotes', null)); + const scope = getLogScope(); - try { - const data = await this.git.remote(repoPath); - const remotes = GitRemoteParser.parse(data, repoPath, getRemoteProviderMatcher(this.container, providers)); - if (remotes == null) return []; + let remotesPromise = this.useCaching ? this._remotesCache.get(repoPath) : undefined; + if (remotesPromise == null) { + async function load(this: LocalGitProvider): Promise { + const providers = loadRemoteProviders( + configuration.get('remotes', this.container.git.getRepository(repoPath!)?.folder?.uri ?? null), + ); + + try { + const data = await this.git.remote(repoPath!); + const remotes = parseGitRemotes( + this.container, + data, + repoPath!, + getRemoteProviderMatcher(this.container, providers), + ); + return remotes; + } catch (ex) { + this._remotesCache.delete(repoPath!); + Logger.error(ex, scope); + return []; + } + } - if (options?.sort) { - GitRemote.sort(remotes); + remotesPromise = load.call(this); + + if (this.useCaching) { + this._remotesCache.set(repoPath, remotesPromise); } + } - return remotes; - } catch (ex) { - Logger.error(ex); - return []; + const remotes = await remotesPromise; + if (options?.sort) { + sortRemotes(remotes); } + + return remotes; } @gate() @@ -3783,7 +4845,9 @@ export class LocalGitProvider implements GitProvider, Disposable { getRevisionContent(repoPath: string, path: string, ref: string): Promise { const [relativePath, root] = splitPath(path, repoPath); - return this.git.show(root, relativePath, ref, { encoding: 'buffer' }); + return this.git.show(root, relativePath, ref, { encoding: 'buffer' }) as Promise< + Uint8Array | undefined + >; } @gate() @@ -3806,7 +4870,7 @@ export class LocalGitProvider implements GitProvider, Disposable { committedDate: '%ct', parents: '%P', stashName: '%gd', - summary: '%B', + summary: '%gs', }); const data = await this.git.stash__list(repoPath, { args: parser.arguments, @@ -3871,33 +4935,28 @@ export class LocalGitProvider implements GitProvider, Disposable { } @log() - async getStatusForFile(repoPath: string, uri: Uri): Promise { - const porcelainVersion = (await this.git.isAtLeastVersion('2.11')) ? 2 : 1; - - const [relativePath, root] = splitPath(uri, repoPath); + async getStatusForFile(repoPath: string, pathOrUri: string | Uri): Promise { + const status = await this.getStatusForRepo(repoPath); + if (!status?.files.length) return undefined; - const data = await this.git.status__file(root, relativePath, porcelainVersion, { - similarityThreshold: configuration.get('advanced.similarityThreshold'), - }); - const status = GitStatusParser.parse(data, root, porcelainVersion); - if (status == null || !status.files.length) return undefined; - - return status.files[0]; + const [relativePath] = splitPath(pathOrUri, repoPath); + const file = status.files.find(f => f.path === relativePath); + return file; } @log() async getStatusForFiles(repoPath: string, pathOrGlob: Uri): Promise { - const porcelainVersion = (await this.git.isAtLeastVersion('2.11')) ? 2 : 1; - - const [relativePath, root] = splitPath(pathOrGlob, repoPath); + let [relativePath] = splitPath(pathOrGlob, repoPath); + if (!relativePath.endsWith('/*')) { + return this.getStatusForFile(repoPath, pathOrGlob).then(f => (f != null ? [f] : undefined)); + } - const data = await this.git.status__file(root, relativePath, porcelainVersion, { - similarityThreshold: configuration.get('advanced.similarityThreshold'), - }); - const status = GitStatusParser.parse(data, root, porcelainVersion); - if (status == null || !status.files.length) return []; + relativePath = relativePath.substring(0, relativePath.length - 1); + const status = await this.getStatusForRepo(repoPath); + if (!status?.files.length) return undefined; - return status.files; + const files = status.files.filter(f => f.path.startsWith(relativePath)); + return files; } @log() @@ -3907,9 +4966,9 @@ export class LocalGitProvider implements GitProvider, Disposable { const porcelainVersion = (await this.git.isAtLeastVersion('2.11')) ? 2 : 1; const data = await this.git.status(repoPath, porcelainVersion, { - similarityThreshold: configuration.get('advanced.similarityThreshold'), + similarityThreshold: configuration.get('advanced.similarityThreshold') ?? undefined, }); - const status = GitStatusParser.parse(data, repoPath, porcelainVersion); + const status = parseGitStatus(data, repoPath, porcelainVersion); if (status?.detached) { const rebaseStatus = await this.getRebaseStatus(repoPath); @@ -3931,7 +4990,11 @@ export class LocalGitProvider implements GitProvider, Disposable { @log({ args: { 1: false } }) async getTags( repoPath: string | undefined, - options?: { cursor?: string; filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, + options?: { + filter?: (t: GitTag) => boolean; + paging?: PagingOptions; + sort?: boolean | TagSortOptions; + }, ): Promise> { if (repoPath == null) return emptyPagedResult; @@ -3940,8 +5003,8 @@ export class LocalGitProvider implements GitProvider, Disposable { async function load(this: LocalGitProvider): Promise> { try { const data = await this.git.tag(repoPath!); - return { values: GitTagParser.parse(data, repoPath!) ?? [] }; - } catch (ex) { + return { values: parseGitTags(data, repoPath!) }; + } catch (_ex) { this._tagsCache.delete(repoPath!); return emptyPagedResult; @@ -3950,7 +5013,7 @@ export class LocalGitProvider implements GitProvider, Disposable { resultsPromise = load.call(this); - if (this.useCaching) { + if (this.useCaching && options?.paging?.cursor == null) { this._tagsCache.set(repoPath, resultsPromise); } } @@ -3976,9 +5039,23 @@ export class LocalGitProvider implements GitProvider, Disposable { const [relativePath, root] = splitPath(path, repoPath); + if (isUncommittedStaged(ref)) { + const data = await this.git.ls_files(root, relativePath, { ref: ref }); + const [result] = parseGitLsFiles(data); + if (result == null) return undefined; + + const size = await this.git.cat_file__size(repoPath, result.oid); + return { + ref: ref, + oid: result.oid, + path: relativePath, + size: size, + type: 'blob', + }; + } + const data = await this.git.ls_tree(root, ref, relativePath); - const trees = GitTreeParser.parse(data); - return trees?.length ? trees[0] : undefined; + return parseGitTree(data, ref)[0]; } @log() @@ -3986,13 +5063,7 @@ export class LocalGitProvider implements GitProvider, Disposable { if (repoPath == null) return []; const data = await this.git.ls_tree(repoPath, ref); - return GitTreeParser.parse(data) ?? []; - } - - @log() - async getUniqueRepositoryId(repoPath: string): Promise { - const data = await this.git.rev_list(repoPath, 'HEAD', { maxParents: 0 }); - return data?.[0]; + return parseGitTree(data, ref); } @log({ args: { 1: false } }) @@ -4023,22 +5094,35 @@ export class LocalGitProvider implements GitProvider, Disposable { return this.git.merge_base__is_ancestor(repoPath, ref, '@{u}'); } + hasUnsafeRepositories(): boolean { + return this.unsafePaths.size !== 0; + } + + @log() + async isAncestorOf(repoPath: string, ref1: string, ref2: string): Promise { + if (repoPath == null) return false; + + return this.git.merge_base__is_ancestor(repoPath, ref1, ref2); + } + isTrackable(uri: Uri): boolean { return this.supportedSchemes.has(uri.scheme); } async isTracked(uri: Uri): Promise { - return (await this.isTrackedPrivate(uri)) != null; + return (await this.isTrackedWithDetails(uri)) != null; } - private async isTrackedPrivate(uri: Uri | GitUri): Promise<[string, string] | undefined>; - private async isTrackedPrivate( + private async isTrackedWithDetails(uri: Uri | GitUri): Promise<[string, string] | undefined>; + private async isTrackedWithDetails( path: string, repoPath?: string, ref?: string, ): Promise<[string, string] | undefined>; - @log({ exit: tracked => `returned ${Boolean(tracked)}` }) - private async isTrackedPrivate( + @log({ + exit: tracked => `returned ${tracked != null ? `[${tracked[0]},[${tracked[1]}]` : 'false'}`, + }) + private async isTrackedWithDetails( pathOrUri: string | Uri | GitUri, repoPath?: string, ref?: string, @@ -4047,19 +5131,19 @@ export class LocalGitProvider implements GitProvider, Disposable { let repository: Repository | undefined; if (typeof pathOrUri === 'string') { - if (ref === GitRevision.deletedOrMissing) return undefined; + if (ref === deletedOrMissing) return undefined; repository = this.container.git.getRepository(Uri.file(pathOrUri)); - repoPath = repoPath || repository?.path; + repoPath ||= repository?.path; [relativePath, repoPath] = splitPath(pathOrUri, repoPath); } else { if (!this.isTrackable(pathOrUri)) return undefined; - if (pathOrUri instanceof GitUri) { + if (isGitUri(pathOrUri)) { // Always use the ref of the GitUri ref = pathOrUri.sha; - if (ref === GitRevision.deletedOrMissing) return undefined; + if (ref === deletedOrMissing) return undefined; } repository = this.container.git.getRepository(pathOrUri); @@ -4092,7 +5176,9 @@ export class LocalGitProvider implements GitProvider, Disposable { ref: string | undefined, repository: Repository | undefined, ): Promise<[string, string] | undefined> { - if (ref === GitRevision.deletedOrMissing) return undefined; + if (ref === deletedOrMissing) return undefined; + + const scope = getLogScope(); try { while (true) { @@ -4122,7 +5208,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - if (!tracked && ref && !GitRevision.isUncommitted(ref)) { + if (!tracked && ref && !isUncommitted(ref)) { tracked = Boolean(await this.git.ls_files(repoPath, relativePath, { ref: ref })); // If we still haven't found this file, make sure it wasn't deleted in that ref (i.e. check the previous) if (!tracked) { @@ -4154,7 +5240,7 @@ export class LocalGitProvider implements GitProvider, Disposable { return [relativePath, repoPath]; } } catch (ex) { - Logger.error(ex); + Logger.error(ex, scope); return undefined; } } @@ -4173,6 +5259,7 @@ export class LocalGitProvider implements GitProvider, Disposable { uri: Uri, options?: { ref1?: string; ref2?: string; staged?: boolean; tool?: string }, ): Promise { + const scope = getLogScope(); const [relativePath, root] = splitPath(uri, repoPath); try { @@ -4204,13 +5291,15 @@ export class LocalGitProvider implements GitProvider, Disposable { return; } - Logger.error(ex, 'openDiffTool'); + Logger.error(ex, scope); void showGenericErrorMessage('Unable to open compare'); } } @log() async openDirectoryCompare(repoPath: string, ref1: string, ref2?: string, tool?: string): Promise { + const scope = getLogScope(); + try { if (!tool) { const scope = getLogScope(); @@ -4239,7 +5328,7 @@ export class LocalGitProvider implements GitProvider, Disposable { return; } - Logger.error(ex, 'openDirectoryCompare'); + Logger.error(ex, scope); void showGenericErrorMessage('Unable to open directory compare'); } } @@ -4253,16 +5342,16 @@ export class LocalGitProvider implements GitProvider, Disposable { ) { if ( !ref || - ref === GitRevision.deletedOrMissing || - (pathOrUri == null && GitRevision.isSha(ref)) || - (pathOrUri != null && GitRevision.isUncommitted(ref)) + ref === deletedOrMissing || + (pathOrUri == null && isSha(ref)) || + (pathOrUri != null && isUncommitted(ref)) ) { return ref; } if (pathOrUri == null) { // If it doesn't look like a sha at all (e.g. branch name) or is a stash ref (^3) don't try to resolve it - if ((!options?.force && !GitRevision.isShaLike(ref)) || ref.endsWith('^3')) return ref; + if ((!options?.force && !isShaLike(ref)) || ref.endsWith('^3')) return ref; return (await this.git.rev_parse__verify(repoPath, ref)) ?? ref; } @@ -4283,7 +5372,7 @@ export class LocalGitProvider implements GitProvider, Disposable { ]); const verified = getSettledValue(verifiedResult); - if (verified == null) return GitRevision.deletedOrMissing; + if (verified == null) return deletedOrMissing; const resolved = getSettledValue(resolvedResult); @@ -4321,13 +5410,36 @@ export class LocalGitProvider implements GitProvider, Disposable { args.push(...files); } + const includeOnlyStashes = args.includes('--no-walk'); + + let stashes: Map | undefined; + let stdin: string | undefined; + + if (shas == null) { + // TODO@eamodio this is insanity -- there *HAS* to be a better way to get git log to return stashes + const stash = await this.getStash(repoPath); + if (stash?.commits.size) { + stdin = ''; + stashes = new Map(stash.commits); + for (const commit of stash.commits.values()) { + stdin += `${commit.sha.substring(0, 9)}\n`; + // Include the stash's 2nd (index files) and 3rd (untracked files) parents + for (const p of skip(commit.parents, 1)) { + stashes.set(p, commit); + stdin += `${p.substring(0, 9)}\n`; + } + } + } + } + const data = await this.git.log__search(repoPath, shas?.size ? undefined : args, { ordering: configuration.get('advanced.commitOrdering'), ...options, limit: limit, shas: shas, + stdin: stdin, }); - const log = GitLogParser.parse( + const log = parseGitLog( this.container, data, LogType.Log, @@ -4338,6 +5450,8 @@ export class LocalGitProvider implements GitProvider, Disposable { limit, false, undefined, + stashes, + includeOnlyStashes, ); if (log != null) { @@ -4385,7 +5499,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } return log; - } catch (ex) { + } catch (_ex) { return undefined; } } @@ -4441,15 +5555,24 @@ export class LocalGitProvider implements GitProvider, Disposable { const limit = options?.limit ?? configuration.get('advanced.maxSearchItems') ?? 0; const similarityThreshold = configuration.get('advanced.similarityThreshold'); + const includeOnlyStashes = searchArgs.includes('--no-walk'); - const stash = await this.getStash(repoPath); + let stashes: Map | undefined; let stdin: string | undefined; + // TODO@eamodio this is insanity -- there *HAS* to be a better way to get git log to return stashes - if (stash != null && stash.commits.size !== 0) { - stdin = join( - map(stash.commits.values(), c => c.sha.substring(0, 9)), - '\n', - ); + const stash = await this.getStash(repoPath); + if (stash?.commits.size) { + stdin = ''; + stashes = new Map(stash.commits); + for (const commit of stash.commits.values()) { + stdin += `${commit.sha.substring(0, 9)}\n`; + // Include the stash's 2nd (index files) and 3rd (untracked files) parents + for (const p of skip(commit.parents, 1)) { + stashes.set(p, commit); + stdin += `${p.substring(0, 9)}\n`; + } + } } const args = [ @@ -4472,7 +5595,7 @@ export class LocalGitProvider implements GitProvider, Disposable { let data; try { - data = await this.git.log2( + data = await this.git.log( repoPath, { cancellation: options?.cancellation, @@ -4503,6 +5626,8 @@ export class LocalGitProvider implements GitProvider, Disposable { let count = total; for (const r of refAndDateParser.parse(data)) { + if (includeOnlyStashes && !stashes?.has(r.sha)) continue; + if (results.has(r.sha)) { limit--; continue; @@ -4539,7 +5664,7 @@ export class LocalGitProvider implements GitProvider, Disposable { }; } - return searchForCommitsCore.call(this, limit); + return await searchForCommitsCore.call(this, limit); } catch (ex) { if (ex instanceof GitSearchError) { throw ex; @@ -4548,15 +5673,46 @@ export class LocalGitProvider implements GitProvider, Disposable { } } + @log({ args: { 2: false } }) + async runGitCommandViaTerminal( + repoPath: string, + command: string, + args: string[], + options?: { execute?: boolean }, + ): Promise { + await this.git.runGitCommandViaTerminal(repoPath, command, args, options); + + // Right now we are reliant on the Repository class to fire the change event (as a stop gap if we don't detect a change through the normal mechanisms) + // setTimeout(() => this.fireChange(RepositoryChange.Unknown), 2500); + } + @log() validateBranchOrTagName(repoPath: string, ref: string): Promise { return this.git.check_ref_format(ref, repoPath); } + @log({ args: { 1: false } }) + async validatePatch(repoPath: string | undefined, contents: string): Promise { + try { + await this.git.apply2(repoPath!, { stdin: contents }, '--check'); + return true; + } catch (ex) { + if (ex instanceof Error && ex.message) { + if (ex.message.includes('No valid patches in input')) { + return false; + } + + return true; + } + + return false; + } + } + @log() async validateReference(repoPath: string, ref: string): Promise { if (ref == null || ref.length === 0) return false; - if (ref === GitRevision.deletedOrMissing || GitRevision.isUncommitted(ref)) return true; + if (ref === deletedOrMissing || isUncommitted(ref)) return true; return (await this.git.rev_parse__verify(repoPath, ref)) != null; } @@ -4575,12 +5731,12 @@ export class LocalGitProvider implements GitProvider, Disposable { } @log() - async unStageFile(repoPath: string, pathOrUri: string | Uri): Promise { + async unstageFile(repoPath: string, pathOrUri: string | Uri): Promise { await this.git.reset(repoPath, typeof pathOrUri === 'string' ? pathOrUri : splitPath(pathOrUri, repoPath)[0]); } @log() - async unStageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise { + async unstageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise { await this.git.reset( repoPath, typeof directoryOrUri === 'string' ? directoryOrUri : splitPath(directoryOrUri, repoPath)[0], @@ -4622,43 +5778,71 @@ export class LocalGitProvider implements GitProvider, Disposable { this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['stashes'] }); } + @log() + async stashRename( + repoPath: string, + stashName: string, + ref: string, + message: string, + stashOnRef?: string, + ): Promise { + await this.git.stash__rename(repoPath, stashName, ref, message, stashOnRef); + this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['stashes'] }); + } + @log({ args: { 2: uris => uris?.length } }) async stashSave( repoPath: string, message?: string, uris?: Uri[], - options?: { includeUntracked?: boolean; keepIndex?: boolean }, + options?: { includeUntracked?: boolean; keepIndex?: boolean; onlyStaged?: boolean }, ): Promise { - if (uris == null) { - await this.git.stash__push(repoPath, message, options); - this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['stashes', 'status'] }); - return; - } - - await this.ensureGitVersion( - '2.13.2', - 'Stashing individual files', - ' Please retry by stashing everything or install a more recent version of Git and try again.', - ); - - const pathspecs = uris.map(u => `./${splitPath(u, repoPath)[0]}`); + try { + if (!uris?.length) { + await this.git.stash__push(repoPath, message, options); + return; + } - const stdinVersion = '2.30.0'; - const stdin = await this.git.isAtLeastVersion(stdinVersion); - // If we don't support stdin, then error out if we are over the maximum allowed git cli length - if (!stdin && countStringLength(pathspecs) > maxGitCliLength) { await this.ensureGitVersion( - stdinVersion, - `Stashing so many files (${pathspecs.length}) at once`, - ' Please retry by stashing fewer files or install a more recent version of Git and try again.', + '2.13.2', + 'Stashing individual files', + ' Please retry by stashing everything or install a more recent version of Git and try again.', ); + + const pathspecs = uris.map(u => `./${splitPath(u, repoPath)[0]}`); + + const stdinVersion = '2.30.0'; + let stdin = await this.git.isAtLeastVersion(stdinVersion); + if (stdin && options?.onlyStaged && uris.length) { + // Since Git doesn't support --staged with --pathspec-from-file try to pass them in directly + stdin = false; + } + + // If we don't support stdin, then error out if we are over the maximum allowed git cli length + if (!stdin && countStringLength(pathspecs) > maxGitCliLength) { + await this.ensureGitVersion( + stdinVersion, + `Stashing so many files (${pathspecs.length}) at once`, + ' Please retry by stashing fewer files or install a more recent version of Git and try again.', + ); + } + + await this.git.stash__push(repoPath, message, { + ...options, + pathspecs: pathspecs, + stdin: stdin, + }); + } finally { + this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['stashes', 'status'] }); } + } - await this.git.stash__push(repoPath, message, { - ...options, - pathspecs: pathspecs, - stdin: stdin, - }); + @log() + async stashSaveSnapshot(repoPath: string, message?: string): Promise { + const id = await this.git.stash__create(repoPath); + if (id == null) return; + + await this.git.stash__store(repoPath, id, message); this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['stashes'] }); } @@ -4668,13 +5852,16 @@ export class LocalGitProvider implements GitProvider, Disposable { path: string, options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean }, ) { + const scope = getLogScope(); + try { await this.git.worktree__add(repoPath, path, options); + this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['worktrees'] }); if (options?.createBranch) { this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['branches'] }); } } catch (ex) { - Logger.error(ex); + Logger.error(ex, scope); const msg = String(ex); @@ -4690,7 +5877,6 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @gate() @log() async getWorktrees(repoPath: string): Promise { await this.ensureGitVersion( @@ -4699,12 +5885,35 @@ export class LocalGitProvider implements GitProvider, Disposable { ' Please install a more recent version of Git and try again.', ); - const data = await this.git.worktree__list(repoPath); - return GitWorktreeParser.parse(data, repoPath); + let worktrees = this.useCaching ? this._worktreesCache.get(repoPath) : undefined; + if (worktrees == null) { + async function load(this: LocalGitProvider) { + try { + const [data, branches] = await Promise.all([ + this.git.worktree__list(repoPath), + this.getBranches(repoPath), + ]); + + return parseGitWorktrees(this.container, data, repoPath, branches.values); + } catch (ex) { + this._worktreesCache.delete(repoPath); + + throw ex; + } + } + + worktrees = load.call(this); + + if (this.useCaching) { + this._worktreesCache.set(repoPath, worktrees); + } + } + + return worktrees; } - // eslint-disable-next-line @typescript-eslint/require-await @log() + // eslint-disable-next-line @typescript-eslint/require-await async getWorktreesDefaultUri(repoPath: string): Promise { let location = configuration.get('worktrees.defaultLocation'); if (location == null) return undefined; @@ -4725,6 +5934,8 @@ export class LocalGitProvider implements GitProvider, Disposable { @log() async deleteWorktree(repoPath: string, path: string, options?: { force?: boolean }) { + const scope = getLogScope(); + await this.ensureGitVersion( '2.17.0', 'Deleting worktrees', @@ -4732,9 +5943,10 @@ export class LocalGitProvider implements GitProvider, Disposable { ); try { - await this.git.worktree__remove(repoPath, path, options); + await this.git.worktree__remove(repoPath, normalizePath(path), options); + this.container.events.fire('git:cache:reset', { repoPath: repoPath, caches: ['worktrees'] }); } catch (ex) { - Logger.error(ex); + Logger.error(ex, scope); const msg = String(ex); @@ -4750,13 +5962,13 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - private _scmGitApi: Promise | undefined; - private async getScmGitApi(): Promise { + private _scmGitApi: Promise | undefined; + private async getScmGitApi(): Promise { return this._scmGitApi ?? (this._scmGitApi = this.getScmGitApiCore()); } @log() - private async getScmGitApiCore(): Promise { + private async getScmGitApiCore(): Promise { try { const extension = extensions.getExtension('vscode.git'); if (extension == null) return undefined; @@ -4796,7 +6008,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @log() + @log({ exit: true }) async getScmRepository(repoPath: string): Promise { const scope = getLogScope(); try { @@ -4808,28 +6020,28 @@ export class LocalGitProvider implements GitProvider, Disposable { } } - @log() - async getOrOpenScmRepository(repoPath: string): Promise { + @log({ exit: true }) + async getOrOpenScmRepository(repoPath: string | Uri): Promise { const scope = getLogScope(); try { + const uri = repoPath instanceof Uri ? repoPath : Uri.file(repoPath); const gitApi = await this.getScmGitApi(); - if (gitApi?.openRepository != null) { - return (await gitApi?.openRepository?.(Uri.file(repoPath))) ?? undefined; + if (gitApi == null) return undefined; + + // `getRepository` will return an opened repository that "contains" that path, so for nested repositories, we need to force the opening of the nested path, otherwise we will only get the root repository + let repo = gitApi.getRepository(uri); + if (repo == null || (repo != null && repo.rootUri.toString() !== uri.toString())) { + Logger.debug( + scope, + repo == null + ? '\u2022 no existing repository found, opening repository...' + : `\u2022 existing, non-matching repository '${repo.rootUri.toString( + true, + )}' found, opening repository...`, + ); + repo = await gitApi.openRepository?.(uri); } - - return gitApi?.getRepository(Uri.file(repoPath)) ?? undefined; - } catch (ex) { - Logger.error(ex, scope); - return undefined; - } - } - - @log() - private async openScmRepository(uri: Uri): Promise { - const scope = getLogScope(); - try { - const gitApi = await this.getScmGitApi(); - return (await gitApi?.openRepository?.(uri)) ?? undefined; + return repo ?? undefined; } catch (ex) { Logger.error(ex, scope); return undefined; @@ -4845,7 +6057,10 @@ export class LocalGitProvider implements GitProvider, Disposable { } } -function getEncoding(uri: Uri): string { - const encoding = configuration.getAny('files.encoding', uri); - return encoding != null && encodingExists(encoding) ? encoding : 'utf8'; +async function getEncoding(uri: Uri): Promise { + const encoding = configuration.getCore('files.encoding', uri); + if (encoding == null || encoding === 'utf8') return 'utf8'; + + const encodingExists = (await import(/* webpackChunkName: "lib-encoding" */ 'iconv-lite')).encodingExists; + return encodingExists(encoding) ? encoding : 'utf8'; } diff --git a/src/env/node/git/locator.ts b/src/env/node/git/locator.ts index fa93bb5f6732c..be05c6626e3d9 100644 --- a/src/env/node/git/locator.ts +++ b/src/env/node/git/locator.ts @@ -1,8 +1,8 @@ import { join as joinPaths } from 'path'; import * as process from 'process'; -import { GlyphChars, LogLevel } from '../../../constants'; +import { GlyphChars } from '../../../constants'; import { any } from '../../../system/promise'; -import { Stopwatch } from '../../../system/stopwatch'; +import { maybeStopWatch } from '../../../system/stopwatch'; import { findExecutable, run } from './shell'; export class UnableToFindGitError extends Error { @@ -27,13 +27,13 @@ export interface GitLocation { } async function findSpecificGit(path: string): Promise { - const sw = new Stopwatch(`findSpecificGit(${path})`, { logLevel: LogLevel.Debug }); + const sw = maybeStopWatch(`findSpecificGit(${path})`, { logLevel: 'debug' }); let version; try { version = await run(path, ['--version'], 'utf8'); } catch (ex) { - sw.stop({ message: ` ${GlyphChars.Dot} Unable to find git: ${ex}` }); + sw?.stop({ message: ` ${GlyphChars.Dot} Unable to find git: ${ex}` }); if (/bad config/i.test(ex.message)) throw new InvalidGitConfigError(ex); throw ex; @@ -47,7 +47,7 @@ async function findSpecificGit(path: string): Promise { try { version = await run(foundPath, ['--version'], 'utf8'); } catch (ex) { - sw.stop({ message: ` ${GlyphChars.Dot} Unable to find git: ${ex}` }); + sw?.stop({ message: ` ${GlyphChars.Dot} Unable to find git: ${ex}` }); if (/bad config/i.test(ex.message)) throw new InvalidGitConfigError(ex); throw ex; @@ -61,7 +61,7 @@ async function findSpecificGit(path: string): Promise { .replace(/^git version /, '') .trim(); - sw.stop({ message: ` ${GlyphChars.Dot} Found ${parsed} in ${path}; ${version}` }); + sw?.stop({ message: ` ${GlyphChars.Dot} Found ${parsed} in ${path}; ${version}` }); return { path: path, @@ -72,16 +72,16 @@ async function findSpecificGit(path: string): Promise { async function findGitDarwin(): Promise { try { const path = (await run('which', ['git'], 'utf8')).trim(); - if (path !== '/usr/bin/git') return findSpecificGit(path); + if (path !== '/usr/bin/git') return await findSpecificGit(path); try { await run('xcode-select', ['-p'], 'utf8'); - return findSpecificGit(path); + return await findSpecificGit(path); } catch (ex) { if (ex.code === 2) { - return Promise.reject(new UnableToFindGitError(ex)); + return await Promise.reject(new UnableToFindGitError(ex)); } - return findSpecificGit(path); + return await findSpecificGit(path); } } catch (ex) { return Promise.reject( @@ -114,7 +114,7 @@ export async function findGitPath( } try { - return any(...paths.map(p => findSpecificGit(p))); + return await any(...paths.map(p => findSpecificGit(p))); } catch (ex) { throw new UnableToFindGitError(ex); } @@ -134,7 +134,7 @@ export async function findGitPath( case 'win32': return await findGitWin32(); default: - return Promise.reject(new UnableToFindGitError()); + return await Promise.reject(new UnableToFindGitError()); } } catch (ex) { return Promise.reject( diff --git a/src/env/node/git/shell.ts b/src/env/node/git/shell.ts index 12aac7dd8b674..823c36e3de528 100644 --- a/src/env/node/git/shell.ts +++ b/src/env/node/git/shell.ts @@ -1,12 +1,12 @@ import type { ExecException } from 'child_process'; -import { execFile } from 'child_process'; +import { exec, execFile } from 'child_process'; import type { Stats } from 'fs'; -import { exists, existsSync, statSync } from 'fs'; +import { access, constants, existsSync, statSync } from 'fs'; import { join as joinPaths } from 'path'; import * as process from 'process'; -import { decode } from 'iconv-lite'; import type { CancellationToken } from 'vscode'; -import { Logger } from '../../../logger'; +import { Logger } from '../../../system/logger'; +import { normalizePath } from '../../../system/path'; export const isWindows = process.platform === 'win32'; @@ -117,6 +117,19 @@ export function findExecutable(exe: string, args: string[]): { cmd: string; args return { cmd: exe, args: args }; } +export async function getWindowsShortPath(path: string): Promise { + return new Promise((resolve, reject) => { + exec(`for %I in ("${path}") do @echo %~sI`, (error, stdout, _stderr) => { + if (error != null) { + reject(error); + return; + } + + resolve(normalizePath(stdout.trim())); + }); + }); +} + export interface RunOptions { cancellation?: CancellationToken; cwd?: string; @@ -214,11 +227,11 @@ export function run( encoding: BufferEncoding | 'buffer' | string, options?: RunOptions & { exitCodeOnly?: boolean }, ): Promise { - const { stdin, stdinEncoding, ...opts }: RunOptions = { maxBuffer: 100 * 1024 * 1024, ...options }; + const { stdin, stdinEncoding, ...opts }: RunOptions = { maxBuffer: 1000 * 1024 * 1024, ...options }; let killed = false; return new Promise((resolve, reject) => { - const proc = execFile(command, args, opts, (error: ExecException | null, stdout, stderr) => { + const proc = execFile(command, args, opts, async (error: ExecException | null, stdout, stderr) => { if (killed) return; if (options?.exitCodeOnly) { @@ -232,17 +245,18 @@ export function run( error.message = `Command output exceeded the allocated stdout buffer. Set 'options.maxBuffer' to a larger value than ${opts.maxBuffer} bytes`; } - reject( - new RunError( - error, - encoding === 'utf8' || encoding === 'binary' || encoding === 'buffer' - ? stdout - : decode(Buffer.from(stdout, 'binary'), encoding), - encoding === 'utf8' || encoding === 'binary' || encoding === 'buffer' - ? stderr - : decode(Buffer.from(stderr, 'binary'), encoding), - ), - ); + let stdoutDecoded: string; + let stderrDecoded: string; + if (encoding === 'utf8' || encoding === 'binary' || encoding === 'buffer') { + // stdout & stderr can be `Buffer` or `string + stdoutDecoded = stdout.toString(); + stderrDecoded = stderr.toString(); + } else { + const decode = (await import(/* webpackChunkName: "lib-encoding" */ 'iconv-lite')).decode; + stdoutDecoded = decode(Buffer.from(stdout, 'binary'), encoding); + stderrDecoded = decode(Buffer.from(stderr, 'binary'), encoding); + } + reject(new RunError(error, stdoutDecoded, stderrDecoded)); return; } @@ -251,11 +265,12 @@ export function run( Logger.warn(`Warning(${command} ${args.join(' ')}): ${stderr}`); } - resolve( - encoding === 'utf8' || encoding === 'binary' || encoding === 'buffer' - ? (stdout as T) - : (decode(Buffer.from(stdout, 'binary'), encoding) as T), - ); + if (encoding === 'utf8' || encoding === 'binary' || encoding === 'buffer') { + resolve(stdout as T); + } else { + const decode = (await import(/* webpackChunkName: "lib-encoding" */ 'iconv-lite')).decode; + resolve(decode(Buffer.from(stdout, 'binary'), encoding) as T); + } }); options?.cancellation?.onCancellationRequested(() => { @@ -275,6 +290,6 @@ export function run( }); } -export function fsExists(path: string) { - return new Promise(resolve => exists(path, exists => resolve(exists))); +export async function fsExists(path: string) { + return new Promise(resolve => access(path, constants.F_OK, err => resolve(err == null))); } diff --git a/src/env/node/git/vslsGitProvider.ts b/src/env/node/git/vslsGitProvider.ts index f30d882faaac3..df482f9b5daf1 100644 --- a/src/env/node/git/vslsGitProvider.ts +++ b/src/env/node/git/vslsGitProvider.ts @@ -1,13 +1,13 @@ +import type { ChildProcess } from 'child_process'; import { FileType, Uri, workspace } from 'vscode'; import { Schemes } from '../../../constants'; import { Container } from '../../../container'; -import type { GitCommandOptions } from '../../../git/commandOptions'; +import type { GitCommandOptions, GitSpawnOptions } from '../../../git/commandOptions'; import type { GitProviderDescriptor } from '../../../git/gitProvider'; -import { GitProviderId } from '../../../git/gitProvider'; import type { Repository } from '../../../git/models/repository'; -import { Logger } from '../../../logger'; -import { getLogScope } from '../../../logScope'; -import { addVslsPrefixIfNeeded } from '../../../system/path'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; +import { addVslsPrefixIfNeeded } from '../../../system/vscode/path'; import { Git } from './git'; import { LocalGitProvider } from './localGitProvider'; @@ -31,15 +31,37 @@ export class VslsGit extends Git { return guest.git(options, ...args); } + + // eslint-disable-next-line @typescript-eslint/require-await + override async gitSpawn(_options: GitSpawnOptions, ..._args: any[]): Promise { + debugger; + throw new Error('Git spawn not supported in Live Share'); + } + + override async logStreamTo( + repoPath: string, + sha: string, + limit: number, + options?: { configs?: readonly string[]; stdin?: string }, + ...args: string[] + ): Promise<[data: string[], count: number]> { + const guest = await Container.instance.vsls.guest(); + if (guest == null) { + debugger; + throw new Error('No guest'); + } + + return guest.gitLogStreamTo(repoPath, sha, limit, options, ...args); + } } export class VslsGitProvider extends LocalGitProvider { override readonly descriptor: GitProviderDescriptor = { - id: GitProviderId.Vsls, + id: 'vsls', name: 'Live Share', virtual: false, }; - override readonly supportedSchemes: Set = new Set([Schemes.Vsls, Schemes.VslsScc]); + override readonly supportedSchemes = new Set([Schemes.Vsls, Schemes.VslsScc]); override async discoverRepositories(uri: Uri): Promise { if (!this.supportedSchemes.has(uri.scheme)) return []; @@ -64,7 +86,8 @@ export class VslsGitProvider extends LocalGitProvider { override canHandlePathOrUri(scheme: string, pathOrUri: string | Uri): string | undefined { // TODO@eamodio To support virtual repositories, we need to verify that the path is local here (by converting the shared path to a local path) - return super.canHandlePathOrUri(scheme, pathOrUri); + const path = super.canHandlePathOrUri(scheme, pathOrUri); + return path != null ? `${scheme}:${path}` : undefined; } override getAbsoluteUri(pathOrUri: string | Uri, base: string | Uri): Uri { @@ -93,7 +116,13 @@ export class VslsGitProvider extends LocalGitProvider { uri = Uri.joinPath(uri, '..'); } - repoPath = await this.git.rev_parse__show_toplevel(uri.fsPath); + let safe; + [safe, repoPath] = await this.git.rev_parse__show_toplevel(uri.fsPath); + if (safe) { + this.unsafePaths.delete(uri.fsPath); + } else { + this.unsafePaths.add(uri.fsPath); + } if (!repoPath) return undefined; return repoPath ? Uri.parse(repoPath, true) : undefined; diff --git a/src/env/node/pathMapping/repositoryLocalPathMappingProvider.ts b/src/env/node/pathMapping/repositoryLocalPathMappingProvider.ts new file mode 100644 index 0000000000000..9c77d7027f421 --- /dev/null +++ b/src/env/node/pathMapping/repositoryLocalPathMappingProvider.ts @@ -0,0 +1,115 @@ +import type { Disposable } from 'vscode'; +import { workspace } from 'vscode'; +import type { Container } from '../../../container'; +import type { LocalRepoDataMap } from '../../../pathMapping/models'; +import type { RepositoryPathMappingProvider } from '../../../pathMapping/repositoryPathMappingProvider'; +import { Logger } from '../../../system/logger'; +import { + acquireSharedFolderWriteLock, + getSharedRepositoryMappingFileUri, + releaseSharedFolderWriteLock, +} from './sharedGKDataFolder'; + +export class RepositoryLocalPathMappingProvider implements RepositoryPathMappingProvider, Disposable { + constructor(private readonly container: Container) {} + + dispose() {} + + private _localRepoDataMap: LocalRepoDataMap | undefined = undefined; + + private async ensureLocalRepoDataMap() { + if (this._localRepoDataMap == null) { + await this.loadLocalRepoDataMap(); + } + } + + private async getLocalRepoDataMap(): Promise { + await this.ensureLocalRepoDataMap(); + return this._localRepoDataMap ?? {}; + } + + async getLocalRepoPaths(options: { + remoteUrl?: string; + repoInfo?: { provider?: string; owner?: string; repoName?: string }; + }): Promise { + const paths: string[] = []; + if (options.remoteUrl != null) { + const remoteUrlPaths = await this._getLocalRepoPaths(options.remoteUrl); + if (remoteUrlPaths != null) { + paths.push(...remoteUrlPaths); + } + } + if (options.repoInfo != null) { + const { provider, owner, repoName } = options.repoInfo; + if (provider != null && owner != null && repoName != null) { + const repoInfoPaths = await this._getLocalRepoPaths(`${provider}/${owner}/${repoName}`); + if (repoInfoPaths != null) { + paths.push(...repoInfoPaths); + } + } + } + + return paths; + } + + private async _getLocalRepoPaths(key: string): Promise { + const localRepoDataMap = await this.getLocalRepoDataMap(); + return localRepoDataMap[key]?.paths; + } + + private async loadLocalRepoDataMap() { + const localFileUri = getSharedRepositoryMappingFileUri(); + try { + const data = await workspace.fs.readFile(localFileUri); + this._localRepoDataMap = (JSON.parse(data.toString()) ?? {}) as LocalRepoDataMap; + } catch (error) { + Logger.error(error, 'loadLocalRepoDataMap'); + } + } + + async writeLocalRepoPath( + options: { remoteUrl?: string; repoInfo?: { provider?: string; owner?: string; repoName?: string } }, + localPath: string, + ): Promise { + if (options.remoteUrl != null) { + await this._writeLocalRepoPath(options.remoteUrl, localPath); + } + if ( + options.repoInfo?.provider != null && + options.repoInfo?.owner != null && + options.repoInfo?.repoName != null + ) { + const { provider, owner, repoName } = options.repoInfo; + const key = `${provider}/${owner}/${repoName}`; + await this._writeLocalRepoPath(key, localPath); + } + } + + private async _writeLocalRepoPath(key: string, localPath: string): Promise { + if (!key || !localPath || !(await acquireSharedFolderWriteLock())) { + return; + } + + await this.loadLocalRepoDataMap(); + if (this._localRepoDataMap == null) { + this._localRepoDataMap = {}; + } + + if (this._localRepoDataMap[key] == null) { + this._localRepoDataMap[key] = { paths: [localPath] }; + } else if (this._localRepoDataMap[key].paths == null) { + this._localRepoDataMap[key].paths = [localPath]; + } else if (!this._localRepoDataMap[key].paths.includes(localPath)) { + this._localRepoDataMap[key].paths.push(localPath); + } + + const localFileUri = getSharedRepositoryMappingFileUri(); + const outputData = new Uint8Array(Buffer.from(JSON.stringify(this._localRepoDataMap))); + try { + await workspace.fs.writeFile(localFileUri, outputData); + } catch (error) { + Logger.error(error, 'writeLocalRepoPath'); + } + await releaseSharedFolderWriteLock(); + } +} diff --git a/src/env/node/pathMapping/sharedGKDataFolder.ts b/src/env/node/pathMapping/sharedGKDataFolder.ts new file mode 100644 index 0000000000000..2a89f174f219f --- /dev/null +++ b/src/env/node/pathMapping/sharedGKDataFolder.ts @@ -0,0 +1,80 @@ +import os from 'os'; +import path from 'path'; +import { Uri, workspace } from 'vscode'; +import { Logger } from '../../../system/logger'; +import { wait } from '../../../system/promise'; +import { getPlatform } from '../platform'; + +export const sharedGKDataFolder = '.gk'; + +export async function acquireSharedFolderWriteLock(): Promise { + const lockFileUri = getSharedLockFileUri(); + + let stat; + while (true) { + try { + stat = await workspace.fs.stat(lockFileUri); + } catch { + // File does not exist, so we can safely create it + break; + } + + const currentTime = new Date().getTime(); + if (currentTime - stat.ctime > 30000) { + // File exists, but the timestamp is older than 30 seconds, so we can safely remove it + break; + } + + // File exists, and the timestamp is less than 30 seconds old, so we need to wait for it to be removed + await wait(100); + } + + try { + // write the lockfile to the shared data folder + await workspace.fs.writeFile(lockFileUri, new Uint8Array(0)); + } catch (error) { + Logger.error(error, 'acquireSharedFolderWriteLock'); + return false; + } + + return true; +} + +export async function releaseSharedFolderWriteLock(): Promise { + try { + const lockFileUri = getSharedLockFileUri(); + await workspace.fs.delete(lockFileUri); + } catch (error) { + Logger.error(error, 'releaseSharedFolderWriteLock'); + return false; + } + + return true; +} + +function getSharedLockFileUri() { + return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'lockfile')); +} + +export function getSharedRepositoryMappingFileUri() { + return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'repoMapping.json')); +} + +export function getSharedCloudWorkspaceMappingFileUri() { + return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'cloudWorkspaces.json')); +} + +export function getSharedLocalWorkspaceMappingFileUri() { + return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'localWorkspaces.json')); +} + +export function getSharedLegacyLocalWorkspaceMappingFileUri() { + return Uri.file( + path.join( + os.homedir(), + `${getPlatform() === 'windows' ? '/AppData/Roaming/' : ''}.gitkraken`, + 'workspaces', + 'workspaces.json', + ), + ); +} diff --git a/src/env/node/pathMapping/workspacesLocalPathMappingProvider.ts b/src/env/node/pathMapping/workspacesLocalPathMappingProvider.ts new file mode 100644 index 0000000000000..2f4d66df16ac1 --- /dev/null +++ b/src/env/node/pathMapping/workspacesLocalPathMappingProvider.ts @@ -0,0 +1,242 @@ +import { Uri, workspace } from 'vscode'; +import type { + CloudWorkspacesPathMap, + CodeWorkspaceFileContents, + LocalWorkspaceFileData, + WorkspaceAutoAddSetting, +} from '../../../plus/workspaces/models'; +import type { WorkspacesPathMappingProvider } from '../../../plus/workspaces/workspacesPathMappingProvider'; +import { Logger } from '../../../system/logger'; +import { + acquireSharedFolderWriteLock, + getSharedCloudWorkspaceMappingFileUri, + getSharedLegacyLocalWorkspaceMappingFileUri, + getSharedLocalWorkspaceMappingFileUri, + releaseSharedFolderWriteLock, +} from './sharedGKDataFolder'; + +export class WorkspacesLocalPathMappingProvider implements WorkspacesPathMappingProvider { + private _cloudWorkspacePathMap: CloudWorkspacesPathMap | undefined = undefined; + + private async ensureCloudWorkspacePathMap(): Promise { + if (this._cloudWorkspacePathMap == null) { + await this.loadCloudWorkspacePathMap(); + } + } + + private async getCloudWorkspacePathMap(): Promise { + await this.ensureCloudWorkspacePathMap(); + return this._cloudWorkspacePathMap ?? {}; + } + + private async loadCloudWorkspacePathMap(): Promise { + const localFileUri = getSharedCloudWorkspaceMappingFileUri(); + try { + const data = await workspace.fs.readFile(localFileUri); + this._cloudWorkspacePathMap = (JSON.parse(data.toString())?.workspaces ?? {}) as CloudWorkspacesPathMap; + } catch (error) { + Logger.error(error, 'loadCloudWorkspacePathMap'); + } + } + + async getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise { + const cloudWorkspacePathMap = await this.getCloudWorkspacePathMap(); + return cloudWorkspacePathMap[cloudWorkspaceId]?.repoPaths?.[repoId]; + } + + async getCloudWorkspaceCodeWorkspacePath(cloudWorkspaceId: string): Promise { + const cloudWorkspacePathMap = await this.getCloudWorkspacePathMap(); + return cloudWorkspacePathMap[cloudWorkspaceId]?.externalLinks?.['.code-workspace']; + } + + async removeCloudWorkspaceCodeWorkspaceFilePath(cloudWorkspaceId: string): Promise { + if (!(await acquireSharedFolderWriteLock())) { + return; + } + + await this.loadCloudWorkspacePathMap(); + + if (this._cloudWorkspacePathMap?.[cloudWorkspaceId]?.externalLinks?.['.code-workspace'] == null) return; + + delete this._cloudWorkspacePathMap[cloudWorkspaceId].externalLinks['.code-workspace']; + + const localFileUri = getSharedCloudWorkspaceMappingFileUri(); + const outputData = new Uint8Array(Buffer.from(JSON.stringify({ workspaces: this._cloudWorkspacePathMap }))); + try { + await workspace.fs.writeFile(localFileUri, outputData); + } catch (error) { + Logger.error(error, 'writeCloudWorkspaceCodeWorkspaceFilePathToMap'); + } + await releaseSharedFolderWriteLock(); + } + + async confirmCloudWorkspaceCodeWorkspaceFilePath(cloudWorkspaceId: string): Promise { + const cloudWorkspacePathMap = await this.getCloudWorkspacePathMap(); + const codeWorkspaceFilePath = cloudWorkspacePathMap[cloudWorkspaceId]?.externalLinks?.['.code-workspace']; + if (codeWorkspaceFilePath == null) return false; + try { + await workspace.fs.stat(Uri.file(codeWorkspaceFilePath)); + return true; + } catch { + return false; + } + } + + async writeCloudWorkspaceRepoDiskPathToMap( + cloudWorkspaceId: string, + repoId: string, + repoLocalPath: string, + ): Promise { + if (!(await acquireSharedFolderWriteLock())) { + return; + } + + await this.loadCloudWorkspacePathMap(); + + if (this._cloudWorkspacePathMap == null) { + this._cloudWorkspacePathMap = {}; + } + + if (this._cloudWorkspacePathMap[cloudWorkspaceId] == null) { + this._cloudWorkspacePathMap[cloudWorkspaceId] = { repoPaths: {}, externalLinks: {} }; + } + + if (this._cloudWorkspacePathMap[cloudWorkspaceId].repoPaths == null) { + this._cloudWorkspacePathMap[cloudWorkspaceId].repoPaths = {}; + } + + this._cloudWorkspacePathMap[cloudWorkspaceId].repoPaths[repoId] = repoLocalPath; + + const localFileUri = getSharedCloudWorkspaceMappingFileUri(); + const outputData = new Uint8Array(Buffer.from(JSON.stringify({ workspaces: this._cloudWorkspacePathMap }))); + try { + await workspace.fs.writeFile(localFileUri, outputData); + } catch (error) { + Logger.error(error, 'writeCloudWorkspaceRepoDiskPathToMap'); + } + await releaseSharedFolderWriteLock(); + } + + async writeCloudWorkspaceCodeWorkspaceFilePathToMap( + cloudWorkspaceId: string, + codeWorkspaceFilePath: string, + ): Promise { + if (!(await acquireSharedFolderWriteLock())) { + return; + } + + await this.loadCloudWorkspacePathMap(); + + if (this._cloudWorkspacePathMap == null) { + this._cloudWorkspacePathMap = {}; + } + + if (this._cloudWorkspacePathMap[cloudWorkspaceId] == null) { + this._cloudWorkspacePathMap[cloudWorkspaceId] = { repoPaths: {}, externalLinks: {} }; + } + + if (this._cloudWorkspacePathMap[cloudWorkspaceId].externalLinks == null) { + this._cloudWorkspacePathMap[cloudWorkspaceId].externalLinks = {}; + } + + this._cloudWorkspacePathMap[cloudWorkspaceId].externalLinks['.code-workspace'] = codeWorkspaceFilePath; + + const localFileUri = getSharedCloudWorkspaceMappingFileUri(); + const outputData = new Uint8Array(Buffer.from(JSON.stringify({ workspaces: this._cloudWorkspacePathMap }))); + try { + await workspace.fs.writeFile(localFileUri, outputData); + } catch (error) { + Logger.error(error, 'writeCloudWorkspaceCodeWorkspaceFilePathToMap'); + } + await releaseSharedFolderWriteLock(); + } + + // TODO@ramint: May want a file watcher on this file down the line + async getLocalWorkspaceData(): Promise { + // Read from file at path defined in the constant localWorkspaceDataFilePath + // If file does not exist, create it and return an empty object + let localFileUri; + let data; + try { + localFileUri = getSharedLocalWorkspaceMappingFileUri(); + data = await workspace.fs.readFile(localFileUri); + if (data?.length) return JSON.parse(data.toString()) as LocalWorkspaceFileData; + } catch (_ex) { + // Fall back to using legacy location for file + try { + localFileUri = getSharedLegacyLocalWorkspaceMappingFileUri(); + data = await workspace.fs.readFile(localFileUri); + if (data?.length) return JSON.parse(data.toString()) as LocalWorkspaceFileData; + } catch (ex) { + Logger.error(ex, 'getLocalWorkspaceData'); + } + } + + return { workspaces: {} }; + } + + async writeCodeWorkspaceFile( + uri: Uri, + workspaceRepoFilePaths: string[], + options?: { workspaceId?: string; workspaceAutoAddSetting?: WorkspaceAutoAddSetting }, + ): Promise { + let codeWorkspaceFileContents: CodeWorkspaceFileContents; + let data; + try { + data = await workspace.fs.readFile(uri); + codeWorkspaceFileContents = JSON.parse(data.toString()) as CodeWorkspaceFileContents; + } catch (_ex) { + codeWorkspaceFileContents = { folders: [], settings: {} }; + } + + codeWorkspaceFileContents.folders = workspaceRepoFilePaths.map(repoFilePath => ({ path: repoFilePath })); + if (options?.workspaceId != null) { + codeWorkspaceFileContents.settings['gitkraken.workspaceId'] = options.workspaceId; + } + + if (options?.workspaceAutoAddSetting != null) { + codeWorkspaceFileContents.settings['gitkraken.workspaceAutoAddSetting'] = options.workspaceAutoAddSetting; + } + + const outputData = new Uint8Array(Buffer.from(JSON.stringify(codeWorkspaceFileContents))); + try { + await workspace.fs.writeFile(uri, outputData); + if (options?.workspaceId != null) { + await this.writeCloudWorkspaceCodeWorkspaceFilePathToMap(options.workspaceId, uri.fsPath); + } + } catch (error) { + Logger.error(error, 'writeCodeWorkspaceFile'); + return false; + } + + return true; + } + + async updateCodeWorkspaceFileSettings( + uri: Uri, + options: { workspaceAutoAddSetting?: WorkspaceAutoAddSetting }, + ): Promise { + let codeWorkspaceFileContents: CodeWorkspaceFileContents; + let data; + try { + data = await workspace.fs.readFile(uri); + codeWorkspaceFileContents = JSON.parse(data.toString()) as CodeWorkspaceFileContents; + } catch (_ex) { + return false; + } + + if (options.workspaceAutoAddSetting != null) { + codeWorkspaceFileContents.settings['gitkraken.workspaceAutoAddSetting'] = options.workspaceAutoAddSetting; + } + + const outputData = new Uint8Array(Buffer.from(JSON.stringify(codeWorkspaceFileContents))); + try { + await workspace.fs.writeFile(uri, outputData); + } catch (_ex) { + Logger.error(_ex, 'updateCodeWorkspaceFileSettings'); + return false; + } + + return true; + } +} diff --git a/src/env/node/platform.ts b/src/env/node/platform.ts index 6d43a0c1bdea3..daec4e98ee097 100644 --- a/src/env/node/platform.ts +++ b/src/env/node/platform.ts @@ -1,21 +1,21 @@ -import * as process from 'process'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { platform } from 'process'; import { env, UIKind } from 'vscode'; export const isWeb = env.uiKind === UIKind.Web; -export const isLinux = process.platform === 'linux'; -export const isMac = process.platform === 'darwin'; -export const isWindows = process.platform === 'win32'; +export const isLinux = platform === 'linux'; +export const isMac = platform === 'darwin'; +export const isWindows = platform === 'win32'; export function getPlatform(): string { - if (isWindows) { - return 'windows'; - } - if (isMac) { - return 'macOS'; - } - if (isLinux) { - return 'linux'; - } + if (isWindows) return 'windows'; + if (isMac) return 'macOS'; + if (isLinux) return 'linux'; return isWeb ? 'web' : 'unknown'; } + +export function getTempFile(filename: string): string { + return join(tmpdir(), filename); +} diff --git a/src/env/node/providers.ts b/src/env/node/providers.ts index 0e91d16a23751..6c64848bee413 100644 --- a/src/env/node/providers.ts +++ b/src/env/node/providers.ts @@ -1,11 +1,14 @@ -import { configuration } from '../../configuration'; import type { Container } from '../../container'; import type { GitCommandOptions } from '../../git/commandOptions'; import type { GitProvider } from '../../git/gitProvider'; +import type { IntegrationAuthenticationService } from '../../plus/integrations/authentication/integrationAuthentication'; +import { configuration } from '../../system/vscode/configuration'; // import { GitHubGitProvider } from '../../plus/github/githubGitProvider'; import { Git } from './git/git'; import { LocalGitProvider } from './git/localGitProvider'; import { VslsGit, VslsGitProvider } from './git/vslsGitProvider'; +import { RepositoryLocalPathMappingProvider } from './pathMapping/repositoryLocalPathMappingProvider'; +import { WorkspacesLocalPathMappingProvider } from './pathMapping/workspacesLocalPathMappingProvider'; let gitInstance: Git | undefined; function ensureGit() { @@ -15,11 +18,24 @@ function ensureGit() { return gitInstance; } -export function git(_options: GitCommandOptions, ..._args: any[]): Promise { - return ensureGit().git(_options, ..._args); +export function git(options: GitCommandOptions, ...args: any[]): Promise { + return ensureGit().git(options, ...args); } -export async function getSupportedGitProviders(container: Container): Promise { +export function gitLogStreamTo( + repoPath: string, + sha: string, + limit: number, + options?: { configs?: readonly string[]; stdin?: string }, + ...args: string[] +): Promise<[data: string[], count: number]> { + return ensureGit().logStreamTo(repoPath, sha, limit, options, ...args); +} + +export async function getSupportedGitProviders( + container: Container, + authenticationService: IntegrationAuthenticationService, +): Promise { const git = ensureGit(); const providers: GitProvider[] = [ @@ -28,10 +44,22 @@ export async function getSupportedGitProviders(container: Container): Promise; interface CommitSelectedEventArgs { readonly commit: GitRevisionReference | GitCommit; - readonly pin?: boolean; + readonly interaction: 'active' | 'passive'; + readonly preserveFocus?: boolean; + readonly preserveVisibility?: boolean; +} + +export type DraftSelectedEvent = EventBusEvent<'draft:selected'>; +interface DraftSelectedEventArgs { + readonly draft: LocalDraft | Draft; + readonly interaction: 'active' | 'passive'; readonly preserveFocus?: boolean; readonly preserveVisibility?: boolean; } @@ -28,39 +36,49 @@ interface GitCacheResetEventArgs { readonly caches?: GitCaches[]; } -type EventBusEventMap = { +type EventsMapping = { 'commit:selected': CommitSelectedEventArgs; + 'draft:selected': DraftSelectedEventArgs; 'file:selected': FileSelectedEventArgs; 'git:cache:reset': GitCacheResetEventArgs; }; -interface EventBusEvent { +interface EventBusEvent { name: T; - data: EventBusEventMap[T]; + data: EventsMapping[T]; source?: EventBusSource | undefined; } -export type EventBusSource = - | 'gitlens.rebase' - | `gitlens.${WebviewIds}` - | `gitlens.views.${WebviewViewIds}` - | `gitlens.views.${ViewsConfigKeys}`; +export type EventBusSource = CustomEditorIds | WebviewIds | WebviewViewIds | `gitlens.views.${ViewsConfigKeys}`; export type EventBusOptions = { source?: EventBusSource; }; +type CacheableEventsMapping = { + 'commit:selected': CommitSelectedEventArgs; + 'draft:selected': DraftSelectedEventArgs; + 'file:selected': FileSelectedEventArgs; +}; + +const _cacheableEventNames = new Set([ + 'commit:selected', + 'draft:selected', + 'file:selected', +]); +const _cachedEventArgs = new Map(); + export class EventBus implements Disposable { private readonly _emitter = new EventEmitter(); - private get event() { - return this._emitter.event; - } dispose() { this._emitter.dispose(); } - fire(name: T, data: EventBusEventMap[T], options?: EventBusOptions) { + fire(name: T, data: EventsMapping[T], options?: EventBusOptions) { + if (canCacheEventArgs(name)) { + _cachedEventArgs.set(name, data as CacheableEventsMapping[typeof name]); + } this._emitter.fire({ name: name, data: data, @@ -68,24 +86,26 @@ export class EventBus implements Disposable { }); } - fireAsync(name: T, data: EventBusEventMap[T], options?: EventBusOptions) { + fireAsync(name: T, data: EventsMapping[T], options?: EventBusOptions) { queueMicrotask(() => this.fire(name, data, options)); } - on( - eventName: T, - handler: (e: EventBusEvent) => void, - thisArgs?: unknown, - disposables?: Disposable[], - ) { - return this.event( + getCachedEventArgs(name: T): CacheableEventsMapping[T] | undefined { + return _cachedEventArgs.get(name) as CacheableEventsMapping[T] | undefined; + } + + on(name: T, handler: (e: EventBusEvent) => void, thisArgs?: unknown) { + return this._emitter.event( // eslint-disable-next-line prefer-arrow-callback function (e) { - if (eventName !== e.name) return; + if (name !== e.name) return; handler.call(thisArgs, e as EventBusEvent); }, thisArgs, - disposables, ); } } + +function canCacheEventArgs(name: keyof EventsMapping): name is keyof CacheableEventsMapping { + return _cacheableEventNames.has(name as keyof CacheableEventsMapping); +} diff --git a/src/extension.ts b/src/extension.ts index fa6c4c6c908ca..b9fa61912cfd8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,47 +1,65 @@ -import type { ExtensionContext } from 'vscode'; -import { version as codeVersion, env, ExtensionMode, extensions, Uri, window, workspace } from 'vscode'; import { hrtime } from '@env/hrtime'; import { isWeb } from '@env/platform'; +import type { ExtensionContext } from 'vscode'; +import { version as codeVersion, env, ExtensionMode, Uri, window, workspace } from 'vscode'; import { Api } from './api/api'; import type { CreatePullRequestActionContext, GitLensApi, OpenPullRequestActionContext } from './api/gitlens'; -import type { CreatePullRequestOnRemoteCommandArgs, OpenPullRequestOnRemoteCommandArgs } from './commands'; -import { configuration, Configuration, fromOutputLevel, OutputLevel } from './configuration'; -import { Commands, ContextKeys, CoreCommands, LogLevel } from './constants'; +import type { CreatePullRequestOnRemoteCommandArgs } from './commands/createPullRequestOnRemote'; +import type { OpenPullRequestOnRemoteCommandArgs } from './commands/openPullRequestOnRemote'; +import { fromOutputLevel } from './config'; +import { trackableSchemes } from './constants'; +import { Commands } from './constants.commands'; +import { SyncedStorageKeys } from './constants.storage'; import { Container } from './container'; -import { setContext } from './context'; import { isGitUri } from './git/gitUri'; import { getBranchNameWithoutRemote, isBranch } from './git/models/branch'; import { isCommit } from './git/models/commit'; import { isRepository } from './git/models/repository'; import { isTag } from './git/models/tag'; -import { Logger } from './logger'; -import { - showDebugLoggingWarningMessage, - showInsidersErrorMessage, - showPreReleaseExpiredErrorMessage, - showWhatsNewMessage, -} from './messages'; +import { showDebugLoggingWarningMessage, showPreReleaseExpiredErrorMessage, showWhatsNewMessage } from './messages'; import { registerPartnerActionRunners } from './partners'; -import { Storage, SyncedStorageKeys } from './storage'; -import { executeCommand, executeCoreCommand, registerCommands } from './system/command'; import { setDefaultDateLocales } from './system/date'; import { once } from './system/event'; +import { BufferedLogChannel, getLoggableName, Logger } from './system/logger'; import { flatten } from './system/object'; import { Stopwatch } from './system/stopwatch'; import { compare, fromString, satisfies } from './system/version'; -import { isViewNode } from './views/nodes/viewNode'; +import { executeCommand, registerCommands } from './system/vscode/command'; +import { configuration, Configuration } from './system/vscode/configuration'; +import { setContext } from './system/vscode/context'; +import { Storage } from './system/vscode/storage'; +import { isTextDocument, isTextEditor, isWorkspaceFolder } from './system/vscode/utils'; +import { isViewNode } from './views/nodes/abstract/viewNode'; +import './commands'; export async function activate(context: ExtensionContext): Promise { const gitlensVersion: string = context.extension.packageJSON.version; - const insiders = context.extension.id === 'eamodio.gitlens-insiders'; - const prerelease = insiders || satisfies(gitlensVersion, '> 2020.0.0'); + const prerelease = satisfies(gitlensVersion, '> 2020.0.0'); - const outputLevel = configuration.get('outputLevel'); + const defaultDateLocale = configuration.get('defaultDateLocale'); + const logLevel = fromOutputLevel(configuration.get('outputLevel')); Logger.configure( { name: 'GitLens', createChannel: function (name: string) { - return window.createOutputChannel(name); + const channel = new BufferedLogChannel(window.createOutputChannel(name), 500); + context.subscriptions.push(channel); + + if (logLevel === 'error' || logLevel === 'warn') { + channel.appendLine( + `GitLens${prerelease ? ' (pre-release)' : ''} v${gitlensVersion} activating in ${ + env.appName + } (${codeVersion}) on the ${isWeb ? 'web' : 'desktop'}; language='${ + env.language + }', logLevel='${logLevel}', defaultDateLocale='${defaultDateLocale}' (${env.machineId}|${ + env.sessionId + })`, + ); + channel.appendLine( + 'To enable debug logging, set `"gitlens.outputLevel: "debug"` or run "GitLens: Enable Debug Logging" from the Command Palette', + ); + } + return channel; }, toLoggable: function (o: any) { if (isGitUri(o)) { @@ -53,39 +71,45 @@ export async function activate(context: ExtensionContext): Promise `[${s.anchor.line}:${s.anchor.character}-${s.active.line}:${s.active.character}]`) + .join(',')})`; + } + return undefined; }, }, - fromOutputLevel(configuration.get('outputLevel')), + logLevel, context.extensionMode === ExtensionMode.Development, ); - const sw = new Stopwatch( - `GitLens${prerelease ? (insiders ? ' (Insiders)' : ' (pre-release)') : ''} v${gitlensVersion}`, - { - log: { - message: ` activating in ${env.appName}(${codeVersion}) on the ${isWeb ? 'web' : 'desktop'} (${ - env.machineId - }|${env.sessionId})`, - //${context.extensionRuntime !== ExtensionRuntime.Node ? ' in a webworker' : ''} - }, + const sw = new Stopwatch(`GitLens${prerelease ? ' (pre-release)' : ''} v${gitlensVersion}`, { + log: { + message: ` activating in ${env.appName} (${codeVersion}) on the ${isWeb ? 'web' : 'desktop'}; language='${ + env.language + }', logLevel='${logLevel}', defaultDateLocale='${defaultDateLocale}' (${env.machineId}|${env.sessionId})`, + //${context.extensionRuntime !== ExtensionRuntime.Node ? ' in a webworker' : ''} }, - ); - - // If we are using the separate insiders extension, ensure that stable isn't also installed - if (insiders) { - const stable = extensions.getExtension('eamodio.gitlens'); - if (stable != null) { - sw.stop({ message: ' was NOT activated because GitLens is also enabled' }); - - // If we don't use a setTimeout here this notification will get lost for some reason - setTimeout(showInsidersErrorMessage, 0); - - return undefined; - } - } + }); - // Ensure that this pre-release or insiders version hasn't expired + // Ensure that this pre-release version hasn't expired if (prerelease) { const v = fromString(gitlensVersion); // Get the build date from the version number @@ -94,33 +118,25 @@ export async function activate(context: ExtensionContext): Promise { - void setContext(ContextKeys.Untrusted, undefined); - container.telemetry.setGlobalAttribute('workspace.isTrusted', workspace.isTrusted); - }), - ); + void setContext('gitlens:untrusted', true); } setKeysForSync(context); const storage = new Storage(context); - const syncedVersion = storage.get(prerelease && !insiders ? 'synced:preVersion' : 'synced:version'); - const localVersion = storage.get(prerelease && !insiders ? 'preVersion' : 'version'); + const syncedVersion = storage.get(prerelease ? 'synced:preVersion' : 'synced:version'); + const localVersion = storage.get(prerelease ? 'preVersion' : 'version'); let previousVersion: string | undefined; if (localVersion == null || syncedVersion == null) { @@ -132,7 +148,7 @@ export async function activate(context: ExtensionContext): Promise { if (configuration.changed(e, 'defaultDateLocale')) { - setDefaultDateLocales(configuration.get('defaultDateLocale', undefined, env.language)); + setDefaultDateLocales(configuration.get('defaultDateLocale') ?? env.language); } }), ); @@ -161,18 +177,27 @@ export async function activate(context: ExtensionContext): Promise { + void setContext('gitlens:untrusted', undefined); + container.telemetry.setGlobalAttribute('workspace.isTrusted', workspace.isTrusted); + }), + ); + } + + void showWelcomeOrWhatsNew(container, gitlensVersion, prerelease, previousVersion); - void storage.store(prerelease && !insiders ? 'preVersion' : 'version', gitlensVersion); + void storage.store(prerelease ? 'preVersion' : 'version', gitlensVersion); // Only update our synced version if the new version is greater if (syncedVersion == null || compare(gitlensVersion, syncedVersion) === 1) { - void storage.store(prerelease && !insiders ? 'synced:preVersion' : 'synced:version', gitlensVersion); + void storage.store(prerelease ? 'synced:preVersion' : 'synced:version', gitlensVersion); } - if (outputLevel === OutputLevel.Debug) { + if (logLevel === 'debug') { setTimeout(async () => { - if (configuration.get('outputLevel') !== OutputLevel.Debug) return; + if (fromOutputLevel(configuration.get('outputLevel')) !== 'debug') return; if (!container.prereleaseOrDebugging) { if (await showDebugLoggingWarningMessage()) { @@ -183,20 +208,26 @@ export async function activate(context: ExtensionContext): Promise parseInt(v, 10)); - const [prevMajor, prevMinor] = previousVersion.split('.').map(v => parseInt(v, 10)); + const current = fromString(version); + const previous = fromString(previousVersion); // Don't notify on downgrades - if (major === prevMajor || major < prevMajor || (major === prevMajor && minor < prevMinor)) { + if (current.major < previous.major || (current.major === previous.major && current.minor < previous.minor)) { return; } - if (major !== prevMajor) { - version = String(major); - } + const majorPrerelease = prerelease && satisfies(previous, '< 2023.6.0800'); - void executeCommand(Commands.ShowHomeView); + if (current.major === previous.major && !majorPrerelease) return; + + version = majorPrerelease ? '14' : String(current.major); if (configuration.get('showWhatsNewAfterUpgrades')) { if (window.state.focused) { @@ -363,9 +394,3 @@ async function showWelcomeOrWhatsNew(container: Container, version: string, prev } } } - -function uninstallDeprecatedAuthentication() { - if (extensions.getExtension('gitkraken.gitkraken-authentication') == null) return; - - void executeCoreCommand(CoreCommands.UninstallExtension, 'gitkraken.gitkraken-authentication'); -} diff --git a/src/features.ts b/src/features.ts index ac01f0f637cc8..b7b4d2bd8d6d8 100644 --- a/src/features.ts +++ b/src/features.ts @@ -1,10 +1,12 @@ import type { RepositoryVisibility } from './git/gitProvider'; -import type { RequiredSubscriptionPlans, Subscription } from './subscription'; +import type { RequiredSubscriptionPlans, Subscription } from './plus/gk/account/subscription'; export const enum Features { Stashes = 'stashes', Timeline = 'timeline', Worktrees = 'worktrees', + StashOnlyStaged = 'stashOnlyStaged', + ForceIfIncludes = 'forceIfIncludes', } export type FeatureAccess = @@ -35,5 +37,5 @@ export const enum PlusFeatures { Timeline = 'timeline', Worktrees = 'worktrees', Graph = 'graph', - Focus = 'focus', + Launchpad = 'launchpad', } diff --git a/src/git/actions.ts b/src/git/actions.ts index e3052e133a762..25a39ec5c330c 100644 --- a/src/git/actions.ts +++ b/src/git/actions.ts @@ -1,12 +1,9 @@ import type { Uri } from 'vscode'; -import type { - BrowseRepoAtRevisionCommandArgs, - GitCommandsCommandArgs, - GitCommandsCommandArgsWithCompletion, -} from '../commands'; -import { Commands } from '../constants'; -import { executeCommand, executeEditorCommand } from '../system/command'; +import type { BrowseRepoAtRevisionCommandArgs } from '../commands/browseRepoAtRevision'; +import type { GitCommandsCommandArgs, GitCommandsCommandArgsWithCompletion } from '../commands/gitCommands'; +import { Commands } from '../constants.commands'; import { defer } from '../system/promise'; +import { executeCommand, executeEditorCommand } from '../system/vscode/command'; export async function executeGitCommand(args: GitCommandsCommandArgs): Promise { const deferred = defer(); diff --git a/src/git/actions/commit.ts b/src/git/actions/commit.ts index b53abc90f8809..1c7a9818b94a9 100644 --- a/src/git/actions/commit.ts +++ b/src/git/actions/commit.ts @@ -1,45 +1,97 @@ -import type { TextDocumentShowOptions } from 'vscode'; -import { env, Range, Uri, window } from 'vscode'; -import type { - DiffWithCommandArgs, - DiffWithPreviousCommandArgs, - DiffWithWorkingCommandArgs, - OpenFileOnRemoteCommandArgs, - OpenWorkingFileCommandArgs, - ShowQuickCommitCommandArgs, - ShowQuickCommitFileCommandArgs, -} from '../../commands'; +import type { TextDocumentShowOptions, TextEditor } from 'vscode'; +import { env, Range, Uri, window, workspace } from 'vscode'; +import type { DiffWithCommandArgs } from '../../commands/diffWith'; +import type { DiffWithPreviousCommandArgs } from '../../commands/diffWithPrevious'; +import type { DiffWithWorkingCommandArgs } from '../../commands/diffWithWorking'; +import type { OpenFileOnRemoteCommandArgs } from '../../commands/openFileOnRemote'; +import type { OpenOnlyChangedFilesCommandArgs } from '../../commands/openOnlyChangedFiles'; +import type { OpenWorkingFileCommandArgs } from '../../commands/openWorkingFile'; +import type { ShowQuickCommitCommandArgs } from '../../commands/showQuickCommit'; +import type { ShowQuickCommitFileCommandArgs } from '../../commands/showQuickCommitFile'; import type { FileAnnotationType } from '../../config'; -import { Commands } from '../../constants'; +import { GlyphChars } from '../../constants'; +import { Commands } from '../../constants.commands'; import { Container } from '../../container'; -import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/graphWebview'; -import { executeCommand, executeEditorCommand } from '../../system/command'; -import { findOrOpenEditor, findOrOpenEditors } from '../../system/utils'; +import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/protocol'; +import { showRevisionFilesPicker } from '../../quickpicks/revisionFilesPicker'; +import { getSettledValue } from '../../system/promise'; +import { executeCommand, executeCoreGitCommand, executeEditorCommand } from '../../system/vscode/command'; +import { configuration } from '../../system/vscode/configuration'; +import { findOrOpenEditor, findOrOpenEditors, openChangesEditor } from '../../system/vscode/utils'; import { GitUri } from '../gitUri'; import type { GitCommit } from '../models/commit'; import { isCommit } from '../models/commit'; +import { deletedOrMissing } from '../models/constants'; import type { GitFile } from '../models/file'; +import { GitFileChange } from '../models/file'; import type { GitRevisionReference } from '../models/reference'; -import { GitReference, GitRevision } from '../models/reference'; - -export async function applyChanges(file: string | GitFile, ref1: GitRevisionReference, ref2?: GitRevisionReference) { - // Open the working file to ensure undo will work - await openFile(file, ref1, { preserveFocus: true, preview: false }); +import { + createReference, + createRevisionRange, + getReferenceFromRevision, + getReferenceLabel, + isUncommitted, + isUncommittedStaged, + shortenRevision, +} from '../models/reference'; +import { getAheadBehindFilesQuery } from '../queryResults'; + +export type Ref = { repoPath: string; ref: string }; +export type RefRange = { repoPath: string; rhs: string; lhs: string }; + +export interface FilesComparison { + files: GitFile[]; + repoPath: string; + ref1: string; + ref2: string; + title?: string; +} - let ref = ref1.ref; - // If the file is `?` (untracked), then this must be a stash, so get the ^3 commit to access the untracked file - if (typeof file !== 'string' && file.status === '?') { - ref = `${ref}^3`; +const filesOpenThreshold = 10; +const filesOpenDiffsThreshold = 10; +const filesOpenMultiDiffThreshold = 50; + +export async function applyChanges(file: string | GitFile, rev1: GitRevisionReference, rev2?: GitRevisionReference) { + let create = false; + let ref1 = rev1.ref; + let ref2 = rev2?.ref; + if (typeof file !== 'string') { + // If the file is `?` (untracked), then this must be a stash, so get the ^3 commit to access the untracked file + if (file.status === '?') { + ref1 = `${ref1}^3`; + create = true; + } else if (file.status === 'A') { + create = true; + } else if (file.status === 'D') { + // If the file is deleted, check to see if it exists, if so, apply the delete, otherwise restore it from the previous commit + const uri = GitUri.fromFile(file, rev1.repoPath); + try { + await workspace.fs.stat(uri); + } catch { + create = true; + + ref2 = ref1; + ref1 = `${ref1}^`; + } + } } - await Container.instance.git.applyChangesToWorkingFile(GitUri.fromFile(file, ref1.repoPath, ref), ref, ref2?.ref); + if (create) { + const uri = GitUri.fromFile(file, rev1.repoPath); + await Container.instance.git.applyChangesToWorkingFile(uri, ref1, ref2); + await openFile(uri, { preserveFocus: true, preview: false }); + } else { + // Open the working file to ensure undo will work + await openFile(file, rev1, { preserveFocus: true, preview: false }); + await Container.instance.git.applyChangesToWorkingFile(GitUri.fromFile(file, rev1.repoPath, ref1), ref1, ref2); + } } -export async function copyIdToClipboard(ref: { repoPath: string; ref: string } | GitCommit) { +export async function copyIdToClipboard(ref: Ref | GitCommit) { await env.clipboard.writeText(ref.ref); } -export async function copyMessageToClipboard(ref: { repoPath: string; ref: string } | GitCommit): Promise { +export async function copyMessageToClipboard(ref: Ref | GitCommit): Promise { let commit; if (isCommit(ref)) { commit = ref; @@ -55,45 +107,54 @@ export async function copyMessageToClipboard(ref: { repoPath: string; ref: strin await env.clipboard.writeText(message); } -export async function openAllChanges(commit: GitCommit, options?: TextDocumentShowOptions): Promise; +export async function openAllChanges( + commit: GitCommit, + options?: TextDocumentShowOptions & { title?: string }, +): Promise; export async function openAllChanges( files: GitFile[], - refs: { repoPath: string; ref1: string; ref2: string }, - options?: TextDocumentShowOptions, + refs: RefRange, + options?: TextDocumentShowOptions & { title?: string }, ): Promise; export async function openAllChanges( commitOrFiles: GitCommit | GitFile[], - refsOrOptions: { repoPath: string; ref1: string; ref2: string } | TextDocumentShowOptions | undefined, - options?: TextDocumentShowOptions, -) { - let files; - let refs; + refsOrOptions: RefRange | (TextDocumentShowOptions & { title?: string }) | undefined, + maybeOptions?: TextDocumentShowOptions & { title?: string }, +): Promise { if (isCommit(commitOrFiles)) { - if (commitOrFiles.files == null) { - await commitOrFiles.ensureFullDetails(); + if (configuration.get('views.openChangesInMultiDiffEditor')) { + return openAllChangesInChangesEditor(commitOrFiles, refsOrOptions as TextDocumentShowOptions | undefined); } + return openAllChangesIndividually(commitOrFiles, refsOrOptions as TextDocumentShowOptions | undefined); + } - files = commitOrFiles.files ?? []; - refs = { - repoPath: commitOrFiles.repoPath, - // Don't need to worry about verifying the previous sha, as the DiffWith command will - ref1: commitOrFiles.unresolvedPreviousSha, - ref2: commitOrFiles.sha, - }; - - options = refsOrOptions as TextDocumentShowOptions | undefined; - } else { - files = commitOrFiles; - refs = refsOrOptions as { repoPath: string; ref1: string; ref2: string }; + if (configuration.get('views.openChangesInMultiDiffEditor')) { + return openAllChangesInChangesEditor(commitOrFiles, refsOrOptions as RefRange, maybeOptions); } + return openAllChangesIndividually(commitOrFiles, refsOrOptions as RefRange, maybeOptions); +} - if (files.length > 10) { - const result = await window.showWarningMessage( - `Are you sure you want to open the changes for all ${files.length} files?`, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result == null || result.title === 'No') return; +export async function openAllChangesIndividually(commit: GitCommit, options?: TextDocumentShowOptions): Promise; +export async function openAllChangesIndividually( + files: GitFile[], + refs: RefRange, + options?: TextDocumentShowOptions, +): Promise; +export async function openAllChangesIndividually( + commitOrFiles: GitCommit | GitFile[], + refsOrOptions: RefRange | TextDocumentShowOptions | undefined, + maybeOptions?: TextDocumentShowOptions, +): Promise { + let { files, refs, options } = await getChangesRefsArgs(commitOrFiles, refsOrOptions, maybeOptions); + + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to open the changes for each of the ${files.length} files?`, + confirmButton: 'Open Changes', + threshold: filesOpenDiffsThreshold, + })) + ) { + return; } options = { preserveFocus: true, preview: false, ...options }; @@ -103,37 +164,98 @@ export async function openAllChanges( } } -export async function openAllChangesWithDiffTool(commit: GitCommit): Promise; -export async function openAllChangesWithDiffTool( +export async function openAllChangesInChangesEditor( + commit: GitCommit, + options?: TextDocumentShowOptions & { title?: string }, +): Promise; +export async function openAllChangesInChangesEditor( files: GitFile[], - ref: { repoPath: string; ref: string }, + refs: RefRange, + options?: TextDocumentShowOptions & { title?: string }, ): Promise; -export async function openAllChangesWithDiffTool( +export async function openAllChangesInChangesEditor( commitOrFiles: GitCommit | GitFile[], - ref?: { repoPath: string; ref: string }, -) { - let files; - if (isCommit(commitOrFiles)) { - if (commitOrFiles.files == null) { - await commitOrFiles.ensureFullDetails(); + refsOrOptions: RefRange | (TextDocumentShowOptions & { title?: string }) | undefined, + maybeOptions?: TextDocumentShowOptions & { title?: string }, +): Promise { + if (!configuration.getCore('multiDiffEditor.experimental.enabled')) { + void window.showErrorMessage( + `Enable the multi-diff editor by setting 'multiDiffEditor.experimental.enabled' to use this command`, + ); + return; + } + + let title; + if (maybeOptions != null) { + ({ title, ...maybeOptions } = maybeOptions); + } + + const { commit, files, refs, options } = await getChangesRefsArgs(commitOrFiles, refsOrOptions, maybeOptions); + + if (title == null) { + if (commit != null) { + title = `Changes in ${shortenRevision(refs.rhs, { strings: { working: 'Working Tree' } })}`; + } else { + title = `Changes between ${shortenRevision(refs.lhs, { strings: { working: 'Working Tree' } })} ${ + GlyphChars.ArrowLeftRightLong + } ${shortenRevision(refs.rhs, { strings: { working: 'Working Tree' } })}`; } + } - files = commitOrFiles.files ?? []; - ref = { - repoPath: commitOrFiles.repoPath, - ref: commitOrFiles.sha, - }; - } else { - files = commitOrFiles; + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to view the changes for all ${files.length} files?`, + confirmButton: 'View All Changes', + threshold: filesOpenMultiDiffThreshold, + })) + ) { + return; } - if (files.length > 10) { - const result = await window.showWarningMessage( - `Are you sure you want to open the changes for all ${files.length} files?`, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result == null || result.title === 'No') return; + const { git } = Container.instance; + + const resources: Parameters[0] = []; + for (const file of files) { + let rhs = file.status === 'D' ? undefined : (await git.getBestRevisionUri(refs.repoPath, file.path, refs.rhs))!; + if (refs.rhs === '') { + if (rhs != null) { + rhs = await git.getWorkingUri(refs.repoPath, rhs); + } else { + rhs = Uri.from({ + scheme: 'untitled', + authority: '', + path: git.getAbsoluteUri(file.path, refs.repoPath).fsPath, + }); + } + } + + const lhs = + file.status === 'A' + ? undefined + : (await git.getBestRevisionUri(refs.repoPath, file.originalPath ?? file.path, refs.lhs))!; + + const uri = (file.status === 'D' ? lhs : rhs) ?? git.getAbsoluteUri(file.path, refs.repoPath); + if (rhs?.scheme === 'untitled' && lhs == null) continue; + + resources.push({ uri: uri, lhs: lhs, rhs: rhs }); + } + + await openChangesEditor(resources, title, options); +} + +export async function openAllChangesWithDiffTool(commit: GitCommit): Promise; +export async function openAllChangesWithDiffTool(files: GitFile[], ref: Ref): Promise; +export async function openAllChangesWithDiffTool(commitOrFiles: GitCommit | GitFile[], ref?: Ref) { + const { files } = await getChangesRefArgs(commitOrFiles, ref); + + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to externally open the changes for each of the ${files.length} files?`, + confirmButton: 'Open Changes', + threshold: filesOpenDiffsThreshold, + })) + ) { + return; } for (const file of files) { @@ -141,43 +263,68 @@ export async function openAllChangesWithDiffTool( } } -export async function openAllChangesWithWorking(commit: GitCommit, options?: TextDocumentShowOptions): Promise; +export async function openAllChangesWithWorking( + commit: GitCommit, + options?: TextDocumentShowOptions & { title?: string }, +): Promise; export async function openAllChangesWithWorking( files: GitFile[], - ref: { repoPath: string; ref: string }, - options?: TextDocumentShowOptions, + ref: Ref, + options?: TextDocumentShowOptions & { title?: string }, ): Promise; export async function openAllChangesWithWorking( commitOrFiles: GitCommit | GitFile[], - refOrOptions: { repoPath: string; ref: string } | TextDocumentShowOptions | undefined, - options?: TextDocumentShowOptions, + refOrOptions: Ref | (TextDocumentShowOptions & { title?: string }) | undefined, + maybeOptions?: TextDocumentShowOptions & { title?: string }, ) { - let files; - let ref; if (isCommit(commitOrFiles)) { - if (commitOrFiles.files == null) { - await commitOrFiles.ensureFullDetails(); + if (configuration.get('views.openChangesInMultiDiffEditor')) { + return openAllChangesInChangesEditor(commitOrFiles, refOrOptions as TextDocumentShowOptions | undefined); } - - files = commitOrFiles.files ?? []; - ref = { - repoPath: commitOrFiles.repoPath, - ref: commitOrFiles.sha, - }; - - options = refOrOptions as TextDocumentShowOptions | undefined; - } else { - files = commitOrFiles; - ref = refOrOptions as { repoPath: string; ref: string }; + return openAllChangesWithWorkingIndividually( + commitOrFiles, + refOrOptions as TextDocumentShowOptions | undefined, + ); } - if (files.length > 10) { - const result = await window.showWarningMessage( - `Are you sure you want to open the changes for all ${files.length} files?`, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, + if (configuration.get('views.openChangesInMultiDiffEditor')) { + return openAllChangesInChangesEditor( + commitOrFiles, + { + repoPath: (refOrOptions as Ref).repoPath, + lhs: (refOrOptions as Ref).ref, + rhs: '', + }, + maybeOptions, ); - if (result == null || result.title === 'No') return; + } + return openAllChangesWithWorkingIndividually(commitOrFiles, refOrOptions as Ref, maybeOptions); +} + +export async function openAllChangesWithWorkingIndividually( + commit: GitCommit, + options?: TextDocumentShowOptions, +): Promise; +export async function openAllChangesWithWorkingIndividually( + files: GitFile[], + ref: Ref, + options?: TextDocumentShowOptions, +): Promise; +export async function openAllChangesWithWorkingIndividually( + commitOrFiles: GitCommit | GitFile[], + refOrOptions: Ref | TextDocumentShowOptions | undefined, + maybeOptions?: TextDocumentShowOptions, +) { + let { files, ref, options } = await getChangesRefArgs(commitOrFiles, refOrOptions, maybeOptions); + + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to open the changes for each of the ${files.length} files?`, + confirmButton: 'Open Changes', + threshold: filesOpenDiffsThreshold, + })) + ) { + return; } options = { preserveFocus: true, preview: false, ...options }; @@ -194,67 +341,68 @@ export async function openChanges( ): Promise; export async function openChanges( file: GitFile, - refs: { repoPath: string; ref1: string; ref2: string }, - options?: TextDocumentShowOptions, + refs: RefRange, + options?: TextDocumentShowOptions & { lhsTitle?: string; rhsTitle?: string }, +): Promise; +export async function openChanges( + file: GitFile, + commitOrRefs: GitCommit | RefRange, + options?: TextDocumentShowOptions & { lhsTitle?: string; rhsTitle?: string }, ): Promise; export async function openChanges( file: string | GitFile, - commitOrRefs: GitCommit | { repoPath: string; ref1: string; ref2: string }, - options?: TextDocumentShowOptions, + commitOrRefs: GitCommit | RefRange, + options?: TextDocumentShowOptions & { lhsTitle?: string; rhsTitle?: string }, ) { + const hasCommit = isCommit(commitOrRefs); + if (typeof file === 'string') { - if (!isCommit(commitOrRefs)) throw new Error('Invalid arguments'); + if (!hasCommit) throw new Error('Invalid arguments'); const f = await commitOrRefs.findFile(file); if (f == null) throw new Error('Invalid arguments'); file = f; + } else if (!hasCommit && commitOrRefs.rhs === '') { + return openChangesWithWorking(file, { repoPath: commitOrRefs.repoPath, ref: commitOrRefs.lhs }, options); } options = { preserveFocus: true, preview: false, ...options }; - if (file.status === 'A') { - if (!isCommit(commitOrRefs)) return; - + if (file.status === 'A' && hasCommit) { const commit = await commitOrRefs.getCommitForFile(file); void executeCommand(Commands.DiffWithPrevious, { commit: commit, showOptions: options, }); + + return; } - const refs = isCommit(commitOrRefs) + const refs: RefRange = hasCommit ? { repoPath: commitOrRefs.repoPath, + rhs: commitOrRefs.sha, // Don't need to worry about verifying the previous sha, as the DiffWith command will - ref1: commitOrRefs.unresolvedPreviousSha, - ref2: commitOrRefs.sha, + lhs: commitOrRefs.unresolvedPreviousSha, } : commitOrRefs; - const uri1 = GitUri.fromFile(file, refs.repoPath); - const uri2 = - file.status === 'R' || file.status === 'C' ? GitUri.fromFile(file, refs.repoPath, refs.ref2, true) : uri1; + const rhsUri = GitUri.fromFile(file, refs.repoPath); + const lhsUri = + file.status === 'R' || file.status === 'C' ? GitUri.fromFile(file, refs.repoPath, refs.lhs, true) : rhsUri; void (await executeCommand(Commands.DiffWith, { repoPath: refs.repoPath, - lhs: { uri: uri1, sha: refs.ref1 }, - rhs: { uri: uri2, sha: refs.ref2 }, + lhs: { uri: lhsUri, sha: refs.lhs, title: options?.lhsTitle }, + rhs: { uri: rhsUri, sha: refs.rhs, title: options?.rhsTitle }, showOptions: options, })); } export function openChangesWithDiffTool(file: string | GitFile, commit: GitCommit, tool?: string): Promise; -export function openChangesWithDiffTool( - file: GitFile, - ref: { repoPath: string; ref: string }, - tool?: string, -): Promise; -export async function openChangesWithDiffTool( - file: string | GitFile, - commitOrRef: GitCommit | { repoPath: string; ref: string }, - tool?: string, -) { +export function openChangesWithDiffTool(file: GitFile, ref: Ref, tool?: string): Promise; +export async function openChangesWithDiffTool(file: string | GitFile, commitOrRef: GitCommit | Ref, tool?: string) { if (typeof file === 'string') { if (!isCommit(commitOrRef)) throw new Error('Invalid arguments'); @@ -268,9 +416,9 @@ export async function openChangesWithDiffTool( commitOrRef.repoPath, GitUri.fromFile(file, file.repoPath ?? commitOrRef.repoPath), { - ref1: GitRevision.isUncommitted(commitOrRef.ref) ? '' : `${commitOrRef.ref}^`, - ref2: GitRevision.isUncommitted(commitOrRef.ref) ? '' : commitOrRef.ref, - staged: GitRevision.isUncommittedStaged(commitOrRef.ref) || file.indexStatus != null, + ref1: isUncommitted(commitOrRef.ref) ? '' : `${commitOrRef.ref}^`, + ref2: isUncommitted(commitOrRef.ref) ? '' : commitOrRef.ref, + staged: isUncommittedStaged(commitOrRef.ref) || file.indexStatus != null, tool: tool, }, ); @@ -279,17 +427,17 @@ export async function openChangesWithDiffTool( export async function openChangesWithWorking( file: string | GitFile, commit: GitCommit, - options?: TextDocumentShowOptions, + options?: TextDocumentShowOptions & { lhsTitle?: string }, ): Promise; export async function openChangesWithWorking( file: GitFile, - ref: { repoPath: string; ref: string }, - options?: TextDocumentShowOptions, + ref: Ref, + options?: TextDocumentShowOptions & { lhsTitle?: string }, ): Promise; export async function openChangesWithWorking( file: string | GitFile, - commitOrRef: GitCommit | { repoPath: string; ref: string }, - options?: TextDocumentShowOptions, + commitOrRef: GitCommit | Ref, + options?: TextDocumentShowOptions & { lhsTitle?: string }, ) { if (typeof file === 'string') { if (!isCommit(commitOrRef)) throw new Error('Invalid arguments'); @@ -317,9 +465,28 @@ export async function openChangesWithWorking( void (await executeEditorCommand(Commands.DiffWithWorking, undefined, { uri: GitUri.fromFile(file, ref.repoPath, ref.ref), showOptions: options, + lhsTitle: options?.lhsTitle, })); } +export async function openComparisonChanges( + container: Container, + refs: RefRange, + options?: TextDocumentShowOptions & { title?: string }, +): Promise { + refs.lhs = refs.lhs || 'HEAD'; + refs.rhs = refs.rhs || 'HEAD'; + + const { files } = await getAheadBehindFilesQuery( + container, + refs.repoPath, + createRevisionRange(refs.lhs, refs.rhs, '...'), + refs.rhs === '', + ); + + await openAllChangesInChangesEditor(files ?? [], refs, options); +} + export async function openDirectoryCompare( repoPath: string, ref: string, @@ -329,18 +496,52 @@ export async function openDirectoryCompare( return Container.instance.git.openDirectoryCompare(repoPath, ref, ref2, tool); } -export async function openDirectoryCompareWithPrevious( - ref: { repoPath: string; ref: string } | GitCommit, -): Promise { +export async function openDirectoryCompareWithPrevious(ref: Ref | GitCommit): Promise { return openDirectoryCompare(ref.repoPath, ref.ref, `${ref.ref}^`); } -export async function openDirectoryCompareWithWorking( - ref: { repoPath: string; ref: string } | GitCommit, -): Promise { +export async function openDirectoryCompareWithWorking(ref: Ref | GitCommit): Promise { return openDirectoryCompare(ref.repoPath, ref.ref, undefined); } +export async function openFolderCompare( + pathOrUri: string | Uri, + refs: RefRange, + options?: TextDocumentShowOptions, +): Promise { + const { git } = Container.instance; + + let comparison; + if (refs.lhs === '') { + debugger; + throw new Error('Cannot get files for comparisons of a ref with working tree'); + } else if (refs.rhs === '') { + comparison = refs.lhs; + } else { + comparison = `${refs.lhs}..${refs.rhs}`; + } + + const relativePath = git.getRelativePath(pathOrUri, refs.repoPath); + + const files = await git.getDiffStatus(refs.repoPath, comparison, undefined, { path: relativePath }); + if (files == null) { + void window.showWarningMessage( + `No changes in '${relativePath}' between ${shortenRevision(refs.lhs, { + strings: { working: 'Working Tree' }, + })} ${GlyphChars.ArrowLeftRightLong} ${shortenRevision(refs.rhs, { + strings: { working: 'Working Tree' }, + })}`, + ); + return; + } + + const title = `Changes in ${relativePath} between ${shortenRevision(refs.lhs, { + strings: { working: 'Working Tree' }, + })} ${GlyphChars.ArrowLeftRightLong} ${shortenRevision(refs.rhs, { strings: { working: 'Working Tree' } })}`; + + return openAllChangesInChangesEditor(files, refs, { ...options, title: title }); +} + export async function openFile(uri: Uri, options?: TextDocumentShowOptions): Promise; export async function openFile( file: string | GitFile, @@ -386,7 +587,7 @@ export async function openFileAtRevision( commitOrOptions?: GitCommit | TextDocumentShowOptions, options?: TextDocumentShowOptions & { annotationType?: FileAnnotationType; line?: number }, ): Promise { - let uri; + let uri: Uri; if (fileOrRevisionUri instanceof Uri) { if (isCommit(commitOrOptions)) throw new Error('Invalid arguments'); @@ -408,7 +609,7 @@ export async function openFileAtRevision( } uri = Container.instance.git.getRevisionUri( - file.status === 'D' ? (await commit.getPreviousSha()) ?? GitRevision.deletedOrMissing : commit.sha, + file.status === 'D' ? (await commit.getPreviousSha()) ?? deletedOrMissing : commit.sha, file, commit.repoPath, ); @@ -424,7 +625,38 @@ export async function openFileAtRevision( opts.selection = new Range(line, 0, line, 0); } - const editor = await findOrOpenEditor(uri, opts); + const gitUri = await GitUri.fromUri(uri); + + let editor: TextEditor | undefined; + try { + editor = await findOrOpenEditor(uri, { throwOnError: true, ...opts }); + } catch (ex) { + if (!ex?.message?.includes('Unable to resolve nonexistent file')) { + void window.showErrorMessage(`Unable to open '${gitUri.relativePath}' in revision '${gitUri.sha}'`); + return; + } + + const pickedUri = await showRevisionFilesPicker( + Container.instance, + createReference(gitUri.sha!, gitUri.repoPath!), + { + ignoreFocusOut: true, + initialPath: gitUri.relativePath, + title: `Open File at Revision \u2022 Unable to open '${gitUri.relativePath}'`, + placeholder: 'Choose a file revision to open', + keyboard: { + keys: ['right', 'alt+right', 'ctrl+right'], + onDidPressKey: async (_key, uri) => { + await findOrOpenEditor(uri, { ...opts, preserveFocus: true, preview: true }); + }, + }, + }, + ); + if (pickedUri == null) return; + + editor = await findOrOpenEditor(pickedUri, opts); + } + if (annotationType != null && editor != null) { void (await Container.instance.fileAnnotations.show(editor, annotationType, { selection: { line: line }, @@ -451,77 +683,63 @@ export async function openFileOnRemote(fileOrUri: string | GitFile | Uri, ref?: })); } -export async function openFiles(commit: GitCommit): Promise; -export async function openFiles(files: GitFile[], repoPath: string, ref: string): Promise; -export async function openFiles(commitOrFiles: GitCommit | GitFile[], repoPath?: string, ref?: string): Promise { - let files; - if (isCommit(commitOrFiles)) { - if (commitOrFiles.files == null) { - await commitOrFiles.ensureFullDetails(); - } - - files = commitOrFiles.files ?? []; - repoPath = commitOrFiles.repoPath; - ref = commitOrFiles.sha; - } else { - files = commitOrFiles; - } - - if (files.length > 10) { - const result = await window.showWarningMessage( - `Are you sure you want to open all ${files.length} files?`, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result == null || result.title === 'No') return; +export async function openFiles(commit: GitCommit, options?: TextDocumentShowOptions): Promise; +export async function openFiles(files: GitFile[], ref: Ref, options?: TextDocumentShowOptions): Promise; +export async function openFiles( + commitOrFiles: GitCommit | GitFile[], + refOrOptions: Ref | TextDocumentShowOptions | undefined, + maybeOptions?: TextDocumentShowOptions, +): Promise { + const { files, ref, options } = await getChangesRefArgs(commitOrFiles, refOrOptions, maybeOptions); + + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to open each of the ${files.length} files?`, + confirmButton: 'Open Files', + threshold: filesOpenThreshold, + })) + ) { + return; } const uris: Uri[] = ( await Promise.all( - files.map(file => Container.instance.git.getWorkingUri(repoPath!, GitUri.fromFile(file, repoPath!, ref))), + files.map(file => + Container.instance.git.getWorkingUri(ref.repoPath, GitUri.fromFile(file, ref.repoPath, ref.ref)), + ), ) ).filter((u?: T): u is T => Boolean(u)); - findOrOpenEditors(uris); + findOrOpenEditors(uris, options); } -export async function openFilesAtRevision(commit: GitCommit): Promise; +export async function openFilesAtRevision(commit: GitCommit, options?: TextDocumentShowOptions): Promise; export async function openFilesAtRevision( files: GitFile[], - repoPath: string, - ref1: string, - ref2: string, + refs: RefRange, + options?: TextDocumentShowOptions, ): Promise; export async function openFilesAtRevision( commitOrFiles: GitCommit | GitFile[], - repoPath?: string, - ref1?: string, - ref2?: string, + refOrOptions: RefRange | TextDocumentShowOptions | undefined, + maybeOptions?: TextDocumentShowOptions, ): Promise { - let files; - if (isCommit(commitOrFiles)) { - if (commitOrFiles.files == null) { - await commitOrFiles.ensureFullDetails(); - } - - files = commitOrFiles.files ?? []; - repoPath = commitOrFiles.repoPath; - ref1 = commitOrFiles.sha; - ref2 = await commitOrFiles.getPreviousSha(); - } else { - files = commitOrFiles; - } - - if (files.length > 10) { - const result = await window.showWarningMessage( - `Are you sure you want to open all ${files.length} file revisions?`, - { title: 'Yes' }, - { title: 'No', isCloseAffordance: true }, - ); - if (result == null || result.title === 'No') return; + const { files, refs, options } = await getChangesRefsArgs(commitOrFiles, refOrOptions, maybeOptions); + + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to open each of the ${files.length} file revisions?`, + confirmButton: 'Open Revisions', + threshold: filesOpenThreshold, + })) + ) { + return; } findOrOpenEditors( - files.map(file => Container.instance.git.getRevisionUri(file.status === 'D' ? ref2! : ref1!, file, repoPath!)), + files.map(file => + Container.instance.git.getRevisionUri(file.status === 'D' ? refs.lhs : refs.rhs, file, refs.repoPath), + ), + options, ); } @@ -533,7 +751,20 @@ export async function restoreFile(file: string | GitFile, revision: GitRevisionR ref = revision.ref; } else { path = file.path; - ref = file.status === `?` ? `${revision.ref}^3` : file.status === 'D' ? `${revision.ref}^` : revision.ref; + if (file.status === 'D') { + // If the file is deleted, check to see if it exists, if so, restore it from the previous commit, otherwise restore it from the current commit + const uri = GitUri.fromFile(file, revision.repoPath); + try { + await workspace.fs.stat(uri); + ref = `${revision.ref}^`; + } catch { + ref = revision.ref; + } + } else if (file.status === '?') { + ref = `${revision.ref}^3`; + } else { + ref = revision.ref; + } } await Container.instance.git.checkout(revision.repoPath, ref, { path: path }); @@ -579,6 +810,7 @@ export async function showDetailsQuickPick(commit: GitCommit, fileOrUri?: string void (await executeCommand<[Uri, ShowQuickCommitFileCommandArgs]>(Commands.ShowQuickCommitFile, uri, { sha: commit.sha, + commit: commit, })); } @@ -586,7 +818,16 @@ export function showDetailsView( commit: GitRevisionReference | GitCommit, options?: { pin?: boolean; preserveFocus?: boolean; preserveVisibility?: boolean }, ): Promise { - return Container.instance.commitDetailsView.show({ ...options, commit: commit }); + const { preserveFocus, ...opts } = { ...options, commit: commit }; + return Container.instance.commitDetailsView.show({ preserveFocus: preserveFocus }, opts); +} + +export function showGraphDetailsView( + commit: GitRevisionReference | GitCommit, + options?: { pin?: boolean; preserveFocus?: boolean; preserveVisibility?: boolean }, +): Promise { + const { preserveFocus, ...opts } = { ...options, commit: commit }; + return Container.instance.graphDetailsView.show({ preserveFocus: preserveFocus }, opts); } export async function showInCommitGraph( @@ -594,7 +835,188 @@ export async function showInCommitGraph( options?: { preserveFocus?: boolean }, ): Promise { void (await executeCommand(Commands.ShowInCommitGraph, { - ref: GitReference.fromRevision(commit), + ref: getReferenceFromRevision(commit), preserveFocus: options?.preserveFocus, })); } + +export async function openOnlyChangedFiles(commit: GitCommit): Promise; +export async function openOnlyChangedFiles(files: GitFile[]): Promise; +export async function openOnlyChangedFiles(commitOrFiles: GitCommit | GitFile[]): Promise { + let files; + if (isCommit(commitOrFiles)) { + if (commitOrFiles.files == null) { + await commitOrFiles.ensureFullDetails(); + } + + files = commitOrFiles.files ?? []; + } else { + files = commitOrFiles.map(f => new GitFileChange(f.repoPath!, f.path, f.status, f.originalPath)); + } + + if ( + !(await confirmOpenIfNeeded(files, { + message: `Are you sure you want to open each of the ${files.length} files?`, + confirmButton: 'Open Files', + threshold: 10, + })) + ) { + return; + } + + void (await executeCommand(Commands.OpenOnlyChangedFiles, { + uris: files.filter(f => f.status !== 'D').map(f => f.uri), + })); +} + +export async function undoCommit(container: Container, commit: GitRevisionReference) { + const repo = await container.git.getOrOpenScmRepository(commit.repoPath); + const scmCommit = await repo?.getCommit('HEAD'); + + if (scmCommit?.hash !== commit.ref) { + void window.showWarningMessage( + `Commit ${getReferenceLabel(commit, { + capitalize: true, + icon: false, + })} cannot be undone, because it is no longer the most recent commit.`, + ); + + return; + } + + const status = await container.git.getStatusForRepo(commit.repoPath); + if (status?.files.length) { + const confirm = { title: 'Undo Commit' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `You have uncommitted changes in the working tree.\n\nDo you still want to undo ${getReferenceLabel( + commit, + { + capitalize: false, + icon: false, + }, + )}?`, + { modal: true }, + confirm, + cancel, + ); + + if (result !== confirm) return; + } + + await executeCoreGitCommand('git.undoCommit', commit.repoPath); +} + +async function confirmOpenIfNeeded( + items: readonly unknown[], + options: { cancelButton?: string; confirmButton?: string; message: string; threshold: number }, +): Promise { + if (items.length <= options.threshold) return true; + + const confirm = { title: options.confirmButton ?? 'Open' }; + const cancel = { title: options.cancelButton ?? 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage(options.message, { modal: true }, confirm, cancel); + return result === confirm; +} + +async function getChangesRefArgs( + commitOrFiles: GitCommit | GitFile[], + refOrOptions: Ref | TextDocumentShowOptions | undefined, + options?: TextDocumentShowOptions, +): Promise<{ + commit?: GitCommit; + files: readonly GitFile[]; + options: TextDocumentShowOptions | undefined; + ref: Ref; +}> { + if (!isCommit(commitOrFiles)) { + return { + files: commitOrFiles, + options: options, + ref: refOrOptions as Ref, + }; + } + + if (commitOrFiles.files == null) { + await commitOrFiles.ensureFullDetails(); + } + + return { + commit: commitOrFiles, + files: commitOrFiles.files ?? [], + options: refOrOptions as TextDocumentShowOptions | undefined, + ref: { + repoPath: commitOrFiles.repoPath, + ref: commitOrFiles.sha, + }, + }; +} + +async function getChangesRefsArgs( + commitOrFiles: GitCommit | GitFile[], + refsOrOptions: RefRange | TextDocumentShowOptions | undefined, + options?: TextDocumentShowOptions, +): Promise<{ + commit?: GitCommit; + files: readonly GitFile[]; + options: TextDocumentShowOptions | undefined; + refs: RefRange; +}> { + if (!isCommit(commitOrFiles)) { + return { + files: commitOrFiles, + options: options, + refs: refsOrOptions as RefRange, + }; + } + + if (commitOrFiles.files == null) { + await commitOrFiles.ensureFullDetails(); + } + + return { + commit: commitOrFiles, + files: commitOrFiles.files ?? [], + options: refsOrOptions as TextDocumentShowOptions | undefined, + refs: { + repoPath: commitOrFiles.repoPath, + rhs: commitOrFiles.sha, + lhs: + commitOrFiles.resolvedPreviousSha ?? + (await commitOrFiles.getPreviousSha()) ?? + commitOrFiles.unresolvedPreviousSha, + }, + }; +} + +export async function getOrderedComparisonRefs( + container: Container, + repoPath: string, + refA: string, + refB: string, +): Promise<[string, string]> { + // Check the ancestry of refA and refB to determine which is the "newer" one + const ancestor = await container.git.isAncestorOf(repoPath, refA, refB); + // If refB is an ancestor of refA, compare refA to refB (as refA is "newer") + if (ancestor) return [refB, refA]; + + const ancestor2 = await container.git.isAncestorOf(repoPath, refB, refA); + // If refA is an ancestor of refB, compare refB to refA (as refB is "newer") + if (ancestor2) return [refA, refB]; + + const [commitRefAResult, commitRefBResult] = await Promise.allSettled([ + container.git.getCommit(repoPath, refA), + container.git.getCommit(repoPath, refB), + ]); + + const commitRefA = getSettledValue(commitRefAResult); + const commitRefB = getSettledValue(commitRefBResult); + + if (commitRefB != null && commitRefA != null && commitRefB.date > commitRefA.date) { + // If refB is "newer", compare refB to refA + return [refB, refA]; + } + + // If refA is "newer", compare refA to refB + return [refA, refB]; +} diff --git a/src/git/actions/stash.ts b/src/git/actions/stash.ts index 75aa77cac2923..33a87a59524f3 100644 --- a/src/git/actions/stash.ts +++ b/src/git/actions/stash.ts @@ -1,4 +1,5 @@ import type { Uri } from 'vscode'; +import type { PushFlags } from '../../commands/git/stash'; import { Container } from '../../container'; import { executeGitCommand } from '../actions'; import type { GitStashCommit } from '../models/commit'; @@ -12,10 +13,17 @@ export function apply(repo?: string | Repository, ref?: GitStashReference) { }); } -export function drop(repo?: string | Repository, ref?: GitStashReference) { +export function drop(repo?: string | Repository, refs?: GitStashReference[]) { return executeGitCommand({ command: 'stash', - state: { subcommand: 'drop', repo: repo, reference: ref }, + state: { subcommand: 'drop', repo: repo, references: refs }, + }); +} + +export function rename(repo?: string | Repository, ref?: GitStashReference, message?: string) { + return executeGitCommand({ + command: 'stash', + state: { subcommand: 'rename', repo: repo, reference: ref, message: message }, }); } @@ -26,15 +34,28 @@ export function pop(repo?: string | Repository, ref?: GitStashReference) { }); } -export function push(repo?: string | Repository, uris?: Uri[], message?: string, keepStaged: boolean = false) { +export function push( + repo?: string | Repository, + uris?: Uri[], + message?: string, + includeUntracked: boolean = false, + keepStaged: boolean = false, + onlyStaged: boolean = false, + onlyStagedUris?: Uri[], +) { return executeGitCommand({ command: 'stash', state: { subcommand: 'push', repo: repo, uris: uris, + onlyStagedUris: onlyStagedUris, message: message, - flags: keepStaged ? ['--keep-index'] : undefined, + flags: [ + ...(includeUntracked ? ['--include-untracked'] : []), + ...(keepStaged ? ['--keep-index'] : []), + ...(onlyStaged ? ['--staged'] : []), + ] as PushFlags[], }, }); } @@ -61,5 +82,6 @@ export function showDetailsView( stash: GitStashReference | GitStashCommit, options?: { pin?: boolean; preserveFocus?: boolean }, ): Promise { - return Container.instance.commitDetailsView.show({ ...options, commit: stash }); + const { preserveFocus, ...opts } = { ...options, commit: stash }; + return Container.instance.commitDetailsView.show({ preserveFocus: preserveFocus }, opts); } diff --git a/src/git/actions/worktree.ts b/src/git/actions/worktree.ts index b7e2813a85493..f4ccd4ac63107 100644 --- a/src/git/actions/worktree.ts +++ b/src/git/actions/worktree.ts @@ -1,38 +1,78 @@ import type { Uri } from 'vscode'; import type { WorktreeGitCommandArgs } from '../../commands/git/worktree'; -import { CoreCommands } from '../../constants'; import { Container } from '../../container'; -import { ensure } from '../../system/array'; -import { executeCoreCommand } from '../../system/command'; -import { OpenWorkspaceLocation } from '../../system/utils'; +import { defer } from '../../system/promise'; +import type { OpenWorkspaceLocation } from '../../system/vscode/utils'; import { executeGitCommand } from '../actions'; import type { GitReference } from '../models/reference'; import type { Repository } from '../models/repository'; import type { GitWorktree } from '../models/worktree'; -export function create(repo?: string | Repository, uri?: Uri, ref?: GitReference, options?: { reveal?: boolean }) { +export async function create( + repo?: string | Repository, + uri?: Uri, + ref?: GitReference, + options?: { addRemote?: { name: string; url: string }; createBranch?: string; reveal?: boolean }, +) { + const deferred = defer(); + + await executeGitCommand({ + command: 'worktree', + state: { + subcommand: 'create', + repo: repo, + uri: uri, + reference: ref, + addRemote: options?.addRemote, + createBranch: options?.createBranch, + flags: options?.createBranch ? ['-b'] : undefined, + result: deferred, + reveal: options?.reveal, + }, + }); + + // If the result is still pending, then the command was cancelled + if (!deferred.pending) return deferred.promise; + + deferred.cancel(); + return undefined; +} + +export function copyChangesToWorktree( + type: 'working-tree' | 'index', + repo?: string | Repository, + worktree?: GitWorktree, +) { return executeGitCommand({ command: 'worktree', - state: { subcommand: 'create', repo: repo, uri: uri, reference: ref, reveal: options?.reveal }, + state: { + subcommand: 'copy-changes', + repo: repo, + worktree: worktree, + changes: { + type: type, + }, + }, }); } -export function open(worktree: GitWorktree, options?: { location?: OpenWorkspaceLocation }) { +export function open(worktree: GitWorktree, options?: { location?: OpenWorkspaceLocation; openOnly?: boolean }) { return executeGitCommand({ command: 'worktree', state: { subcommand: 'open', repo: worktree.repoPath, - uri: worktree.uri, + worktree: worktree, flags: convertLocationToOpenFlags(options?.location), + openOnly: options?.openOnly, }, }); } -export function remove(repo?: string | Repository, uri?: Uri) { +export function remove(repo?: string | Repository, uris?: Uri[]) { return executeGitCommand({ command: 'worktree', - state: { subcommand: 'delete', repo: repo, uris: ensure(uri) }, + state: { subcommand: 'delete', repo: repo, uris: uris }, }); } @@ -53,30 +93,33 @@ export async function reveal( return node; } -export async function revealInFileExplorer(worktree: GitWorktree) { - void (await executeCoreCommand(CoreCommands.RevealInFileExplorer, worktree.uri)); -} - -type OpenFlagsArray = Extract>, { subcommand: 'open' }>['flags']; +type OpenFlags = Extract< + NonNullable>, + { subcommand: 'open' } +>['flags'][number]; -export function convertLocationToOpenFlags(location: OpenWorkspaceLocation | undefined): OpenFlagsArray | undefined { +export function convertLocationToOpenFlags(location: OpenWorkspaceLocation): OpenFlags[]; +export function convertLocationToOpenFlags(location: OpenWorkspaceLocation | undefined): OpenFlags[] | undefined; +export function convertLocationToOpenFlags(location: OpenWorkspaceLocation | undefined): OpenFlags[] | undefined { if (location == null) return undefined; switch (location) { - case OpenWorkspaceLocation.NewWindow: + case 'newWindow': return ['--new-window']; - case OpenWorkspaceLocation.AddToWorkspace: + case 'addToWorkspace': return ['--add-to-workspace']; - case OpenWorkspaceLocation.CurrentWindow: + case 'currentWindow': default: return []; } } -export function convertOpenFlagsToLocation(flags: OpenFlagsArray | undefined): OpenWorkspaceLocation | undefined { +export function convertOpenFlagsToLocation(flags: OpenFlags[]): OpenWorkspaceLocation; +export function convertOpenFlagsToLocation(flags: OpenFlags[] | undefined): OpenWorkspaceLocation | undefined; +export function convertOpenFlagsToLocation(flags: OpenFlags[] | undefined): OpenWorkspaceLocation | undefined { if (flags == null) return undefined; - if (flags.includes('--new-window')) return OpenWorkspaceLocation.NewWindow; - if (flags.includes('--add-to-workspace')) return OpenWorkspaceLocation.AddToWorkspace; - return OpenWorkspaceLocation.CurrentWindow; + if (flags.includes('--new-window')) return 'newWindow'; + if (flags.includes('--add-to-workspace')) return 'addToWorkspace'; + return 'currentWindow'; } diff --git a/src/git/errors.ts b/src/git/errors.ts index ec4919ffd94b2..d4979cc8110d0 100644 --- a/src/git/errors.ts +++ b/src/git/errors.ts @@ -6,12 +6,68 @@ export class GitSearchError extends Error { } } +export const enum ApplyPatchCommitErrorReason { + StashFailed, + CreateWorktreeFailed, + ApplyFailed, + ApplyAbortedWouldOverwrite, + AppliedWithConflicts, +} + +export class ApplyPatchCommitError extends Error { + static is(ex: unknown, reason?: ApplyPatchCommitErrorReason): ex is ApplyPatchCommitError { + return ex instanceof ApplyPatchCommitError && (reason == null || ex.reason === reason); + } + + readonly original?: Error; + readonly reason: ApplyPatchCommitErrorReason | undefined; + + constructor(reason: ApplyPatchCommitErrorReason, message?: string, original?: Error) { + message ||= 'Unable to apply patch'; + super(message); + + this.original = original; + this.reason = reason; + Error.captureStackTrace?.(this, ApplyPatchCommitError); + } +} + +export class BlameIgnoreRevsFileError extends Error { + static is(ex: unknown): ex is BlameIgnoreRevsFileError { + return ex instanceof BlameIgnoreRevsFileError; + } + + constructor( + public readonly fileName: string, + public readonly original?: Error, + ) { + super(`Invalid blame.ignoreRevsFile: '${fileName}'`); + + Error.captureStackTrace?.(this, BlameIgnoreRevsFileError); + } +} + +export class BlameIgnoreRevsFileBadRevisionError extends Error { + static is(ex: unknown): ex is BlameIgnoreRevsFileBadRevisionError { + return ex instanceof BlameIgnoreRevsFileBadRevisionError; + } + + constructor( + public readonly revision: string, + public readonly original?: Error, + ) { + super(`Invalid revision in blame.ignoreRevsFile: '${revision}'`); + + Error.captureStackTrace?.(this, BlameIgnoreRevsFileBadRevisionError); + } +} + export const enum StashApplyErrorReason { - WorkingChanges = 1, + WorkingChanges, } export class StashApplyError extends Error { - static is(ex: any, reason?: StashApplyErrorReason): ex is StashApplyError { + static is(ex: unknown, reason?: StashApplyErrorReason): ex is StashApplyError { return ex instanceof StashApplyError && (reason == null || ex.reason === reason); } @@ -41,13 +97,321 @@ export class StashApplyError extends Error { } } +export const enum StashPushErrorReason { + ConflictingStagedAndUnstagedLines, + NothingToSave, +} + +export class StashPushError extends Error { + static is(ex: unknown, reason?: StashPushErrorReason): ex is StashPushError { + return ex instanceof StashPushError && (reason == null || ex.reason === reason); + } + + readonly original?: Error; + readonly reason: StashPushErrorReason | undefined; + + constructor(reason?: StashPushErrorReason, original?: Error); + constructor(message?: string, original?: Error); + constructor(messageOrReason: string | StashPushErrorReason | undefined, original?: Error) { + let message; + let reason: StashPushErrorReason | undefined; + if (messageOrReason == null) { + message = 'Unable to stash'; + } else if (typeof messageOrReason === 'string') { + message = messageOrReason; + reason = undefined; + } else { + reason = messageOrReason; + switch (reason) { + case StashPushErrorReason.ConflictingStagedAndUnstagedLines: + message = + 'Changes were stashed, but the working tree cannot be updated because at least one file has staged and unstaged changes on the same line(s)'; + break; + case StashPushErrorReason.NothingToSave: + message = 'No files to stash'; + break; + default: + message = 'Unable to stash'; + } + } + super(message); + + this.original = original; + this.reason = reason; + Error.captureStackTrace?.(this, StashApplyError); + } +} + +export const enum PushErrorReason { + RemoteAhead, + TipBehind, + PushRejected, + PushRejectedWithLease, + PushRejectedWithLeaseIfIncludes, + PermissionDenied, + RemoteConnection, + NoUpstream, + Other, +} + +export class PushError extends Error { + static is(ex: unknown, reason?: PushErrorReason): ex is PushError { + return ex instanceof PushError && (reason == null || ex.reason === reason); + } + + readonly original?: Error; + readonly reason: PushErrorReason | undefined; + + constructor(reason?: PushErrorReason, original?: Error, branch?: string, remote?: string); + constructor(message?: string, original?: Error); + constructor( + messageOrReason: string | PushErrorReason | undefined, + original?: Error, + branch?: string, + remote?: string, + ) { + let message; + const baseMessage = `Unable to push${branch ? ` branch '${branch}'` : ''}${remote ? ` to ${remote}` : ''}`; + let reason: PushErrorReason | undefined; + if (messageOrReason == null) { + message = baseMessage; + } else if (typeof messageOrReason === 'string') { + message = messageOrReason; + reason = undefined; + } else { + reason = messageOrReason; + + switch (reason) { + case PushErrorReason.RemoteAhead: + message = `${baseMessage} because the remote contains work that you do not have locally. Try fetching first.`; + break; + case PushErrorReason.TipBehind: + message = `${baseMessage} as it is behind its remote counterpart. Try pulling first.`; + break; + case PushErrorReason.PushRejected: + message = `${baseMessage} because some refs failed to push or the push was rejected. Try pulling first.`; + break; + case PushErrorReason.PushRejectedWithLease: + case PushErrorReason.PushRejectedWithLeaseIfIncludes: + message = `Unable to force push${branch ? ` branch '${branch}'` : ''}${ + remote ? ` to ${remote}` : '' + } because some refs failed to push or the push was rejected. The tip of the remote-tracking branch has been updated since the last checkout. Try pulling first.`; + break; + case PushErrorReason.PermissionDenied: + message = `${baseMessage} because you don't have permission to push to this remote repository.`; + break; + case PushErrorReason.RemoteConnection: + message = `${baseMessage} because the remote repository could not be reached.`; + break; + case PushErrorReason.NoUpstream: + message = `${baseMessage} because it has no upstream branch.`; + break; + default: + message = baseMessage; + } + } + super(message); + + this.original = original; + this.reason = reason; + Error.captureStackTrace?.(this, PushError); + } +} + +export const enum PullErrorReason { + Conflict, + GitIdentity, + RemoteConnection, + UnstagedChanges, + UnmergedFiles, + UncommittedChanges, + OverwrittenChanges, + RefLocked, + RebaseMultipleBranches, + TagConflict, + Other, +} + +export class PullError extends Error { + static is(ex: unknown, reason?: PullErrorReason): ex is PullError { + return ex instanceof PullError && (reason == null || ex.reason === reason); + } + + readonly original?: Error; + readonly reason: PullErrorReason | undefined; + + constructor(reason?: PullErrorReason, original?: Error, branch?: string, remote?: string); + constructor(message?: string, original?: Error); + constructor(messageOrReason: string | PullErrorReason | undefined, original?: Error) { + let message; + let reason: PullErrorReason | undefined; + const baseMessage = `Unable to pull`; + if (messageOrReason == null) { + message = baseMessage; + } else if (typeof messageOrReason === 'string') { + message = messageOrReason; + reason = undefined; + } else { + reason = messageOrReason; + switch (reason) { + case PullErrorReason.Conflict: + message = `${baseMessage} due to conflicts.`; + break; + case PullErrorReason.GitIdentity: + message = `${baseMessage} because you have not yet set up your Git identity.`; + break; + case PullErrorReason.RemoteConnection: + message = `${baseMessage} because the remote repository could not be reached.`; + break; + case PullErrorReason.UnstagedChanges: + message = `${baseMessage} because you have unstaged changes.`; + break; + case PullErrorReason.UnmergedFiles: + message = `${baseMessage} because you have unmerged files.`; + break; + case PullErrorReason.UncommittedChanges: + message = `${baseMessage} because you have uncommitted changes.`; + break; + case PullErrorReason.OverwrittenChanges: + message = `${baseMessage} because local changes to some files would be overwritten.`; + break; + case PullErrorReason.RefLocked: + message = `${baseMessage} because a local ref could not be updated.`; + break; + case PullErrorReason.RebaseMultipleBranches: + message = `${baseMessage} because you are trying to rebase onto multiple branches.`; + break; + case PullErrorReason.TagConflict: + message = `${baseMessage} because a local tag would be overwritten.`; + break; + default: + message = baseMessage; + } + } + super(message); + + this.original = original; + this.reason = reason; + Error.captureStackTrace?.(this, PullError); + } +} + +export const enum FetchErrorReason { + NoFastForward, + NoRemote, + RemoteConnection, + Other, +} + +export class FetchError extends Error { + static is(ex: unknown, reason?: FetchErrorReason): ex is FetchError { + return ex instanceof FetchError && (reason == null || ex.reason === reason); + } + + readonly original?: Error; + readonly reason: FetchErrorReason | undefined; + + constructor(reason?: FetchErrorReason, original?: Error, branch?: string, remote?: string); + constructor(message?: string, original?: Error); + constructor( + messageOrReason: string | FetchErrorReason | undefined, + original?: Error, + branch?: string, + remote?: string, + ) { + let message; + const baseMessage = `Unable to fetch${branch ? ` branch '${branch}'` : ''}${remote ? ` from ${remote}` : ''}`; + let reason: FetchErrorReason | undefined; + if (messageOrReason == null) { + message = baseMessage; + } else if (typeof messageOrReason === 'string') { + message = messageOrReason; + reason = undefined; + } else { + reason = messageOrReason; + switch (reason) { + case FetchErrorReason.NoFastForward: + message = `${baseMessage} as it cannot be fast-forwarded`; + break; + case FetchErrorReason.NoRemote: + message = `${baseMessage} without a remote repository specified.`; + break; + case FetchErrorReason.RemoteConnection: + message = `${baseMessage}. Could not connect to the remote repository.`; + break; + default: + message = baseMessage; + } + } + super(message); + + this.original = original; + this.reason = reason; + Error.captureStackTrace?.(this, FetchError); + } +} + +export const enum CherryPickErrorReason { + Conflicts, + AbortedWouldOverwrite, + Other, +} + +export class CherryPickError extends Error { + static is(ex: unknown, reason?: CherryPickErrorReason): ex is CherryPickError { + return ex instanceof CherryPickError && (reason == null || ex.reason === reason); + } + + readonly original?: Error; + readonly reason: CherryPickErrorReason | undefined; + + constructor(reason?: CherryPickErrorReason, original?: Error, sha?: string); + constructor(message?: string, original?: Error); + constructor(messageOrReason: string | CherryPickErrorReason | undefined, original?: Error, sha?: string) { + let message; + const baseMessage = `Unable to cherry-pick${sha ? ` commit '${sha}'` : ''}`; + let reason: CherryPickErrorReason | undefined; + if (messageOrReason == null) { + message = baseMessage; + } else if (typeof messageOrReason === 'string') { + message = messageOrReason; + reason = undefined; + } else { + reason = messageOrReason; + switch (reason) { + case CherryPickErrorReason.AbortedWouldOverwrite: + message = `${baseMessage} as some local changes would be overwritten.`; + break; + case CherryPickErrorReason.Conflicts: + message = `${baseMessage} due to conflicts.`; + break; + default: + message = baseMessage; + } + } + super(message); + + this.original = original; + this.reason = reason; + Error.captureStackTrace?.(this, CherryPickError); + } +} + +export class WorkspaceUntrustedError extends Error { + constructor() { + super('Unable to perform Git operations because the current workspace is untrusted'); + + Error.captureStackTrace?.(this, WorkspaceUntrustedError); + } +} + export const enum WorktreeCreateErrorReason { - AlreadyCheckedOut = 1, - AlreadyExists = 2, + AlreadyCheckedOut, + AlreadyExists, } export class WorktreeCreateError extends Error { - static is(ex: any, reason?: WorktreeCreateErrorReason): ex is WorktreeCreateError { + static is(ex: unknown, reason?: WorktreeCreateErrorReason): ex is WorktreeCreateError { return ex instanceof WorktreeCreateError && (reason == null || ex.reason === reason); } @@ -84,12 +448,12 @@ export class WorktreeCreateError extends Error { } export const enum WorktreeDeleteErrorReason { - HasChanges = 1, - MainWorkingTree = 2, + HasChanges, + MainWorkingTree, } export class WorktreeDeleteError extends Error { - static is(ex: any, reason?: WorktreeDeleteErrorReason): ex is WorktreeDeleteError { + static is(ex: unknown, reason?: WorktreeDeleteErrorReason): ex is WorktreeDeleteError { return ex instanceof WorktreeDeleteError && (reason == null || ex.reason === reason); } diff --git a/src/git/formatters/commitFormatter.ts b/src/git/formatters/commitFormatter.ts index df109cd5b79d7..fe8bd7bd43efd 100644 --- a/src/git/formatters/commitFormatter.ts +++ b/src/git/formatters/commitFormatter.ts @@ -1,4 +1,5 @@ import type { Uri } from 'vscode'; +import type { MaybeEnrichedAutolink } from '../../annotations/autolinks'; import type { Action, ActionContext, @@ -6,40 +7,42 @@ import type { OpenPullRequestActionContext, } from '../../api/gitlens'; import { getPresenceDataUri } from '../../avatars'; -import type { ShowQuickCommitCommandArgs } from '../../commands'; -import { - ConnectRemoteProviderCommand, - DiffWithCommand, - OpenCommitOnRemoteCommand, - OpenFileAtRevisionCommand, - ShowCommitsInViewCommand, - ShowQuickCommitFileCommand, -} from '../../commands'; import { Command } from '../../commands/base'; -import { configuration, DateStyle, FileAnnotationType } from '../../configuration'; -import { Commands, GlyphChars } from '../../constants'; +import { DiffWithCommand } from '../../commands/diffWith'; +import { InspectCommand } from '../../commands/inspect'; +import { OpenCommitOnRemoteCommand } from '../../commands/openCommitOnRemote'; +import { OpenFileAtRevisionCommand } from '../../commands/openFileAtRevision'; +import { ConnectRemoteProviderCommand } from '../../commands/remoteProviders'; +import type { ShowQuickCommitCommandArgs } from '../../commands/showQuickCommit'; +import { ShowQuickCommitFileCommand } from '../../commands/showQuickCommitFile'; +import type { DateStyle } from '../../config'; +import { GlyphChars } from '../../constants'; +import { Commands } from '../../constants.commands'; import { Container } from '../../container'; import { emojify } from '../../emojis'; -import { arePlusFeaturesEnabled } from '../../plus/subscription/utils'; -import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/graphWebview'; +import { arePlusFeaturesEnabled } from '../../plus/gk/utils'; +import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/protocol'; import { join, map } from '../../system/iterable'; -import { PromiseCancelledError } from '../../system/promise'; +import { isPromise } from '../../system/promise'; import type { TokenOptions } from '../../system/string'; import { encodeHtmlWeak, escapeMarkdown, getSuperscript } from '../../system/string'; +import { configuration } from '../../system/vscode/configuration'; import type { ContactPresence } from '../../vsls/vsls'; import type { PreviousLineComparisonUrisResult } from '../gitProvider'; import type { GitCommit } from '../models/commit'; -import { isCommit } from '../models/commit'; -import type { IssueOrPullRequest } from '../models/issue'; -import { PullRequest } from '../models/pullRequest'; -import { GitReference, GitRevision } from '../models/reference'; -import { GitRemote } from '../models/remote'; +import { isCommit, isStash } from '../models/commit'; +import { uncommitted, uncommittedStaged } from '../models/constants'; +import { getIssueOrPullRequestMarkdownIcon } from '../models/issue'; +import type { PullRequest } from '../models/pullRequest'; +import { isPullRequest } from '../models/pullRequest'; +import { getReferenceFromRevision, isUncommittedStaged, shortenRevision } from '../models/reference'; +import type { GitRemote } from '../models/remote'; +import { getHighlanderProviders } from '../models/remote'; import type { RemoteProvider } from '../remotes/remoteProvider'; import type { FormatOptions, RequiredTokenOptions } from './formatter'; import { Formatter } from './formatter'; export interface CommitFormatOptions extends FormatOptions { - autolinkedIssuesOrPullRequests?: Map; avatarSize?: number; dateStyle?: DateStyle; editor?: { line: number; uri: Uri }; @@ -57,12 +60,13 @@ export interface CommitFormatOptions extends FormatOptions { tips?: string; }; }; + enrichedAutolinks?: Map; messageAutolinks?: boolean; messageIndent?: number; messageTruncateAtNewLine?: boolean; - pullRequestOrRemote?: PullRequest | PromiseCancelledError | GitRemote; + pullRequest?: PullRequest | Promise; pullRequestPendingMessage?: string; - presence?: ContactPresence; + presence?: ContactPresence | Promise; previousLineComparisonUris?: PreviousLineComparisonUrisResult; outputFormat?: 'html' | 'markdown' | 'plaintext'; remotes?: GitRemote[]; @@ -154,15 +158,15 @@ export class CommitFormatter extends Formatter { } private get _pullRequestDate() { - const { pullRequestOrRemote: pr } = this._options; - if (pr == null || !PullRequest.is(pr)) return ''; + const { pullRequest: pr } = this._options; + if (pr == null || !isPullRequest(pr)) return ''; return pr.formatDate(this._options.dateFormat) ?? ''; } private get _pullRequestDateAgo() { - const { pullRequestOrRemote: pr } = this._options; - if (pr == null || !PullRequest.is(pr)) return ''; + const { pullRequest: pr } = this._options; + if (pr == null || !isPullRequest(pr)) return ''; return pr.formatDateFromNow() ?? ''; } @@ -170,7 +174,7 @@ export class CommitFormatter extends Formatter { private get _pullRequestDateOrAgo() { const dateStyle = this._options.dateStyle != null ? this._options.dateStyle : configuration.get('defaultDateStyle'); - return dateStyle === DateStyle.Absolute ? this._pullRequestDate : this._pullRequestDateAgo; + return dateStyle === 'absolute' ? this._pullRequestDate : this._pullRequestDateAgo; } get ago(): string { @@ -181,7 +185,7 @@ export class CommitFormatter extends Formatter { const dateStyle = this._options.dateStyle != null ? this._options.dateStyle : configuration.get('defaultDateStyle'); return this._padOrTruncate( - dateStyle === DateStyle.Absolute ? this._date : this._dateAgo, + dateStyle === 'absolute' ? this._date : this._dateAgo, this._options.tokenOptions.agoOrDate, ); } @@ -190,7 +194,7 @@ export class CommitFormatter extends Formatter { const dateStyle = this._options.dateStyle != null ? this._options.dateStyle : configuration.get('defaultDateStyle'); return this._padOrTruncate( - dateStyle === DateStyle.Absolute ? this._date : this._dateAgoShort, + dateStyle === 'absolute' ? this._date : this._dateAgoShort, this._options.tokenOptions.agoOrDateShort, ); } @@ -225,7 +229,7 @@ export class CommitFormatter extends Formatter { const dateStyle = this._options.dateStyle != null ? this._options.dateStyle : configuration.get('defaultDateStyle'); return this._padOrTruncate( - dateStyle === DateStyle.Absolute ? this._authorDate : this._authorDateAgo, + dateStyle === 'absolute' ? this._authorDate : this._authorDateAgo, this._options.tokenOptions.authorAgoOrDate, ); } @@ -234,7 +238,7 @@ export class CommitFormatter extends Formatter { const dateStyle = this._options.dateStyle != null ? this._options.dateStyle : configuration.get('defaultDateStyle'); return this._padOrTruncate( - dateStyle === DateStyle.Absolute ? this._authorDate : this._authorDateAgoShort, + dateStyle === 'absolute' ? this._authorDate : this._authorDateAgoShort, this._options.tokenOptions.authorAgoOrDateShort, ); } @@ -275,7 +279,14 @@ export class CommitFormatter extends Formatter { let { name } = this._item.author; - const presence = this._options.presence; + let presence = this._options.presence; + // If we are still waiting for the presence, pretend it is offline + if (isPromise(presence)) { + presence = { + status: 'offline', + statusText: 'Offline', + }; + } if (presence != null) { let title = `${name} ${name === 'You' ? 'are' : 'is'} ${ presence.status === 'dnd' ? 'in ' : '' @@ -362,16 +373,12 @@ export class CommitFormatter extends Formatter { const { previousLineComparisonUris: diffUris } = this._options; if (diffUris?.previous != null) { commands = `[\`${this._padOrTruncate( - GitRevision.shorten( - GitRevision.isUncommittedStaged(diffUris.current.sha) - ? diffUris.current.sha - : GitRevision.uncommitted, - )!, + shortenRevision(isUncommittedStaged(diffUris.current.sha) ? diffUris.current.sha : uncommitted), this._options.tokenOptions.commands, - )}\`](${ShowCommitsInViewCommand.getMarkdownCommandArgs( + )}\`](${InspectCommand.getMarkdownCommandArgs( this._item.sha, this._item.repoPath, - )} "Open Details")`; + )} "Inspect Commit Details")`; commands += `  [$(chevron-left)$(compare-changes)](${DiffWithCommand.getMarkdownCommandArgs({ lhs: { @@ -388,19 +395,17 @@ export class CommitFormatter extends Formatter { commands += `  [$(versions)](${OpenFileAtRevisionCommand.getMarkdownCommandArgs( Container.instance.git.getRevisionUri(diffUris.previous), - FileAnnotationType.Blame, + 'blame', this._options.editor?.line, )} "Open Blame Prior to this Change")`; } else { commands = `[\`${this._padOrTruncate( - GitRevision.shorten( - this._item.isUncommittedStaged ? GitRevision.uncommittedStaged : GitRevision.uncommitted, - )!, + shortenRevision(this._item.isUncommittedStaged ? uncommittedStaged : uncommitted), this._options.tokenOptions.commands, - )}\`](${ShowCommitsInViewCommand.getMarkdownCommandArgs( + )}\`](${InspectCommand.getMarkdownCommandArgs( this._item.sha, this._item.repoPath, - )} "Open Details")`; + )} "Inspect Commit Details")`; } return commands; @@ -408,10 +413,10 @@ export class CommitFormatter extends Formatter { const separator = '   |   '; - commands = `---\n\n[\`$(git-commit) ${this.id}\`](${ShowCommitsInViewCommand.getMarkdownCommandArgs( + commands = `---\n\n[\`$(git-commit) ${this.id}\`](${InspectCommand.getMarkdownCommandArgs( this._item.sha, this._item.repoPath, - )} "Open Details")`; + )} "Inspect Commit Details")`; commands += `  [$(chevron-left)$(compare-changes)](${DiffWithCommand.getMarkdownCommandArgs( this._item, @@ -426,7 +431,7 @@ export class CommitFormatter extends Formatter { ); commands += `  [$(versions)](${OpenFileAtRevisionCommand.getMarkdownCommandArgs( uri, - FileAnnotationType.Blame, + 'blame', this._options.editor?.line, )} "Open Blame Prior to this Change")`; } @@ -443,21 +448,23 @@ export class CommitFormatter extends Formatter { if (arePlusFeaturesEnabled()) { commands += `  [$(gitlens-graph)](${Command.getMarkdownCommandArgsCore( Commands.ShowInCommitGraph, - { ref: GitReference.fromRevision(this._item) }, + // Avoid including the message here, it just bloats the command url + { ref: getReferenceFromRevision(this._item, { excludeMessage: true }) }, )} "Open in Commit Graph")`; } - if (this._options.remotes != null && this._options.remotes.length !== 0) { - const providers = GitRemote.getHighlanderProviders(this._options.remotes); + const { pullRequest: pr, remotes } = this._options; + + if (remotes?.length) { + const providers = getHighlanderProviders(remotes); commands += `  [$(globe)](${OpenCommitOnRemoteCommand.getMarkdownCommandArgs( this._item.sha, )} "Open Commit on ${providers?.length ? providers[0].name : 'Remote'}")`; } - const { pullRequestOrRemote: pr } = this._options; if (pr != null) { - if (PullRequest.is(pr)) { + if (isPullRequest(pr)) { commands += `${separator}[$(git-pull-request) PR #${ pr.id }](${getMarkdownActionCommand('openPullRequest', { @@ -469,13 +476,20 @@ export class CommitFormatter extends Formatter { }\n${GlyphChars.Dash.repeat(2)}\n${escapeMarkdown(pr.title).replace(/"/g, '\\"')}\n${ pr.state }, ${pr.formatDateFromNow()}")`; - } else if (pr instanceof PromiseCancelledError) { + } else if (isPromise(pr)) { commands += `${separator}[$(git-pull-request) PR $(loading~spin)](command:${Commands.RefreshHover} "Searching for a Pull Request (if any) that introduced this commit...")`; - } else if (pr.provider != null && configuration.get('integrations.enabled')) { - commands += `${separator}[$(plug) Connect to ${pr.provider.name}${ + } + } else if (remotes != null) { + const [remote] = remotes; + if ( + remote?.hasIntegration() && + !remote.maybeIntegrationConnected && + configuration.get('integrations.enabled') + ) { + commands += `${separator}[$(plug) Connect to ${remote?.provider.name}${ GlyphChars.Ellipsis - }](${ConnectRemoteProviderCommand.getMarkdownCommandArgs(pr)} "Connect to ${ - pr.provider.name + }](${ConnectRemoteProviderCommand.getMarkdownCommandArgs(remote)} "Connect to ${ + remote.provider.name } to enable the display of the Pull Request (if any) that introduced this commit")`; } } @@ -525,7 +539,7 @@ export class CommitFormatter extends Formatter { const dateStyle = this._options.dateStyle != null ? this._options.dateStyle : configuration.get('defaultDateStyle'); return this._padOrTruncate( - dateStyle === DateStyle.Absolute ? this._committerDate : this._committerDateAgo, + dateStyle === 'absolute' ? this._committerDate : this._committerDateAgo, this._options.tokenOptions.committerAgoOrDate, ); } @@ -534,7 +548,7 @@ export class CommitFormatter extends Formatter { const dateStyle = this._options.dateStyle != null ? this._options.dateStyle : configuration.get('defaultDateStyle'); return this._padOrTruncate( - dateStyle === DateStyle.Absolute ? this._committerDate : this._committerDateAgoShort, + dateStyle === 'absolute' ? this._committerDate : this._committerDateAgoShort, this._options.tokenOptions.committerAgoOrDateShort, ); } @@ -553,16 +567,37 @@ export class CommitFormatter extends Formatter { } get footnotes(): string { - const { outputFormat } = this._options; + if (this._options.footnotes == null || this._options.footnotes.size === 0) return ''; + + const { footnotes, outputFormat } = this._options; + + // Aggregate similar footnotes + const notes = new Map(); + for (const [i, footnote] of footnotes) { + let note = notes.get(footnote); + if (note == null) { + note = [getSuperscript(i)]; + notes.set(footnote, note); + } else { + note.push(getSuperscript(i)); + } + } + + if (outputFormat === 'plaintext') { + return this._padOrTruncate( + join( + map(notes, ([footnote, indices]) => `${indices.join(',')} ${footnote}`), + '\n', + ), + this._options.tokenOptions.footnotes, + ); + } + return this._padOrTruncate( - this._options.footnotes == null || this._options.footnotes.size === 0 - ? '' - : join( - map(this._options.footnotes, ([i, footnote]) => - outputFormat === 'plaintext' ? `${getSuperscript(i)} ${footnote}` : footnote, - ), - outputFormat === 'html' ? /*html*/ `
` : outputFormat === 'markdown' ? '\\\n' : '\n', - ), + join( + notes.keys(), + outputFormat === 'html' ? /*html*/ `
` : outputFormat === 'markdown' ? '\\\n' : '\n', + ), this._options.tokenOptions.footnotes, ); } @@ -579,28 +614,41 @@ export class CommitFormatter extends Formatter { } get link(): string { + let icon; + let label; + if (isStash(this._item)) { + icon = 'archive'; + label = this._padOrTruncate( + `Stash${this._item.number ? ` #${this._item.number}` : ''}`, + this._options.tokenOptions.link, + ); + } else { + icon = this._item.sha != null && !this._item.isUncommitted ? 'git-commit' : ''; + label = this._padOrTruncate( + shortenRevision(this._item.sha ?? '', { strings: { working: 'Working Tree' } }), + this._options.tokenOptions.id, + ); + } + let link; switch (this._options.outputFormat) { - case 'markdown': { - const sha = this._padOrTruncate(this._item.shortSha ?? '', this._options.tokenOptions.id); - link = `[\`$(git-commit) ${sha}\`](${ShowCommitsInViewCommand.getMarkdownCommandArgs( - this._item.sha, - this._item.repoPath, - )} "Open Details")`; + case 'markdown': + icon = icon ? `$(${icon}) ` : ''; + link = `[\`${icon}${label}\`](${InspectCommand.getMarkdownCommandArgs({ + ref: getReferenceFromRevision(this._item), + })} "Inspect Commit Details")`; break; - } - case 'html': { - const sha = this._padOrTruncate(this._item.shortSha ?? '', this._options.tokenOptions.id); - link = /*html*/ `` : ''; + link = /*html*/ `${sha}`; + }>${icon}${label}`; break; - } default: - return this.id; + link = this.id; + break; } return this._padOrTruncate(link, this._options.tokenOptions.link); @@ -652,7 +700,10 @@ export class CommitFormatter extends Formatter { message, outputFormat, this._options.remotes, - this._options.autolinkedIssuesOrPullRequests, + this._options.enrichedAutolinks, + this._options.pullRequest != null && !isPromise(this._options.pullRequest) + ? new Set([this._options.pullRequest.id]) + : undefined, this._options.footnotes, ); } @@ -674,36 +725,39 @@ export class CommitFormatter extends Formatter { } get pullRequest(): string { - const { pullRequestOrRemote: pr } = this._options; + const { pullRequest: pr } = this._options; // TODO: Implement html rendering if (pr == null || this._options.outputFormat === 'html') { return this._padOrTruncate('', this._options.tokenOptions.pullRequest); } let text; - if (PullRequest.is(pr)) { + if (isPullRequest(pr)) { if (this._options.outputFormat === 'markdown') { - const prTitle = escapeMarkdown(pr.title).replace(/"/g, '\\"').trim(); - - text = `PR [**#${pr.id}**](${getMarkdownActionCommand('openPullRequest', { - repoPath: this._item.repoPath, - provider: { id: pr.provider.id, name: pr.provider.name, domain: pr.provider.domain }, - pullRequest: { id: pr.id, url: pr.url }, - })} "Open Pull Request \\#${pr.id}${ + text = `[**$(git-pull-request) PR #${pr.id}**](${getMarkdownActionCommand( + 'openPullRequest', + { + repoPath: this._item.repoPath, + provider: { id: pr.provider.id, name: pr.provider.name, domain: pr.provider.domain }, + pullRequest: { id: pr.id, url: pr.url }, + }, + )} "Open Pull Request \\#${pr.id}${ Container.instance.actionRunners.count('openPullRequest') == 1 ? ` on ${pr.provider.name}` : '...' }\n${GlyphChars.Dash.repeat(2)}\n${escapeMarkdown(pr.title).replace(/"/g, '\\"')}\n${ pr.state }, ${pr.formatDateFromNow()}")`; if (this._options.footnotes != null) { + const prTitle = escapeMarkdown(pr.title).replace(/"/g, '\\"').trim(); + const index = this._options.footnotes.size + 1; this._options.footnotes.set( index, - `${PullRequest.getMarkdownIcon(pr)} [**${prTitle}**](${pr.url} "Open Pull Request \\#${ - pr.id - } on ${pr.provider.name}")\\\n${GlyphChars.Space.repeat(4)} #${ + `${getIssueOrPullRequestMarkdownIcon(pr)} [**${prTitle}**](${pr.url} "Open Pull Request \\#${ pr.id - } ${pr.state.toLocaleLowerCase()} ${pr.formatDateFromNow()}`, + } on ${pr.provider.name}")\\\n${GlyphChars.Space.repeat(4)} #${pr.id} ${ + pr.state + } ${pr.formatDateFromNow()}`, ); } } else if (this._options.footnotes != null) { @@ -717,7 +771,7 @@ export class CommitFormatter extends Formatter { } else { text = `PR #${pr.id}`; } - } else if (pr instanceof PromiseCancelledError) { + } else if (isPromise(pr)) { text = this._options.outputFormat === 'markdown' ? `[PR $(loading~spin)](command:${Commands.RefreshHover} "Searching for a Pull Request (if any) that introduced this commit...")` @@ -742,9 +796,9 @@ export class CommitFormatter extends Formatter { } get pullRequestState(): string { - const { pullRequestOrRemote: pr } = this._options; + const { pullRequest: pr } = this._options; return this._padOrTruncate( - pr == null || !PullRequest.is(pr) ? '' : pr.state ?? '', + pr == null || !isPullRequest(pr) ? '' : pr.state ?? '', this._options.tokenOptions.pullRequestState, ); } diff --git a/src/git/formatters/formatter.ts b/src/git/formatters/formatter.ts index 57cbbb387058d..f48c4508cf474 100644 --- a/src/git/formatters/formatter.ts +++ b/src/git/formatters/formatter.ts @@ -1,13 +1,5 @@ import type { TokenOptions } from '../../system/string'; -import { - getTokensFromTemplate, - getWidth, - interpolate, - interpolateAsync, - padLeft, - padRight, - truncate, -} from '../../system/string'; +import { getTokensFromTemplate, getTruncatedWidth, getWidth, interpolate, interpolateAsync } from '../../system/string'; export interface FormatOptions { dateFormat?: string | null; @@ -22,6 +14,14 @@ const spaceReplacementRegex = / /g; export declare type RequiredTokenOptions = Options & Required>; +const defaultTokenOptions: Required = { + collapseWhitespace: false, + padDirection: 'left', + prefix: undefined, + suffix: undefined, + truncateTo: undefined, +}; + export abstract class Formatter { protected _item!: Item; protected _options!: RequiredTokenOptions; @@ -57,48 +57,52 @@ export abstract class Formatter 0) { - if (options.collapseWhitespace) { - this.collapsableWhitespace = diff; - } + const r = getTruncatedWidth(s, max, suffixWidth + 1); + if (r.truncated) return `${s.slice(0, r.index)}${r.ellipsed ? '\u2026' : ''}${options.suffix ?? ''}`; - if (options.padDirection === 'left') { - s = padLeft(s, max, undefined, width); - } else { - if (options.collapseWhitespace) { - max -= diff; - } - s = padRight(s, max, undefined, width); - } - } else if (diff < 0) { - s = truncate(s, max, undefined, width); - } + let width = r.width; + if (options.suffix) { + s += options.suffix; + width += suffixWidth; } - if (options.prefix || options.suffix) { - s = `${options.prefix ?? ''}${s}${options.suffix ?? ''}`; + if (width === max) return s; + + if (options.collapseWhitespace) { + this.collapsableWhitespace = max - width; + } + + if (options.padDirection === 'left') { + return s.padStart(max, '\u00a0'); } - return s; + if (options.collapseWhitespace) return s; + + return s.padEnd(max, '\u00a0'); } private static _formatter: Formatter | undefined = undefined; @@ -123,12 +127,13 @@ export abstract class Formatter((map, token) => { - map[token.key] = token.options; - return map; - }, Object.create(null)); + const tokenOptions = getTokensFromTemplate(template).reduce>( + (map, token) => { + map[token.key] = token.options; + return map; + }, + Object.create(null), + ); options.tokenOptions = tokenOptions; } @@ -166,12 +171,13 @@ export abstract class Formatter((map, token) => { - map[token.key] = token.options; - return map; - }, Object.create(null)); + const tokenOptions = getTokensFromTemplate(template).reduce>( + (map, token) => { + map[token.key] = token.options; + return map; + }, + Object.create(null), + ); options.tokenOptions = tokenOptions; } diff --git a/src/git/formatters/statusFormatter.ts b/src/git/formatters/statusFormatter.ts index 2c5e00020eac9..1f5bb0bbc414a 100644 --- a/src/git/formatters/statusFormatter.ts +++ b/src/git/formatters/statusFormatter.ts @@ -1,8 +1,15 @@ import { GlyphChars } from '../../constants'; import { basename } from '../../system/path'; import type { TokenOptions } from '../../system/string'; -import type { GitFileWithCommit } from '../models/file'; -import { GitFile, GitFileChange } from '../models/file'; +import type { GitFile, GitFileWithCommit } from '../models/file'; +import { + getGitFileFormattedDirectory, + getGitFileFormattedPath, + getGitFileOriginalRelativePath, + getGitFileRelativePath, + getGitFileStatusText, + isGitFileChange, +} from '../models/file'; import type { FormatOptions } from './formatter'; import { Formatter } from './formatter'; @@ -25,7 +32,7 @@ export interface StatusFormatOptions extends FormatOptions { export class StatusFileFormatter extends Formatter { get directory() { - const directory = GitFile.getFormattedDirectory(this._item, false, this._options.relativePath); + const directory = getGitFileFormattedDirectory(this._item, false, this._options.relativePath); return this._padOrTruncate(directory, this._options.tokenOptions.directory); } @@ -35,7 +42,7 @@ export class StatusFileFormatter extends Formatter } get filePath() { - const filePath = GitFile.getFormattedPath(this._item, { + const filePath = getGitFileFormattedPath(this._item, { relativeTo: this._options.relativePath, truncateTo: this._options.tokenOptions.filePath?.truncateTo, }); @@ -51,17 +58,17 @@ export class StatusFileFormatter extends Formatter // return ''; // } - const originalPath = GitFile.getOriginalRelativePath(this._item, this._options.relativePath); + const originalPath = getGitFileOriginalRelativePath(this._item, this._options.relativePath); return this._padOrTruncate(originalPath, this._options.tokenOptions.originalPath); } get path() { - const directory = GitFile.getRelativePath(this._item, this._options.relativePath); + const directory = getGitFileRelativePath(this._item, this._options.relativePath); return this._padOrTruncate(directory, this._options.tokenOptions.path); } get status() { - const status = GitFile.getStatusText(this._item.status); + const status = getGitFileStatusText(this._item.status); return this._padOrTruncate(status, this._options.tokenOptions.status); } @@ -81,21 +88,21 @@ export class StatusFileFormatter extends Formatter get changes(): string { return this._padOrTruncate( - GitFileChange.is(this._item) ? this._item.formatStats() : '', + isGitFileChange(this._item) ? this._item.formatStats() : '', this._options.tokenOptions.changes, ); } get changesDetail(): string { return this._padOrTruncate( - GitFileChange.is(this._item) ? this._item.formatStats({ expand: true, separator: ', ' }) : '', + isGitFileChange(this._item) ? this._item.formatStats({ expand: true, separator: ', ' }) : '', this._options.tokenOptions.changesDetail, ); } get changesShort(): string { return this._padOrTruncate( - GitFileChange.is(this._item) ? this._item.formatStats({ compact: true, separator: '' }) : '', + isGitFileChange(this._item) ? this._item.formatStats({ compact: true, separator: '' }) : '', this._options.tokenOptions.changesShort, ); } diff --git a/src/git/fsProvider.ts b/src/git/fsProvider.ts index 864996932392a..33707408de032 100644 --- a/src/git/fsProvider.ts +++ b/src/git/fsProvider.ts @@ -1,14 +1,15 @@ +import { isLinux } from '@env/platform'; import type { Event, FileChangeEvent, FileStat, FileSystemProvider, Uri } from 'vscode'; import { Disposable, EventEmitter, FileSystemError, FileType, workspace } from 'vscode'; -import { isLinux } from '@env/platform'; import { Schemes } from '../constants'; import type { Container } from '../container'; import { debug } from '../system/decorators/log'; import { map } from '../system/iterable'; -import { normalizePath, relative } from '../system/path'; +import { normalizePath } from '../system/path'; import { TernarySearchTree } from '../system/searchTree'; +import { relative } from '../system/vscode/path'; import { GitUri, isGitUri } from './gitUri'; -import { GitRevision } from './models/reference'; +import { deletedOrMissing } from './models/constants'; import type { GitTreeEntry } from './models/tree'; const emptyArray = new Uint8Array(0); @@ -70,7 +71,7 @@ export class GitFileSystemProvider implements FileSystemProvider, Disposable { async readFile(uri: Uri): Promise { const { path, ref, repoPath } = fromGitLensFSUri(uri); - if (ref === GitRevision.deletedOrMissing) return emptyArray; + if (ref === deletedOrMissing) return emptyArray; const data = await this.container.git.getRevisionContent(repoPath, path, ref); return data != null ? data : emptyArray; @@ -84,7 +85,7 @@ export class GitFileSystemProvider implements FileSystemProvider, Disposable { async stat(uri: Uri): Promise { const { path, ref, repoPath } = fromGitLensFSUri(uri); - if (ref === GitRevision.deletedOrMissing) { + if (ref === deletedOrMissing) { return { type: FileType.File, size: 0, @@ -96,13 +97,13 @@ export class GitFileSystemProvider implements FileSystemProvider, Disposable { let treeItem; const searchTree = this._searchTreeMap.get(ref); - if (searchTree !== undefined) { + if (searchTree != null) { // Add the fake root folder to the path treeItem = (await searchTree).get(`/~/${path}`); } else { if (path == null || path.length === 0) { const tree = await this.getTree(path, ref, repoPath); - if (tree === undefined) throw FileSystemError.FileNotFound(uri); + if (tree == null) throw FileSystemError.FileNotFound(uri); return { type: FileType.Directory, @@ -115,9 +116,7 @@ export class GitFileSystemProvider implements FileSystemProvider, Disposable { treeItem = await this.container.git.getTreeEntryForRevision(repoPath, path, ref); } - if (treeItem === undefined) { - throw FileSystemError.FileNotFound(uri); - } + if (treeItem == null) throw FileSystemError.FileNotFound(uri); return { type: typeToFileType(treeItem.type), @@ -144,7 +143,7 @@ export class GitFileSystemProvider implements FileSystemProvider, Disposable { const trees = await this.container.git.getTreeForRevision(repoPath, ref); // Add a fake root folder so that searches will work - searchTree.set('~', { commitSha: '', path: '~', size: 0, type: 'tree' }); + searchTree.set('~', { ref: '', oid: '', path: '~', size: 0, type: 'tree' }); for (const item of trees) { searchTree.set(`~/${item.path}`, item); } diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index e598cc9fff049..f884de9173e65 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -1,19 +1,21 @@ import type { CancellationToken, Disposable, Event, Range, TextDocument, Uri, WorkspaceFolder } from 'vscode'; import type { Commit, InputBox } from '../@types/vscode.git'; import type { ForcePushMode } from '../@types/vscode.git.enums'; +import type { GitConfigKeys } from '../constants'; +import type { SearchQuery } from '../constants.search'; import type { Features } from '../features'; import type { GitUri } from './gitUri'; import type { GitBlame, GitBlameLine, GitBlameLines } from './models/blame'; import type { BranchSortOptions, GitBranch } from './models/branch'; import type { GitCommit } from './models/commit'; import type { GitContributor } from './models/contributor'; -import type { GitDiff, GitDiffFilter, GitDiffHunkLine, GitDiffShortStat } from './models/diff'; +import type { GitDiff, GitDiffFile, GitDiffFiles, GitDiffFilter, GitDiffLine, GitDiffShortStat } from './models/diff'; import type { GitFile } from './models/file'; import type { GitGraph } from './models/graph'; import type { GitLog } from './models/log'; import type { GitMergeStatus } from './models/merge'; import type { GitRebaseStatus } from './models/rebase'; -import type { GitBranchReference } from './models/reference'; +import type { GitBranchReference, GitReference, GitRevisionRange } from './models/reference'; import type { GitReflog } from './models/reflog'; import type { GitRemote } from './models/remote'; import type { Repository, RepositoryChangeEvent } from './models/repository'; @@ -23,25 +25,26 @@ import type { GitTag, TagSortOptions } from './models/tag'; import type { GitTreeEntry } from './models/tree'; import type { GitUser } from './models/user'; import type { GitWorktree } from './models/worktree'; -import type { RemoteProvider } from './remotes/remoteProvider'; -import type { RemoteProviders } from './remotes/remoteProviders'; -import type { RichRemoteProvider } from './remotes/richRemoteProvider'; -import type { GitSearch, SearchQuery } from './search'; +import type { GitSearch } from './search'; -export type GitCaches = 'branches' | 'contributors' | 'providers' | 'remotes' | 'stashes' | 'status' | 'tags'; +export type GitCaches = + | 'branches' + | 'contributors' + | 'providers' + | 'remotes' + | 'stashes' + | 'status' + | 'tags' + | 'worktrees'; export type GitRepositoryCaches = Extract; -export const gitRepositoryCacheKeys: Set = new Set(['branches', 'remotes']); +export const gitRepositoryCacheKeys = new Set(['branches', 'remotes']); export interface GitDir { readonly uri: Uri; readonly commonUri?: Uri; } -export const enum GitProviderId { - Git = 'git', - GitHub = 'github', - Vsls = 'vsls', -} +export type GitProviderId = 'git' | 'github' | 'vsls'; export interface GitProviderDescriptor { readonly id: GitProviderId; @@ -61,6 +64,11 @@ export interface ScmRepository { push(remoteName?: string, branchName?: string, setUpstream?: boolean, force?: ForcePushMode): Promise; } +export interface LeftRightCommitCountResult { + left: number; + right: number; +} + export interface PagedResult { readonly paging?: { readonly cursor: string; @@ -69,6 +77,10 @@ export interface PagedResult { readonly values: NonNullable[]; } +export interface PagingOptions { + cursor?: string; +} + export interface NextComparisonUrisResult { current: GitUri; next: GitUri | undefined; @@ -92,13 +104,16 @@ export interface RepositoryOpenEvent { readonly uri: Uri; } -export const enum RepositoryVisibility { - Private = 'private', - Public = 'public', - Local = 'local', +export type RepositoryVisibility = 'private' | 'public' | 'local'; + +export interface RepositoryVisibilityInfo { + visibility: RepositoryVisibility; + timestamp: number; + remotesHash?: string; } export interface GitProvider extends Disposable { + get onDidChange(): Event; get onDidChangeRepository(): Event; get onDidCloseRepository(): Event; get onDidOpenRepository(): Event; @@ -106,7 +121,10 @@ export interface GitProvider extends Disposable { readonly descriptor: GitProviderDescriptor; readonly supportedSchemes: Set; - discoverRepositories(uri: Uri): Promise; + discoverRepositories( + uri: Uri, + options?: { cancellation?: CancellationToken; depth?: number; silent?: boolean }, + ): Promise; updateContext?(): void; openRepository( folder: WorkspaceFolder | undefined, @@ -118,7 +136,10 @@ export interface GitProvider extends Disposable { openRepositoryInitWatcher?(): RepositoryInitWatcher; supports(feature: Features): Promise; - visibility(repoPath: string): Promise; + visibility( + repoPath: string, + remotes?: GitRemote[], + ): Promise<[visibility: RepositoryVisibility, cacheKey: string | undefined]>; getOpenScmRepositories(): Promise; getScmRepository(repoPath: string): Promise; @@ -136,11 +157,28 @@ export interface GitProvider extends Disposable { pruneRemote(repoPath: string, name: string): Promise; removeRemote(repoPath: string, name: string): Promise; applyChangesToWorkingFile(uri: GitUri, ref1?: string, ref2?: string): Promise; + applyUnreachableCommitForPatch?( + repoPath: string, + ref: string, + options?: { + branchName?: string; + createBranchIfNeeded?: boolean; + createWorktreePath?: string; + stash?: boolean | 'prompt'; + }, + ): Promise; checkout( repoPath: string, ref: string, options?: { createBranch?: string | undefined } | { path?: string | undefined }, ): Promise; + clone?(url: string, parentPath: string): Promise; + createUnreachableCommitForPatch?( + repoPath: string, + contents: string, + baseRef: string, + message: string, + ): Promise; excludeIgnoredUris(repoPath: string, uris: Uri[]): Promise; fetch( repoPath: string, @@ -152,8 +190,28 @@ export interface GitProvider extends Disposable { remote?: string | undefined; }, ): Promise; + pull( + repoPath: string, + options?: { + branch?: GitBranchReference | undefined; + rebase?: boolean | undefined; + tags?: boolean | undefined; + }, + ): Promise; + push( + repoPath: string, + options?: { + reference?: GitReference | undefined; + force?: boolean | undefined; + publish?: { remote: string }; + }, + ): Promise; findRepositoryUri(uri: Uri, isDirectory?: boolean): Promise; - getAheadBehindCommitCount(repoPath: string, refs: string[]): Promise<{ ahead: number; behind: number } | undefined>; + getLeftRightCommitCount( + repoPath: string, + range: GitRevisionRange, + options?: { authors?: GitUser[] | undefined; excludeMerges?: boolean }, + ): Promise; /** * Returns the blame of a file * @param uri Uri of the file to blame @@ -198,8 +256,8 @@ export interface GitProvider extends Disposable { getBranches( repoPath: string, options?: { - cursor?: string; filter?: ((b: GitBranch) => boolean) | undefined; + paging?: PagingOptions | undefined; sort?: boolean | BranchSortOptions | undefined; }, ): Promise>; @@ -207,14 +265,11 @@ export interface GitProvider extends Disposable { getCommit(repoPath: string, ref: string): Promise; getCommitBranches( repoPath: string, - ref: string, - options?: { - branch?: string | undefined; - commitDate?: Date | undefined; - mode?: 'contains' | 'pointsAt' | undefined; - name?: string | undefined; - remotes?: boolean | undefined; - }, + refs: string[], + branch?: string | undefined, + options?: + | { all?: boolean; commitDate?: Date; mode?: 'contains' | 'pointsAt' } + | { commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, ): Promise; getCommitCount(repoPath: string, ref: string): Promise; getCommitForFile( @@ -230,34 +285,54 @@ export interface GitProvider extends Disposable { repoPath: string, asWebviewUri: (uri: Uri) => Uri, options?: { - branch?: string; include?: { stats?: boolean }; limit?: number; ref?: string; }, ): Promise; - getConfig?(repoPath: string, key: string): Promise; - setConfig?(repoPath: string, key: string, value: string | undefined): Promise; + getCommitTags( + repoPath: string, + ref: string, + options?: { + commitDate?: Date | undefined; + mode?: 'contains' | 'pointsAt' | undefined; + }, + ): Promise; + getConfig?(repoPath: string, key: GitConfigKeys): Promise; + setConfig?(repoPath: string, key: GitConfigKeys, value: string | undefined): Promise; getContributors( repoPath: string, - options?: { all?: boolean | undefined; ref?: string | undefined; stats?: boolean | undefined }, + options?: { + all?: boolean | undefined; + merges?: boolean | 'first-parent'; + ref?: string | undefined; + stats?: boolean | undefined; + }, ): Promise; getCurrentUser(repoPath: string): Promise; + getBaseBranchName?(repoPath: string, ref: string): Promise; getDefaultBranchName(repoPath: string | undefined, remote?: string): Promise; + getDiff?( + repoPath: string | Uri, + to: string, + from?: string, + options?: { context?: number; uris?: Uri[] }, + ): Promise; + getDiffFiles?(repoPath: string | Uri, contents: string): Promise; /** * Returns a file diff between two commits * @param uri Uri of the file to diff * @param ref1 Commit to diff from * @param ref2 Commit to diff to */ - getDiffForFile(uri: GitUri, ref1: string | undefined, ref2?: string): Promise; + getDiffForFile(uri: GitUri, ref1: string | undefined, ref2?: string): Promise; /** * Returns a file diff between a commit and the specified contents * @param uri Uri of the file to diff * @param ref Commit to diff from * @param contents Contents to use for the diff */ - getDiffForFileContents(uri: GitUri, ref: string, contents: string): Promise; + getDiffForFileContents(uri: GitUri, ref: string, contents: string): Promise; /** * Returns a line diff between two commits * @param uri Uri of the file to diff @@ -270,14 +345,15 @@ export interface GitProvider extends Disposable { editorLine: number, ref1: string | undefined, ref2?: string, - ): Promise; + ): Promise; getDiffStatus( repoPath: string, - ref1?: string, + ref1OrRange: string | GitRevisionRange, ref2?: string, - options?: { filters?: GitDiffFilter[] | undefined; similarityThreshold?: number | undefined }, + options?: { filters?: GitDiffFilter[]; path?: string; similarityThreshold?: number }, ): Promise; getFileStatusForCommit(repoPath: string, uri: Uri, ref: string): Promise; + getFirstCommitSha?(repoPath: string): Promise; getGitDir?(repoPath: string): Promise; getLastFetchedTimestamp(repoPath: string): Promise; getLog( @@ -287,7 +363,7 @@ export interface GitProvider extends Disposable { authors?: GitUser[] | undefined; cursor?: string | undefined; limit?: number | undefined; - merges?: boolean | undefined; + merges?: boolean | 'first-parent' | undefined; ordering?: 'date' | 'author-date' | 'topo' | null | undefined; ref?: string | undefined; since?: string | undefined; @@ -299,7 +375,7 @@ export interface GitProvider extends Disposable { authors?: GitUser[] | undefined; cursor?: string | undefined; limit?: number | undefined; - merges?: boolean | undefined; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null | undefined; ref?: string | undefined; since?: string | undefined; @@ -342,7 +418,6 @@ export interface GitProvider extends Disposable { uri: Uri, ref: string | undefined, skip?: number, - firstParent?: boolean, ): Promise; getPreviousComparisonUrisForLine( repoPath: string, @@ -361,10 +436,7 @@ export interface GitProvider extends Disposable { skip?: number | undefined; }, ): Promise; - getRemotes( - repoPath: string | undefined, - options?: { providers?: RemoteProviders; sort?: boolean }, - ): Promise[]>; + getRemotes(repoPath: string | undefined, options?: { sort?: boolean }): Promise; getRevisionContent(repoPath: string, path: string, ref: string): Promise; getStash(repoPath: string | undefined): Promise; getStatusForFile(repoPath: string, uri: Uri): Promise; @@ -373,14 +445,13 @@ export interface GitProvider extends Disposable { getTags( repoPath: string | undefined, options?: { - cursor?: string; filter?: ((t: GitTag) => boolean) | undefined; + paging?: PagingOptions | undefined; sort?: boolean | TagSortOptions | undefined; }, ): Promise>; getTreeEntryForRevision(repoPath: string, path: string, ref: string): Promise; getTreeForRevision(repoPath: string, ref: string): Promise; - getUniqueRepositoryId(repoPath: string): Promise; hasBranchOrTag( repoPath: string | undefined, options?: { @@ -391,6 +462,8 @@ export interface GitProvider extends Disposable { ): Promise; hasCommitBeenPushed(repoPath: string, ref: string): Promise; + hasUnsafeRepositories?(): boolean; + isAncestorOf(repoPath: string, ref1: string, ref2: string): Promise; isTrackable(uri: Uri): boolean; isTracked(uri: Uri): Promise; @@ -423,7 +496,7 @@ export interface GitProvider extends Disposable { }, ): Promise; searchCommits( - repoPath: string | Uri, + repoPath: string, search: SearchQuery, options?: { cancellation?: CancellationToken; @@ -431,22 +504,33 @@ export interface GitProvider extends Disposable { ordering?: 'date' | 'author-date' | 'topo'; }, ): Promise; + + runGitCommandViaTerminal?( + repoPath: string, + command: string, + args: string[], + options?: { execute?: boolean }, + ): Promise; + validateBranchOrTagName(repoPath: string, ref: string): Promise; + validatePatch?(repoPath: string | undefined, contents: string): Promise; validateReference(repoPath: string, ref: string): Promise; stageFile(repoPath: string, pathOrUri: string | Uri): Promise; stageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise; - unStageFile(repoPath: string, pathOrUri: string | Uri): Promise; - unStageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise; + unstageFile(repoPath: string, pathOrUri: string | Uri): Promise; + unstageDirectory(repoPath: string, directoryOrUri: string | Uri): Promise; - stashApply(repoPath: string, stashName: string, options?: { deleteAfter?: boolean | undefined }): Promise; - stashDelete(repoPath: string, stashName: string, ref?: string): Promise; - stashSave( + stashApply?(repoPath: string, stashName: string, options?: { deleteAfter?: boolean | undefined }): Promise; + stashDelete?(repoPath: string, stashName: string, ref?: string): Promise; + stashRename?(repoPath: string, stashName: string, ref: string, message: string, stashOnRef?: string): Promise; + stashSave?( repoPath: string, message?: string, uris?: Uri[], - options?: { includeUntracked?: boolean | undefined; keepIndex?: boolean | undefined }, + options?: { includeUntracked?: boolean; keepIndex?: boolean; onlyStaged?: boolean }, ): Promise; + stashSaveSnapshot?(repoPath: string, message?: string): Promise; createWorktree?( repoPath: string, @@ -461,4 +545,5 @@ export interface GitProvider extends Disposable { export interface RevisionUriData { ref?: string; repoPath: string; + uncPath?: string; } diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index d4bbdda6f0bda..fe6a6c071cb08 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -1,3 +1,4 @@ +import { isWeb } from '@env/platform'; import type { CancellationToken, ConfigurationChangeEvent, @@ -11,56 +12,69 @@ import type { } from 'vscode'; import { Disposable, EventEmitter, FileType, ProgressLocation, Uri, window, workspace } from 'vscode'; import { resetAvatarCache } from '../avatars'; -import { configuration } from '../configuration'; -import { ContextKeys, CoreGitConfiguration, GlyphChars, Schemes } from '../constants'; +import type { GitConfigKeys } from '../constants'; +import { GlyphChars, Schemes } from '../constants'; +import type { SearchQuery } from '../constants.search'; import type { Container } from '../container'; -import { setContext } from '../context'; -import { AccessDeniedError, ProviderNotFoundError } from '../errors'; +import { AccessDeniedError, CancellationError, ProviderNotFoundError } from '../errors'; import type { FeatureAccess, Features, PlusFeatures, RepoFeatureAccess } from '../features'; -import { Logger } from '../logger'; -import { getLogScope } from '../logScope'; -import type { SubscriptionChangeEvent } from '../plus/subscription/subscriptionService'; +import { getApplicablePromo } from '../plus/gk/account/promos'; +import type { Subscription } from '../plus/gk/account/subscription'; +import { isSubscriptionPaidPlan, SubscriptionPlanId } from '../plus/gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../plus/gk/account/subscriptionService'; +import type { HostingIntegration } from '../plus/integrations/integration'; import type { RepoComparisonKey } from '../repositories'; import { asRepoComparisonKey, Repositories } from '../repositories'; -import type { Subscription } from '../subscription'; -import { isSubscriptionPaidPlan, SubscriptionPlanId } from '../subscription'; -import { groupByFilterMap, groupByMap, joinUnique } from '../system/array'; +import { joinUnique } from '../system/array'; import { gate } from '../system/decorators/gate'; import { debug, log } from '../system/decorators/log'; -import { count, filter, first, flatMap, join, map, some } from '../system/iterable'; -import { getBestPath, getScheme, isAbsolute, maybeUri, normalizePath } from '../system/path'; -import { cancellable, fastestSettled, getSettledValue, isPromise, PromiseCancelledError } from '../system/promise'; +import type { Deferrable } from '../system/function'; +import { debounce } from '../system/function'; +import { count, filter, first, flatMap, groupByFilterMap, groupByMap, join, map, some } from '../system/iterable'; +import { Logger } from '../system/logger'; +import { getLogScope, setLogScopeExit } from '../system/logger.scope'; +import { getScheme, isAbsolute, maybeUri, normalizePath } from '../system/path'; +import type { Deferred } from '../system/promise'; +import { asSettled, defer, getDeferredPromiseIfPending, getSettledValue } from '../system/promise'; +import { sortCompare } from '../system/string'; import { VisitedPathsTrie } from '../system/trie'; +import { registerCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; +import { getBestPath } from '../system/vscode/path'; import type { GitCaches, GitDir, GitProvider, GitProviderDescriptor, GitProviderId, + LeftRightCommitCountResult, NextComparisonUrisResult, PagedResult, + PagingOptions, PreviousComparisonUrisResult, PreviousLineComparisonUrisResult, + RepositoryVisibility, + RepositoryVisibilityInfo, ScmRepository, } from './gitProvider'; -import { RepositoryVisibility } from './gitProvider'; import type { GitUri } from './gitUri'; import type { GitBlame, GitBlameLine, GitBlameLines } from './models/blame'; import type { BranchSortOptions, GitBranch } from './models/branch'; import { GitCommit, GitCommitIdentity } from './models/commit'; +import { deletedOrMissing, uncommitted, uncommittedStaged } from './models/constants'; import type { GitContributor } from './models/contributor'; -import type { GitDiff, GitDiffFilter, GitDiffHunkLine, GitDiffShortStat } from './models/diff'; +import type { GitDiff, GitDiffFile, GitDiffFiles, GitDiffFilter, GitDiffLine, GitDiffShortStat } from './models/diff'; import type { GitFile } from './models/file'; import type { GitGraph } from './models/graph'; -import type { SearchedIssue } from './models/issue'; import type { GitLog } from './models/log'; import type { GitMergeStatus } from './models/merge'; -import type { PullRequest, PullRequestState, SearchedPullRequest } from './models/pullRequest'; import type { GitRebaseStatus } from './models/rebase'; -import type { GitBranchReference, GitReference } from './models/reference'; -import { GitRevision } from './models/reference'; +import type { GitBranchReference, GitReference, GitRevisionRange } from './models/reference'; +import { createRevisionRange, isSha, isUncommitted, isUncommittedParent } from './models/reference'; import type { GitReflog } from './models/reflog'; -import { GitRemote } from './models/remote'; +import type { GitRemote } from './models/remote'; +import { getRemoteThemeIconString, getVisibilityCacheKey } from './models/remote'; import type { RepositoryChangeEvent } from './models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from './models/repository'; import type { GitStash } from './models/stash'; @@ -70,12 +84,15 @@ import type { GitTreeEntry } from './models/tree'; import type { GitUser } from './models/user'; import type { GitWorktree } from './models/worktree'; import type { RemoteProvider } from './remotes/remoteProvider'; -import { RichRemoteProviders } from './remotes/remoteProviderConnections'; -import type { RemoteProviders } from './remotes/remoteProviders'; -import type { RichRemoteProvider } from './remotes/richRemoteProvider'; -import type { GitSearch, SearchQuery } from './search'; +import type { GitSearch } from './search'; const emptyArray = Object.freeze([]) as unknown as any[]; +const emptyDisposable = Object.freeze({ + dispose: () => { + /* noop */ + }, +}); + const maxDefaultBranchWeight = 100; const weightedDefaultBranches = new Map([ ['master', maxDefaultBranchWeight], @@ -85,8 +102,6 @@ const weightedDefaultBranches = new Map([ ['development', 1], ]); -const defaultRepositoryId = '0'; - export type GitProvidersChangeEvent = { readonly added: readonly GitProvider[]; readonly removed: readonly GitProvider[]; @@ -104,27 +119,27 @@ export interface GitProviderResult { path: string; } -export const enum RepositoriesVisibility { - Private = 'private', - Public = 'public', - Local = 'local', - Mixed = 'mixed', -} +export type RepositoriesVisibility = RepositoryVisibility | 'mixed'; export class GitProviderService implements Disposable { private readonly _onDidChangeProviders = new EventEmitter(); get onDidChangeProviders(): Event { return this._onDidChangeProviders.event; } + + @debug({ + args: { + 0: added => `(${added?.length ?? 0}) ${added?.map(p => p.descriptor.id).join(', ')}`, + 1: removed => `(${removed?.length ?? 0}) ${removed?.map(p => p.descriptor.id).join(', ')}`, + }, + }) private fireProvidersChanged(added?: GitProvider[], removed?: GitProvider[]) { - this.container.telemetry.setGlobalAttributes({ - 'providers.count': this._providers.size, - 'providers.ids': join(this._providers.keys(), ','), - }); - this.container.telemetry.sendEvent('providers/changed', { - 'providers.added': added?.length ?? 0, - 'providers.removed': removed?.length ?? 0, - }); + if (this.container.telemetry.enabled) { + this.container.telemetry.setGlobalAttributes({ + 'providers.count': this._providers.size, + 'providers.ids': join(this._providers.keys(), ','), + }); + } this._etag = Date.now(); @@ -135,38 +150,54 @@ export class GitProviderService implements Disposable { get onDidChangeRepositories(): Event { return this._onDidChangeRepositories.event; } + + @debug({ + args: { + 0: added => `(${added?.length ?? 0}) ${added?.map(r => r.id).join(', ')}`, + 1: removed => `(${removed?.length ?? 0}) ${removed?.map(r => r.id).join(', ')}`, + }, + }) private fireRepositoriesChanged(added?: Repository[], removed?: Repository[]) { - const openSchemes = this.openRepositories.map(r => r.uri.scheme); - this.container.telemetry.setGlobalAttributes({ - 'repositories.count': openSchemes.length, - 'repositories.schemes': joinUnique(openSchemes, ','), - }); - this.container.telemetry.sendEvent('repositories/changed', { - 'repositories.added': added?.length ?? 0, - 'repositories.removed': removed?.length ?? 0, - }); + if (this.container.telemetry.enabled) { + const openSchemes = this.openRepositories.map(r => r.uri.scheme); + + this.container.telemetry.setGlobalAttributes({ + 'repositories.count': openSchemes.length, + 'repositories.schemes': joinUnique(openSchemes, ','), + }); + this.container.telemetry.sendEvent('repositories/changed', { + 'repositories.added': added?.length ?? 0, + 'repositories.removed': removed?.length ?? 0, + }); + } this._etag = Date.now(); - this._accessCache.clear(); - this._visibilityCache.delete(undefined); - if (removed?.length) { - this._visibilityCache.clear(); - } + this.clearAccessCache(); + this._reposVisibilityCache = undefined; + this._onDidChangeRepositories.fire({ added: added ?? [], removed: removed ?? [], etag: this._etag }); - if (added?.length) { - queueMicrotask(() => { + if (added?.length && this.container.telemetry.enabled) { + setTimeout(async () => { for (const repo of added) { + const remoteProviders = new Set(); + + const remotes = await repo.getRemotes(); + for (const remote of remotes) { + remoteProviders.add(remote.provider?.id ?? 'unknown'); + } + this.container.telemetry.sendEvent('repository/opened', { 'repository.id': repo.idHash, 'repository.scheme': repo.uri.scheme, 'repository.closed': repo.closed, 'repository.folder.scheme': repo.folder?.uri.scheme, 'repository.provider.id': repo.provider.id, + 'repository.remoteProviders': join(remoteProviders, ','), }); } - }); + }, 0); } } @@ -177,22 +208,22 @@ export class GitProviderService implements Disposable { readonly supportedSchemes = new Set(); + private readonly _bestRemotesCache = new Map[]>>(); private readonly _disposable: Disposable; + private _initializing: Deferred | undefined; private readonly _pendingRepositories = new Map>(); private readonly _providers = new Map(); private readonly _repositories = new Repositories(); - private readonly _bestRemotesCache: Map | null> & - Map<`rich|${RepoComparisonKey}`, GitRemote | null> & - Map<`rich+connected|${RepoComparisonKey}`, GitRemote | null> = new Map(); private readonly _visitedPaths = new VisitedPathsTrie(); constructor(private readonly container: Container) { + this._initializing = defer(); this._disposable = Disposable.from( container.subscription.onDidChange(this.onSubscriptionChanged, this), window.onDidChangeWindowState(this.onWindowStateChanged, this), workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged, this), configuration.onDidChange(this.onConfigurationChanged, this), - RichRemoteProviders.onDidChangeConnectionState(e => { + container.integrations.onDidChangeConnectionState(e => { if (e.reason === 'connected') { resetAvatarCache('failed'); } @@ -200,6 +231,14 @@ export class GitProviderService implements Disposable { this.resetCaches('providers'); this.updateContext(); }), + !workspace.isTrusted + ? workspace.onDidGrantWorkspaceTrust(() => { + if (workspace.isTrusted && workspace.workspaceFolders?.length) { + void this.discoverRepositories(workspace.workspaceFolders, { force: true }); + } + }) + : emptyDisposable, + ...this.registerCommands(), ); this.container.BranchDateFormatting.reset(); @@ -247,9 +286,13 @@ export class GitProviderService implements Disposable { } } + private registerCommands(): Disposable[] { + return [registerCommand('gitlens.plus.refreshRepositoryAccess', () => this.clearAllOpenRepoVisibilityCaches())]; + } + @debug() onSubscriptionChanged(e: SubscriptionChangeEvent) { - this._accessCache.clear(); + this.clearAccessCache(); this._subscription = e.current; } @@ -267,11 +310,13 @@ export class GitProviderService implements Disposable { singleLine: true, }) private onWorkspaceFoldersChanged(e: WorkspaceFoldersChangeEvent) { - const schemes = workspace.workspaceFolders?.map(f => f.uri.scheme); - this.container.telemetry.setGlobalAttributes({ - 'folders.count': schemes?.length ?? 0, - 'folders.schemes': schemes != null ? joinUnique(schemes, ', ') : '', - }); + if (this.container.telemetry.enabled) { + const schemes = workspace.workspaceFolders?.map(f => f.uri.scheme); + this.container.telemetry.setGlobalAttributes({ + 'folders.count': schemes?.length ?? 0, + 'folders.schemes': schemes != null ? joinUnique(schemes, ', ') : '', + }); + } if (e.added.length) { void this.discoverRepositories(e.added); @@ -330,7 +375,9 @@ export class GitProviderService implements Disposable { } get highlander(): Repository | undefined { - return this.repositoryCount === 1 ? first(this._repositories.values()) : undefined; + return this.repositoryCount === 1 || this.openRepositoryCount === 1 + ? first(this._repositories.values()) + : undefined; } // get readonly() { @@ -379,7 +426,17 @@ export class GitProviderService implements Disposable { const disposable = Disposable.from( provider, ...disposables, - provider.onDidChangeRepository(e => { + provider.onDidChange(() => { + Logger.debug(`GitProvider(${id}).onDidChange()`); + + const { workspaceFolders } = workspace; + if (workspaceFolders?.length) { + void this.discoverRepositories(workspaceFolders, { force: true }); + } + }), + provider.onDidChangeRepository(async e => { + Logger.debug(`GitProvider(${id}).onDidChangeRepository(e=${e.repository.toString()})`); + if ( e.changed( RepositoryChange.Remotes, @@ -395,19 +452,37 @@ export class GitProviderService implements Disposable { // Send a notification that the repositories changed queueMicrotask(() => this.fireRepositoriesChanged([], [e.repository])); + } else if (e.changed(RepositoryChange.Opened, RepositoryChangeComparisonMode.Any)) { + this.updateContext(); + + // Send a notification that the repositories changed + queueMicrotask(() => this.fireRepositoriesChanged([e.repository], [])); + } + + if (e.changed(RepositoryChange.Remotes, RepositoryChangeComparisonMode.Any)) { + const remotes = await provider.getRemotes(e.repository.path); + const visibilityInfo = this.getVisibilityInfoFromCache(e.repository.path); + if (visibilityInfo != null) { + this.checkVisibilityCachedRemotes(e.repository.path, visibilityInfo, remotes); + } } - this._visibilityCache.delete(e.repository.path); this._onDidChangeRepository.fire(e); }), provider.onDidCloseRepository(e => { const repository = this._repositories.get(e.uri); + Logger.debug( + `GitProvider(${id}).onDidCloseRepository(e=${repository?.toString() ?? e.uri.toString()})`, + ); + if (repository != null) { repository.closed = true; } }), provider.onDidOpenRepository(e => { const repository = this._repositories.get(e.uri); + Logger.debug(`GitProvider(${id}).onDidOpenRepository(e=${repository?.toString() ?? e.uri.toString()})`); + if (repository != null) { repository.closed = false; } else { @@ -419,7 +494,7 @@ export class GitProviderService implements Disposable { this.fireProvidersChanged([provider]); // Don't kick off the discovery if we're still initializing (we'll do it at the end for all "known" providers) - if (!this._initializing) { + if (this._initializing == null) { this.onWorkspaceFoldersChanged({ added: workspace.workspaceFolders ?? [], removed: [] }); } @@ -437,49 +512,68 @@ export class GitProviderService implements Disposable { } } - this.updateContext(); + const { deactivating } = this.container; + if (!deactivating) { + this.updateContext(); + } if (removed.length) { // Defer the event trigger enough to let everything unwind queueMicrotask(() => { - this.fireRepositoriesChanged([], removed); + if (!deactivating) { + this.fireRepositoriesChanged([], removed); + } removed.forEach(r => r.dispose()); }); } - this.fireProvidersChanged([], [provider]); + if (!deactivating) { + this.fireProvidersChanged([], [provider]); + } }, }; } - private _initializing: boolean = true; - @log({ singleLine: true }) - registrationComplete() { + async registrationComplete() { const scope = getLogScope(); - this._initializing = false; - - const { workspaceFolders } = workspace; + let { workspaceFolders } = workspace; if (workspaceFolders?.length) { - void this.discoverRepositories(workspaceFolders); + await this.discoverRepositories(workspaceFolders); + + // This is a hack to work around some issue with remote repositories on the web not being discovered on the initial load + if (this.repositoryCount === 0 && isWeb) { + setTimeout(() => { + ({ workspaceFolders } = workspace); + if (workspaceFolders?.length) { + void this.discoverRepositories(workspaceFolders, { force: true }); + } + }, 1000); + } } else { + this._initializing?.fulfill(this._etag); + this._initializing = undefined; + this.updateContext(); } - const autoRepositoryDetection = configuration.getAny( - CoreGitConfiguration.AutoRepositoryDetection, - ); + const autoRepositoryDetection = configuration.getCore('git.autoRepositoryDetection'); - queueMicrotask(() => - this.container.telemetry.sendEvent('providers/registrationComplete', { - 'config.git.autoRepositoryDetection': autoRepositoryDetection, - }), - ); - - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} workspaceFolders=${workspaceFolders?.length}, git.autoRepositoryDetection=${autoRepositoryDetection}`; + if (this.container.telemetry.enabled) { + setTimeout( + () => + this.container.telemetry.sendEvent('providers/registrationComplete', { + 'config.git.autoRepositoryDetection': autoRepositoryDetection, + }), + 0, + ); } + + setLogScopeExit( + scope, + ` ${GlyphChars.Dot} repositories=${this.repositoryCount}, workspaceFolders=${workspaceFolders?.length}, git.autoRepositoryDetection=${autoRepositoryDetection}`, + ); } getOpenProviders(): GitProvider[] { @@ -504,46 +598,71 @@ export class GitProviderService implements Disposable { private _discoveredWorkspaceFolders = new Map>(); + private _discoveringRepositories: Deferred | undefined; + get isDiscoveringRepositories(): Promise | undefined { + return ( + getDeferredPromiseIfPending(this._discoveringRepositories) ?? + getDeferredPromiseIfPending(this._initializing) + ); + } + @log({ args: { 0: folders => folders.length } }) async discoverRepositories(folders: readonly WorkspaceFolder[], options?: { force?: boolean }): Promise { - const promises = []; + if (this._discoveringRepositories?.pending) { + await this._discoveringRepositories.promise; + this._discoveringRepositories = undefined; + } - for (const folder of folders) { - if (!options?.force && this._discoveredWorkspaceFolders.has(folder)) continue; + const deferred = this._initializing ?? defer(); + this._discoveringRepositories = deferred; + this._initializing = undefined; - const promise = this.discoverRepositoriesCore(folder); - promises.push(promise); - this._discoveredWorkspaceFolders.set(folder, promise); - } + try { + const promises = []; - if (promises.length === 0) return; + for (const folder of folders) { + if (!options?.force && this._discoveredWorkspaceFolders.has(folder)) continue; - const results = await Promise.allSettled(promises); + const promise = this.discoverRepositoriesCore(folder); + promises.push(promise); + this._discoveredWorkspaceFolders.set(folder, promise); + } - const repositories = flatMap, Repository>( - filter, PromiseFulfilledResult>( - results, - (r): r is PromiseFulfilledResult => r.status === 'fulfilled', - ), - r => r.value, - ); + if (promises.length === 0) return; - const added: Repository[] = []; + const results = await Promise.allSettled(promises); - for (const repository of repositories) { - if (this._repositories.add(repository)) { - added.push(repository); - } - } + const repositories = flatMap, Repository>( + filter, PromiseFulfilledResult>( + results, + (r): r is PromiseFulfilledResult => r.status === 'fulfilled', + ), + r => r.value, + ); - this.updateContext(); + const added: Repository[] = []; - if (added.length === 0) return; + for (const repository of repositories) { + this._repositories.add(repository); + if (!repository.closed) { + added.push(repository); + } + } - // Defer the event trigger enough to let everything unwind - queueMicrotask(() => this.fireRepositoriesChanged(added)); + this.updateContext(); + + if (added.length) { + // Defer the event trigger enough to let everything unwind + queueMicrotask(() => this.fireRepositoriesChanged(added)); + } + } finally { + queueMicrotask(() => { + deferred.fulfill(this._etag); + }); + } } + @debug({ exit: true }) private async discoverRepositoriesCore(folder: WorkspaceFolder): Promise { const { provider } = this.getProvider(folder.uri); @@ -563,21 +682,36 @@ export class GitProviderService implements Disposable { } } + @log() + async findRepositories( + uri: Uri, + options?: { cancellation?: CancellationToken; depth?: number; silent?: boolean }, + ): Promise { + const { provider } = this.getProvider(uri); + return provider.discoverRepositories(uri, options); + } + private _subscription: Subscription | undefined; private async getSubscription(): Promise { return this._subscription ?? (this._subscription = await this.container.subscription.getSubscription()); } - private _accessCache: Map> & - Map> = new Map(); + private _accessCache = new Map>(); + private _accessCacheByRepo = new Map>(); + private clearAccessCache(): void { + this._accessCache.clear(); + this._accessCacheByRepo.clear(); + } + async access(feature: PlusFeatures | undefined, repoPath: string | Uri): Promise; async access(feature?: PlusFeatures, repoPath?: string | Uri): Promise; + @debug({ exit: true }) async access(feature?: PlusFeatures, repoPath?: string | Uri): Promise { if (repoPath == null) { - let access = this._accessCache.get(undefined); + let access = this._accessCache.get(feature); if (access == null) { - access = this.accessCore(feature, repoPath); - this._accessCache.set(undefined, access); + access = this.accessCore(feature); + this._accessCache.set(feature, access); } return access; } @@ -585,10 +719,10 @@ export class GitProviderService implements Disposable { const { path } = this.getProvider(repoPath); const cacheKey = path; - let access = this._accessCache.get(cacheKey); + let access = this._accessCacheByRepo.get(cacheKey); if (access == null) { access = this.accessCore(feature, repoPath); - this._accessCache.set(cacheKey, access); + this._accessCacheByRepo.set(cacheKey, access); } return access; @@ -599,9 +733,9 @@ export class GitProviderService implements Disposable { feature?: PlusFeatures, repoPath?: string | Uri, ): Promise; - @debug() + @debug({ exit: true }) private async accessCore( - _feature?: PlusFeatures, + feature?: PlusFeatures, repoPath?: string | Uri, ): Promise { const subscription = await this.getSubscription(); @@ -615,6 +749,14 @@ export class GitProviderService implements Disposable { return { allowed: subscription.account?.verified !== false, subscription: { current: subscription } }; } + if (feature === 'launchpad') { + // If our launchpad graduation promo is active allow access for everyone + if (getApplicablePromo(subscription.state, 'launchpad')) { + return { allowed: true, subscription: { current: subscription } }; + } + return { allowed: false, subscription: { current: subscription, required: SubscriptionPlanId.Pro } }; + } + function getRepoAccess( this: GitProviderService, repoPath: string | Uri, @@ -622,11 +764,11 @@ export class GitProviderService implements Disposable { ): Promise { const { path: cacheKey } = this.getProvider(repoPath); - let access = force ? undefined : this._accessCache.get(cacheKey); + let access = force ? undefined : this._accessCacheByRepo.get(cacheKey); if (access == null) { access = this.visibility(repoPath).then( visibility => { - if (visibility === RepositoryVisibility.Private) { + if (visibility === 'private') { return { allowed: false, subscription: { current: subscription, required: SubscriptionPlanId.Pro }, @@ -644,7 +786,7 @@ export class GitProviderService implements Disposable { () => ({ allowed: true, subscription: { current: subscription } }), ); - this._accessCache.set(cacheKey, access); + this._accessCacheByRepo.set(cacheKey, access); } return access; @@ -662,13 +804,13 @@ export class GitProviderService implements Disposable { const visibility = await this.visibility(); switch (visibility) { - case RepositoriesVisibility.Private: + case 'private': return { allowed: false, subscription: { current: subscription, required: SubscriptionPlanId.Pro }, - visibility: RepositoryVisibility.Private, + visibility: 'private', }; - case RepositoriesVisibility.Mixed: + case 'mixed': return { allowed: 'mixed', subscription: { current: subscription, required: SubscriptionPlanId.Pro }, @@ -677,7 +819,7 @@ export class GitProviderService implements Disposable { return { allowed: true, subscription: { current: subscription }, - visibility: RepositoryVisibility.Public, + visibility: 'public', }; } } @@ -691,71 +833,173 @@ export class GitProviderService implements Disposable { if (allowed === false) throw new AccessDeniedError(subscription.current, subscription.required); } + @debug({ exit: true }) supports(repoPath: string | Uri, feature: Features): Promise { const { provider } = this.getProvider(repoPath); return provider.supports(feature); } - private _visibilityCache: Map> & - Map> = new Map(); + private _reposVisibilityCache: RepositoriesVisibility | undefined; + private _repoVisibilityCache: Map | undefined; + + private ensureRepoVisibilityCache(): void { + if (this._repoVisibilityCache == null) { + const repoVisibility: [string, RepositoryVisibilityInfo][] | undefined = this.container.storage + .get('repoVisibility') + ?.map<[string, RepositoryVisibilityInfo]>(([key, visibilityInfo]) => [ + key, + { + visibility: visibilityInfo.visibility as RepositoryVisibility, + timestamp: visibilityInfo.timestamp, + remotesHash: visibilityInfo.remotesHash, + }, + ]); + this._repoVisibilityCache = new Map(repoVisibility); + } + } + + private async clearRepoVisibilityCache(keys?: string[]): Promise { + if (keys == null) { + this._repoVisibilityCache = undefined; + void this.container.storage.delete('repoVisibility'); + } else { + keys?.forEach(key => this._repoVisibilityCache?.delete(key)); + + const repoVisibility = Array.from(this._repoVisibilityCache?.entries() ?? []); + if (repoVisibility.length === 0) { + await this.container.storage.delete('repoVisibility'); + } else { + await this.container.storage.store('repoVisibility', repoVisibility); + } + } + } + + @debug({ exit: r => `returned ${r?.visibility}` }) + private getVisibilityInfoFromCache(key: string): RepositoryVisibilityInfo | undefined { + this.ensureRepoVisibilityCache(); + const visibilityInfo = this._repoVisibilityCache?.get(key); + if (visibilityInfo == null) return undefined; + + const now = Date.now(); + if (now - visibilityInfo.timestamp > 1000 * 60 * 60 * 24 * 30 /* TTL is 30 days */) { + void this.clearRepoVisibilityCache([key]); + return undefined; + } + + return visibilityInfo; + } + + private checkVisibilityCachedRemotes( + key: string, + visibilityInfo: RepositoryVisibilityInfo | undefined, + remotes: GitRemote[], + ): boolean { + if (visibilityInfo == null) return true; + + if (visibilityInfo.visibility === 'public') { + if (remotes.length == 0 || !remotes.some(r => r.remoteKey === visibilityInfo.remotesHash)) { + void this.clearRepoVisibilityCache([key]); + return false; + } + } else if (visibilityInfo.visibility === 'private') { + const remotesHash = getVisibilityCacheKey(remotes); + if (remotesHash !== visibilityInfo.remotesHash) { + void this.clearRepoVisibilityCache([key]); + return false; + } + } + + return true; + } + + private updateVisibilityCache(key: string, visibilityInfo: RepositoryVisibilityInfo): void { + this.ensureRepoVisibilityCache(); + this._repoVisibilityCache?.set(key, visibilityInfo); + void this.container.storage.store('repoVisibility', Array.from(this._repoVisibilityCache!.entries())); + } + + @debug() + clearAllRepoVisibilityCaches(): Promise { + return this.clearRepoVisibilityCache(); + } + + @debug() + clearAllOpenRepoVisibilityCaches(): Promise { + const openRepoProviderPaths = this.openRepositories.map(r => this.getProvider(r.path).path); + return this.clearRepoVisibilityCache(openRepoProviderPaths); + } + visibility(): Promise; visibility(repoPath: string | Uri): Promise; + @debug({ exit: true }) async visibility(repoPath?: string | Uri): Promise { if (repoPath == null) { - let visibility = this._visibilityCache.get(undefined); + let visibility = this._reposVisibilityCache; if (visibility == null) { - visibility = this.visibilityCore(); - void visibility.then(v => { - this.container.telemetry.setGlobalAttribute('repositories.visibility', v); - this.container.telemetry.sendEvent('repositories/visibility'); - }); - this._visibilityCache.set(undefined, visibility); + visibility = await this.visibilityCore(); + if (this.container.telemetry.enabled) { + this.container.telemetry.setGlobalAttribute('repositories.visibility', visibility); + this.container.telemetry.sendEvent('repositories/visibility', { + 'repositories.visibility': visibility, + }); + } + this._reposVisibilityCache = visibility; } return visibility; } const { path: cacheKey } = this.getProvider(repoPath); - let visibility = this._visibilityCache.get(cacheKey); + let visibility = this.getVisibilityInfoFromCache(cacheKey)?.visibility; if (visibility == null) { - visibility = this.visibilityCore(repoPath); - void visibility.then(v => - queueMicrotask(() => { + visibility = await this.visibilityCore(repoPath); + if (this.container.telemetry.enabled) { + setTimeout(() => { const repo = this.getRepository(repoPath); this.container.telemetry.sendEvent('repository/visibility', { - 'repository.visibility': v, + 'repository.visibility': visibility, 'repository.id': repo?.idHash, 'repository.scheme': repo?.uri.scheme, 'repository.closed': repo?.closed, 'repository.folder.scheme': repo?.folder?.uri.scheme, 'repository.provider.id': repo?.provider.id, }); - }), - ); - this._visibilityCache.set(cacheKey, visibility); + }, 0); + } } return visibility; } private visibilityCore(): Promise; private visibilityCore(repoPath: string | Uri): Promise; - @debug() + @debug({ exit: true }) private async visibilityCore(repoPath?: string | Uri): Promise { - function getRepoVisibility(this: GitProviderService, repoPath: string | Uri): Promise { + async function getRepoVisibility( + this: GitProviderService, + repoPath: string | Uri, + ): Promise { const { provider, path } = this.getProvider(repoPath); + const remotes = await provider.getRemotes(path, { sort: true }); + const visibilityInfo = this.getVisibilityInfoFromCache(path); + if (visibilityInfo == null || !this.checkVisibilityCachedRemotes(path, visibilityInfo, remotes)) { + const [visibility, remotesHash] = await provider.visibility(path); + if (visibility !== 'local') { + this.updateVisibilityCache(path, { + visibility: visibility, + timestamp: Date.now(), + remotesHash: remotesHash, + }); + } - let visibility = this._visibilityCache.get(path); - if (visibility == null) { - visibility = provider.visibility(path); - this._visibilityCache.set(path, visibility); + return visibility; } - return visibility; + return visibilityInfo.visibility; } if (repoPath == null) { const repositories = this.openRepositories; - if (repositories.length === 0) return RepositoriesVisibility.Private; + if (repositories.length === 0) return 'private'; if (repositories.length === 1) { return getRepoVisibility.call(this, repositories[0].path); @@ -765,27 +1009,27 @@ export class GitProviderService implements Disposable { let isPrivate = false; let isLocal = false; - for await (const result of fastestSettled(repositories.map(r => getRepoVisibility.call(this, r.path)))) { + for await (const result of asSettled(repositories.map(r => getRepoVisibility.call(this, r.path)))) { if (result.status !== 'fulfilled') continue; - if (result.value === RepositoryVisibility.Public) { - if (isLocal || isPrivate) return RepositoriesVisibility.Mixed; + if (result.value === 'public') { + if (isLocal || isPrivate) return 'mixed'; isPublic = true; - } else if (result.value === RepositoryVisibility.Local) { - if (isPublic || isPrivate) return RepositoriesVisibility.Mixed; + } else if (result.value === 'local') { + if (isPublic || isPrivate) return 'mixed'; isLocal = true; - } else if (result.value === RepositoryVisibility.Private) { - if (isPublic || isLocal) return RepositoriesVisibility.Mixed; + } else if (result.value === 'private') { + if (isPublic || isLocal) return 'mixed'; isPrivate = true; } } - if (isPublic) return RepositoriesVisibility.Public; - if (isLocal) return RepositoriesVisibility.Local; - return RepositoriesVisibility.Private; + if (isPublic) return 'public'; + if (isLocal) return 'local'; + return 'private'; } return getRepoVisibility.call(this, repoPath); @@ -797,8 +1041,8 @@ export class GitProviderService implements Disposable { async setEnabledContext(enabled: boolean): Promise { let disabled = !enabled; // If we think we should be disabled during startup, check if we have a saved value from the last time this repo was loaded - if (!enabled && this._initializing) { - disabled = !(this.container.storage.getWorkspace('assumeRepositoriesOnStartup') ?? true); + if (!enabled && this._initializing != null) { + disabled = !(this.container.storage.getWorkspace('assumeRepositoriesOnStartup') ?? false); } this.container.telemetry.setGlobalAttribute('enabled', enabled); @@ -809,29 +1053,33 @@ export class GitProviderService implements Disposable { if (this._context.enabled !== enabled) { this._context.enabled = enabled; - promises.push(setContext(ContextKeys.Enabled, enabled)); + promises.push(setContext('gitlens:enabled', enabled)); } if (this._context.disabled !== disabled) { this._context.disabled = disabled; - promises.push(setContext(ContextKeys.Disabled, disabled)); + promises.push(setContext('gitlens:disabled', disabled)); } await Promise.allSettled(promises); - if (!this._initializing) { - void this.container.storage.storeWorkspace('assumeRepositoriesOnStartup', enabled); + if (this._initializing == null) { + void this.container.storage.storeWorkspace('assumeRepositoriesOnStartup', enabled).catch(); } } + private _sendProviderContextTelemetryDebounced: Deferrable<() => void> | undefined; + private updateContext() { + if (this.container.deactivating) return; + const openRepositoryCount = this.openRepositoryCount; const hasRepositories = openRepositoryCount !== 0; void this.setEnabledContext(hasRepositories); // Don't bother trying to set the values if we're still starting up - if (this._initializing) return; + if (this._initializing != null) return; this.container.telemetry.setGlobalAttributes({ enabled: hasRepositories, @@ -844,47 +1092,81 @@ export class GitProviderService implements Disposable { async function updateRemoteContext(this: GitProviderService) { const integrations = configuration.get('integrations.enabled'); - let hasRemotes = false; - let hasRichRemotes = false; - let hasConnectedRemotes = false; - if (hasRepositories) { - for (const repo of this._repositories.values()) { - if (!hasConnectedRemotes && integrations) { - hasConnectedRemotes = await repo.hasRichRemote(true); + const remoteProviders = new Set(); + const reposWithRemotes = new Set(); + const reposWithHostingIntegrations = new Set(); + const reposWithHostingIntegrationsConnected = new Set(); + + async function scanRemotes(repo: Repository) { + let hasSupportedIntegration = false; + let hasConnectedIntegration = false; + + const remotes = await repo.getRemotes(); + for (const remote of remotes) { + remoteProviders.add(remote.provider?.id ?? 'unknown'); + reposWithRemotes.add(repo.uri.toString()); + reposWithRemotes.add(repo.path); + + // Skip if integrations are disabled or if we've already found a connected integration + if (!integrations || (hasSupportedIntegration && hasConnectedIntegration)) continue; + + if (remote.hasIntegration()) { + hasSupportedIntegration = true; + reposWithHostingIntegrations.add(repo.uri.toString()); + reposWithHostingIntegrations.add(repo.path); + + let connected = remote.maybeIntegrationConnected; + // If we don't know if we are connected, only check if the remote is the default or there is only one + // TODO@eamodio is the above still a valid requirement? + if (connected == null && (remote.default || remotes.length === 1)) { + const integration = await remote.getIntegration(); + connected = await integration?.isConnected(); + } - if (hasConnectedRemotes) { - hasRichRemotes = true; - hasRemotes = true; + if (connected) { + hasConnectedIntegration = true; + reposWithHostingIntegrationsConnected.add(repo.uri.toString()); + reposWithHostingIntegrationsConnected.add(repo.path); } } + } + } - if (!hasRichRemotes && integrations) { - hasRichRemotes = await repo.hasRichRemote(); + if (hasRepositories) { + void (await Promise.allSettled(map(this._repositories.values(), scanRemotes))); + } - if (hasRichRemotes) { - hasRemotes = true; - } - } + if (this.container.telemetry.enabled) { + this.container.telemetry.setGlobalAttributes({ + 'repositories.hasRemotes': reposWithRemotes.size !== 0, + 'repositories.hasRichRemotes': reposWithHostingIntegrations.size !== 0, + 'repositories.hasConnectedRemotes': reposWithHostingIntegrationsConnected.size !== 0, - if (!hasRemotes) { - hasRemotes = await repo.hasRemotes(); - } + 'repositories.withRemotes': reposWithRemotes.size / 2, + 'repositories.withHostingIntegrations': reposWithHostingIntegrations.size / 2, + 'repositories.withHostingIntegrationsConnected': reposWithHostingIntegrationsConnected.size / 2, - if (hasRemotes && ((hasRichRemotes && hasConnectedRemotes) || !integrations)) break; + 'repositories.remoteProviders': join(remoteProviders, ','), + }); + if (this._sendProviderContextTelemetryDebounced == null) { + this._sendProviderContextTelemetryDebounced = debounce( + () => this.container.telemetry.sendEvent('providers/context'), + 2500, + ); } + this._sendProviderContextTelemetryDebounced(); } - this.container.telemetry.setGlobalAttributes({ - 'repositories.hasRemotes': hasRemotes, - 'repositories.hasRichRemotes': hasRichRemotes, - 'repositories.hasConnectedRemotes': hasConnectedRemotes, - }); - queueMicrotask(() => this.container.telemetry.sendEvent('providers/context')); - await Promise.allSettled([ - setContext(ContextKeys.HasRemotes, hasRemotes), - setContext(ContextKeys.HasRichRemotes, hasRichRemotes), - setContext(ContextKeys.HasConnectedRemotes, hasConnectedRemotes), + setContext('gitlens:repos:withRemotes', reposWithRemotes.size ? [...reposWithRemotes] : undefined), + setContext( + 'gitlens:repos:withHostingIntegrations', + reposWithHostingIntegrations.size ? [...reposWithHostingIntegrations] : undefined, + ), + setContext( + 'gitlens:repos:withHostingIntegrationsConnected', + reposWithHostingIntegrationsConnected.size ? [...reposWithHostingIntegrationsConnected] : undefined, + ), ]); } @@ -960,7 +1242,7 @@ export class GitProviderService implements Disposable { path: string, ref: string | undefined, ): Promise { - if (repoPath == null || ref === GitRevision.deletedOrMissing) return undefined; + if (repoPath == null || ref === deletedOrMissing) return undefined; const { provider, path: rp } = this.getProvider(repoPath); return provider.getBestRevisionUri(rp, provider.getRelativePath(path, rp), ref); @@ -1029,11 +1311,47 @@ export class GitProviderService implements Disposable { } @log() - checkout(repoPath: string, ref: string, options?: { createBranch?: string } | { path?: string }): Promise { + async applyUnreachableCommitForPatch( + repoPath: string | Uri, + ref: string, + options?: { + branchName?: string; + createBranchIfNeeded?: boolean; + createWorktreePath?: string; + stash?: boolean | 'prompt'; + }, + ): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.applyUnreachableCommitForPatch?.(path, ref, options); + } + + @log() + checkout( + repoPath: string | Uri, + ref: string, + options?: { createBranch?: string } | { path?: string }, + ): Promise { const { provider, path } = this.getProvider(repoPath); return provider.checkout(path, ref, options); } + @log() + async clone(url: string, parentPath: string): Promise { + const { provider } = this.getProvider(parentPath); + return provider.clone?.(url, parentPath); + } + + @log({ args: { 1: '', 3: '' } }) + async createUnreachableCommitForPatch( + repoPath: string | Uri, + contents: string, + baseRef: string, + message: string, + ): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.createUnreachableCommitForPatch?.(path, contents, baseRef, message); + } + @log({ singleLine: true }) resetCaches(...caches: GitCaches[]): void { if (caches.length === 0 || caches.includes('providers')) { @@ -1044,7 +1362,7 @@ export class GitProviderService implements Disposable { } @log({ args: { 1: uris => uris.length } }) - excludeIgnoredUris(repoPath: string, uris: Uri[]): Promise { + excludeIgnoredUris(repoPath: string | Uri, uris: Uri[]): Promise { const { provider, path } = this.getProvider(repoPath); return provider.excludeIgnoredUris(path, uris); } @@ -1052,7 +1370,7 @@ export class GitProviderService implements Disposable { @gate() @log() fetch( - repoPath: string, + repoPath: string | Uri, options?: { all?: boolean; branch?: GitBranchReference; prune?: boolean; pull?: boolean; remote?: string }, ): Promise { const { provider, path } = this.getProvider(repoPath); @@ -1080,10 +1398,17 @@ export class GitProviderService implements Disposable { location: ProgressLocation.Notification, title: `Fetching ${repositories.length} repositories`, }, - () => Promise.all(repositories!.map(r => r.fetch({ progress: false, ...options }))), + () => Promise.all(repositories.map(r => r.fetch({ progress: false, ...options }))), ); } + @gate() + @log() + pull(repoPath: string | Uri, options?: { rebase?: boolean; tags?: boolean }): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.pull(path, options); + } + @gate( (repos, opts) => `${repos == null ? '' : repos.map(r => r.id).join(',')}|${JSON.stringify(opts)}`, ) @@ -1105,11 +1430,21 @@ export class GitProviderService implements Disposable { location: ProgressLocation.Notification, title: `Pulling ${repositories.length} repositories`, }, - () => Promise.all(repositories!.map(r => r.pull({ progress: false, ...options }))), + () => Promise.all(repositories.map(r => r.pull({ progress: false, ...options }))), ); } - @gate(repos => `${repos == null ? '' : repos.map(r => r.id).join(',')}`) + @gate() + @log() + push( + repoPath: string | Uri, + options?: { reference?: GitReference; force?: boolean; publish?: { remote: string } }, + ): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.push(path, options); + } + + @gate(repos => (repos == null ? '' : repos.map(r => r.id).join(','))) @log({ args: { 0: repos => repos?.map(r => r.name).join(', ') } }) async pushAll( repositories?: Repository[], @@ -1137,17 +1472,18 @@ export class GitProviderService implements Disposable { location: ProgressLocation.Notification, title: `Pushing ${repositories.length} repositories`, }, - () => Promise.all(repositories!.map(r => r.push({ progress: false, ...options }))), + () => Promise.all(repositories.map(r => r.push({ progress: false, ...options }))), ); } - @log({ args: { 1: refs => refs.join(',') } }) - getAheadBehindCommitCount( + @log() + getLeftRightCommitCount( repoPath: string | Uri, - refs: string[], - ): Promise<{ ahead: number; behind: number } | undefined> { + range: GitRevisionRange, + options?: { authors?: GitUser[] | undefined; excludeMerges?: boolean }, + ): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.getAheadBehindCommitCount(path, refs); + return provider.getLeftRightCommitCount(path, range, options); } @log({ args: { 1: d => d?.isDirty } }) @@ -1237,7 +1573,7 @@ export class GitProviderService implements Disposable { @log({ args: { 0: b => b.name } }) async getBranchAheadRange(branch: GitBranch): Promise { if (branch.state.ahead > 0) { - return GitRevision.createRange(branch.upstream?.name, branch.ref); + return createRevisionRange(branch.upstream?.name, branch.ref, '..'); } if (branch.upstream == null) { @@ -1258,7 +1594,7 @@ export class GitProviderService implements Disposable { const possibleBranch = weightedBranch!.branch.upstream?.name ?? weightedBranch!.branch.ref; if (possibleBranch !== branch.ref) { - return GitRevision.createRange(possibleBranch, branch.ref); + return createRevisionRange(possibleBranch, branch.ref, '..'); } } } @@ -1271,6 +1607,7 @@ export class GitProviderService implements Disposable { repoPath: string | Uri | undefined, options?: { filter?: (b: GitBranch) => boolean; + paging?: PagingOptions; sort?: boolean | BranchSortOptions; }, ): Promise> { @@ -1283,60 +1620,69 @@ export class GitProviderService implements Disposable { @log() async getBranchesAndTagsTipsFn( repoPath: string | Uri | undefined, - currentName?: string, + suppressName?: string, ): Promise< (sha: string, options?: { compact?: boolean | undefined; icons?: boolean | undefined }) => string | undefined > { - const [branchesResult, tagsResult] = await Promise.allSettled([ + if (repoPath == null) return () => undefined; + + const [branchesResult, tagsResult, remotesResult] = await Promise.allSettled([ this.getBranches(repoPath), this.getTags(repoPath), + this.getRemotes(repoPath), ]); const branches = getSettledValue(branchesResult)?.values ?? []; const tags = getSettledValue(tagsResult)?.values ?? []; + const remotes = getSettledValue(remotesResult) ?? []; const branchesAndTagsBySha = groupByFilterMap( (branches as (GitBranch | GitTag)[]).concat(tags as (GitBranch | GitTag)[]), bt => bt.sha, bt => { - if (currentName) { - if (bt.name === currentName) return undefined; - if (bt.refType === 'branch' && bt.getNameWithoutRemote() === currentName) { - return { name: bt.name, compactName: bt.getRemoteName(), type: bt.refType }; + let icon; + if (bt.refType === 'branch') { + if (bt.remote) { + const remote = remotes.find(r => r.name === bt.getRemoteName()); + icon = `$(${getRemoteThemeIconString(remote)}) `; + } else { + icon = '$(git-branch) '; } + } else { + icon = '$(tag) '; } - return { name: bt.name, compactName: undefined, type: bt.refType }; + return { + name: bt.name, + icon: icon, + compactName: + suppressName && bt.refType === 'branch' && bt.getNameWithoutRemote() === suppressName + ? bt.getRemoteName() + : undefined, + type: bt.refType, + }; }, ); return (sha: string, options?: { compact?: boolean; icons?: boolean }): string | undefined => { const branchesAndTags = branchesAndTagsBySha.get(sha); - if (branchesAndTags == null || branchesAndTags.length === 0) return undefined; + if (!branchesAndTags?.length) return undefined; + + const tips = + suppressName && options?.compact + ? branchesAndTags.filter(bt => bt.name !== suppressName) + : branchesAndTags; if (!options?.compact) { - return branchesAndTags - .map( - bt => `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${bt.name}`, - ) - .join(', '); + return tips.map(bt => `${options?.icons ? bt.icon : ''}${bt.name}`).join(', '); } - if (branchesAndTags.length > 1) { - const [bt] = branchesAndTags; - return `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${ - bt.compactName ?? bt.name - }, ${GlyphChars.Ellipsis}`; + if (tips.length > 1) { + const [bt] = tips; + return `${options?.icons ? bt.icon : ''}${bt.compactName ?? bt.name}, ${GlyphChars.Ellipsis}`; } - return branchesAndTags - .map( - bt => - `${options?.icons ? `${bt.type === 'tag' ? '$(tag)' : '$(git-branch)'} ` : ''}${ - bt.compactName ?? bt.name - }`, - ) - .join(', '); + return tips.map(bt => `${options?.icons ? bt.icon : ''}${bt.compactName ?? bt.name}`).join(', '); }; } @@ -1350,7 +1696,7 @@ export class GitProviderService implements Disposable { async getCommit(repoPath: string | Uri, ref: string): Promise { const { provider, path } = this.getProvider(repoPath); - if (ref === GitRevision.uncommitted || ref === GitRevision.uncommittedStaged) { + if (ref === uncommitted || ref === uncommittedStaged) { const now = new Date(); const user = await this.getCurrentUser(repoPath); return new GitCommit( @@ -1374,11 +1720,14 @@ export class GitProviderService implements Disposable { @log() getCommitBranches( repoPath: string | Uri, - ref: string, - options?: { branch?: string; commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, + refs: string | string[], + branch?: string | undefined, + options?: + | { all?: boolean; commitDate?: Date; mode?: 'contains' | 'pointsAt' } + | { commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, ): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.getCommitBranches(path, ref, options); + return provider.getCommitBranches(path, typeof refs === 'string' ? [refs] : refs, branch, options); } @log() @@ -1404,7 +1753,6 @@ export class GitProviderService implements Disposable { repoPath: string | Uri, asWebviewUri: (uri: Uri) => Uri, options?: { - branch?: string; include?: { stats?: boolean }; limit?: number; ref?: string; @@ -1415,13 +1763,23 @@ export class GitProviderService implements Disposable { } @log() - async getConfig(repoPath: string, key: string): Promise { + getCommitTags( + repoPath: string | Uri, + ref: string, + options?: { commitDate?: Date; mode?: 'contains' | 'pointsAt' }, + ): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.getCommitTags(path, ref, options); + } + + @log() + async getConfig(repoPath: string | Uri, key: GitConfigKeys): Promise { const { provider, path } = this.getProvider(repoPath); return provider.getConfig?.(path, key); } @log() - async setConfig(repoPath: string, key: string, value: string | undefined): Promise { + async setConfig(repoPath: string | Uri, key: GitConfigKeys, value: string | undefined): Promise { const { provider, path } = this.getProvider(repoPath); return provider.setConfig?.(path, key, value); } @@ -1429,7 +1787,7 @@ export class GitProviderService implements Disposable { @log() async getContributors( repoPath: string | Uri, - options?: { all?: boolean; ref?: string; stats?: boolean }, + options?: { all?: boolean; merges?: boolean | 'first-parent'; ref?: string; stats?: boolean }, ): Promise { if (repoPath == null) return []; @@ -1439,11 +1797,17 @@ export class GitProviderService implements Disposable { @gate() @log() - async getCurrentUser(repoPath: string | Uri): Promise { + getCurrentUser(repoPath: string | Uri): Promise { const { provider, path } = this.getProvider(repoPath); return provider.getCurrentUser(path); } + @log() + async getBaseBranchName(repoPath: string | Uri, ref: string): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.getBaseBranchName?.(path, ref); + } + @log() async getDefaultBranchName(repoPath: string | Uri | undefined, remote?: string): Promise { if (repoPath == null) return undefined; @@ -1452,6 +1816,23 @@ export class GitProviderService implements Disposable { return provider.getDefaultBranchName(path, remote); } + @log() + async getDiff( + repoPath: string | Uri, + to: string, + from?: string, + options?: { context?: number; uris?: Uri[] }, + ): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.getDiff?.(path, to, from, options); + } + + @log({ args: { 1: false } }) + async getDiffFiles(repoPath: string | Uri, contents: string): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.getDiffFiles?.(path, contents); + } + @log() /** * Returns a file diff between two commits @@ -1459,7 +1840,7 @@ export class GitProviderService implements Disposable { * @param ref1 Commit to diff from * @param ref2 Commit to diff to */ - async getDiffForFile(uri: GitUri, ref1: string | undefined, ref2?: string): Promise { + getDiffForFile(uri: GitUri, ref1: string | undefined, ref2?: string): Promise { const { provider } = this.getProvider(uri); return provider.getDiffForFile(uri, ref1, ref2); } @@ -1471,7 +1852,7 @@ export class GitProviderService implements Disposable { * @param ref Commit to diff from * @param contents Contents to use for the diff */ - async getDiffForFileContents(uri: GitUri, ref: string, contents: string): Promise { + getDiffForFileContents(uri: GitUri, ref: string, contents: string): Promise { const { provider } = this.getProvider(uri); return provider.getDiffForFileContents(uri, ref, contents); } @@ -1484,37 +1865,37 @@ export class GitProviderService implements Disposable { * @param ref1 Commit to diff from * @param ref2 Commit to diff to */ - async getDiffForLine( + getDiffForLine( uri: GitUri, editorLine: number, ref1: string | undefined, ref2?: string, - ): Promise { + ): Promise { const { provider } = this.getProvider(uri); return provider.getDiffForLine(uri, editorLine, ref1, ref2); } @log() - async getDiffStatus( + getDiffStatus( repoPath: string | Uri, - ref1?: string, + ref1OrRange: string | GitRevisionRange, ref2?: string, - options?: { filters?: GitDiffFilter[]; similarityThreshold?: number }, + options?: { filters?: GitDiffFilter[]; path?: string; similarityThreshold?: number }, ): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.getDiffStatus(path, ref1, ref2, options); + return provider.getDiffStatus(path, ref1OrRange, ref2, options); } @log() async getFileStatusForCommit(repoPath: string | Uri, uri: Uri, ref: string): Promise { - if (ref === GitRevision.deletedOrMissing || GitRevision.isUncommitted(ref)) return undefined; + if (ref === deletedOrMissing || isUncommitted(ref)) return undefined; const { provider, path } = this.getProvider(repoPath); return provider.getFileStatusForCommit(path, uri, ref); } @debug() - getGitDir(repoPath: string): Promise { + getGitDir(repoPath: string | Uri): Promise { const { provider, path } = this.getProvider(repoPath); return Promise.resolve(provider.getGitDir?.(path)); } @@ -1532,7 +1913,7 @@ export class GitProviderService implements Disposable { all?: boolean; authors?: GitUser[]; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; since?: string; @@ -1548,7 +1929,7 @@ export class GitProviderService implements Disposable { options?: { authors?: GitUser[]; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; since?: string; @@ -1631,12 +2012,11 @@ export class GitProviderService implements Disposable { uri: Uri, ref: string | undefined, skip: number = 0, - firstParent: boolean = false, ): Promise { - if (ref === GitRevision.deletedOrMissing) return Promise.resolve(undefined); + if (ref === deletedOrMissing) return Promise.resolve(undefined); const { provider, path } = this.getProvider(repoPath); - return provider.getPreviousComparisonUris(path, uri, ref, skip, firstParent); + return provider.getPreviousComparisonUris(path, uri, ref, skip); } @log() @@ -1647,187 +2027,12 @@ export class GitProviderService implements Disposable { ref: string | undefined, skip: number = 0, ): Promise { - if (ref === GitRevision.deletedOrMissing) return Promise.resolve(undefined); + if (ref === deletedOrMissing) return Promise.resolve(undefined); const { provider, path } = this.getProvider(repoPath); return provider.getPreviousComparisonUrisForLine(path, uri, editorLine, ref, skip); } - async getPullRequestForBranch( - branch: string, - remote: GitRemote, - options?: { avatarSize?: number; include?: PullRequestState[]; limit?: number; timeout?: number }, - ): Promise; - async getPullRequestForBranch( - branch: string, - provider: RichRemoteProvider, - options?: { avatarSize?: number; include?: PullRequestState[]; limit?: number; timeout?: number }, - ): Promise; - @gate((branch, remoteOrProvider, options) => { - const provider = GitRemote.is(remoteOrProvider) ? remoteOrProvider.provider : remoteOrProvider; - return `${branch}${ - provider != null ? `|${provider.id}:${provider.domain}/${provider.path}` : '' - }|${JSON.stringify(options)}`; - }) - @debug({ args: { 1: remoteOrProvider => remoteOrProvider.name } }) - async getPullRequestForBranch( - branch: string, - remoteOrProvider: GitRemote | RichRemoteProvider, - options?: { avatarSize?: number; include?: PullRequestState[]; limit?: number; timeout?: number }, - ): Promise { - let provider; - if (GitRemote.is(remoteOrProvider)) { - ({ provider } = remoteOrProvider); - if (!provider?.hasRichIntegration()) return undefined; - } else { - provider = remoteOrProvider; - } - - let timeout; - if (options != null) { - ({ timeout, ...options } = options); - } - - let promiseOrPR = provider.getPullRequestForBranch(branch, options); - if (promiseOrPR == null || !isPromise(promiseOrPR)) { - return promiseOrPR; - } - - if (timeout != null && timeout > 0) { - promiseOrPR = cancellable(promiseOrPR, timeout); - } - - try { - return await promiseOrPR; - } catch (ex) { - if (ex instanceof PromiseCancelledError) throw ex; - - return undefined; - } - } - - async getPullRequestForCommit( - ref: string, - remote: GitRemote, - options?: { timeout?: number }, - ): Promise; - async getPullRequestForCommit( - ref: string, - provider: RichRemoteProvider, - options?: { timeout?: number }, - ): Promise; - @gate((ref, remoteOrProvider, options) => { - const provider = GitRemote.is(remoteOrProvider) ? remoteOrProvider.provider : remoteOrProvider; - return `${ref}${provider != null ? `|${provider.id}:${provider.domain}/${provider.path}` : ''}|${ - options?.timeout - }`; - }) - @debug({ args: { 1: remoteOrProvider => remoteOrProvider.name } }) - async getPullRequestForCommit( - ref: string, - remoteOrProvider: GitRemote | RichRemoteProvider, - options?: { timeout?: number }, - ): Promise { - if (GitRevision.isUncommitted(ref)) return undefined; - - let provider; - if (GitRemote.is(remoteOrProvider)) { - ({ provider } = remoteOrProvider); - if (!provider?.hasRichIntegration()) return undefined; - } else { - provider = remoteOrProvider; - } - - let promiseOrPR = provider.getPullRequestForCommit(ref); - if (promiseOrPR == null || !isPromise(promiseOrPR)) { - return promiseOrPR; - } - - if (options?.timeout != null && options.timeout > 0) { - promiseOrPR = cancellable(promiseOrPR, options.timeout); - } - - try { - return await promiseOrPR; - } catch (ex) { - if (ex instanceof PromiseCancelledError) throw ex; - - return undefined; - } - } - - @debug({ args: { 0: remoteOrProvider => remoteOrProvider.name } }) - async getMyPullRequests( - remoteOrProvider: GitRemote | RichRemoteProvider, - options?: { timeout?: number }, - ): Promise { - let provider; - if (GitRemote.is(remoteOrProvider)) { - ({ provider } = remoteOrProvider); - if (!provider?.hasRichIntegration()) return undefined; - } else { - provider = remoteOrProvider; - } - - let timeout; - if (options != null) { - ({ timeout, ...options } = options); - } - - let promiseOrPRs = provider.searchMyPullRequests(); - if (promiseOrPRs == null || !isPromise(promiseOrPRs)) { - return promiseOrPRs; - } - - if (timeout != null && timeout > 0) { - promiseOrPRs = cancellable(promiseOrPRs, timeout); - } - - try { - return await promiseOrPRs; - } catch (ex) { - if (ex instanceof PromiseCancelledError) throw ex; - - return undefined; - } - } - - @debug({ args: { 0: remoteOrProvider => remoteOrProvider.name } }) - async getMyIssues( - remoteOrProvider: GitRemote | RichRemoteProvider, - options?: { timeout?: number }, - ): Promise { - let provider; - if (GitRemote.is(remoteOrProvider)) { - ({ provider } = remoteOrProvider); - if (!provider?.hasRichIntegration()) return undefined; - } else { - provider = remoteOrProvider; - } - - let timeout; - if (options != null) { - ({ timeout, ...options } = options); - } - - let promiseOrPRs = provider.searchMyIssues(); - if (promiseOrPRs == null || !isPromise(promiseOrPRs)) { - return promiseOrPRs; - } - - if (timeout != null && timeout > 0) { - promiseOrPRs = cancellable(promiseOrPRs, timeout); - } - - try { - return await promiseOrPRs; - } catch (ex) { - if (ex instanceof PromiseCancelledError) throw ex; - - return undefined; - } - } - @log() async getIncomingActivity( repoPath: string | Uri, @@ -1843,185 +2048,135 @@ export class GitProviderService implements Disposable { return provider.getIncomingActivity(path, options); } + @log() async getBestRemoteWithProvider( - repoPath: string | Uri | undefined, - ): Promise | undefined>; - async getBestRemoteWithProvider( - remotes: GitRemote[], - ): Promise | undefined>; - @gate( - remotesOrRepoPath => - `${ - remotesOrRepoPath == null || typeof remotesOrRepoPath === 'string' - ? remotesOrRepoPath - : remotesOrRepoPath instanceof Uri - ? remotesOrRepoPath.toString() - : `${remotesOrRepoPath.length}:${remotesOrRepoPath[0]?.repoPath ?? ''}` - }`, - ) - @log({ - args: { - 0: remotesOrRepoPath => - Array.isArray(remotesOrRepoPath) ? remotesOrRepoPath.map(r => r.name).join(',') : remotesOrRepoPath, - }, - }) - async getBestRemoteWithProvider( - remotesOrRepoPath: GitRemote[] | string | Uri | undefined, - ): Promise | undefined> { - if (remotesOrRepoPath == null) return undefined; - - let remotes; - let repoPath; - if (Array.isArray(remotesOrRepoPath)) { - if (remotesOrRepoPath.length === 0) return undefined; - - remotes = remotesOrRepoPath; - repoPath = remotesOrRepoPath[0].repoPath; - } else { - repoPath = remotesOrRepoPath; - } + repoPath: string | Uri, + cancellation?: CancellationToken, + ): Promise | undefined> { + const remotes = await this.getBestRemotesWithProviders(repoPath, cancellation); + return remotes[0]; + } + @log() + async getBestRemotesWithProviders( + repoPath: string | Uri, + cancellation?: CancellationToken, + ): Promise[]> { + if (repoPath == null) return []; if (typeof repoPath === 'string') { repoPath = this.getAbsoluteUri(repoPath); } const cacheKey = asRepoComparisonKey(repoPath); - let remote = this._bestRemotesCache.get(cacheKey); - if (remote !== undefined) return remote ?? undefined; - - remotes = (remotes ?? (await this.getRemotesWithProviders(repoPath))).filter( - ( - r: GitRemote, - ): r is GitRemote => r.provider != null, - ); - - if (remotes.length === 0) return undefined; - - if (remotes.length === 1) { - remote = remotes[0]; - } else { - const weightedRemotes = new Map([ - ['upstream', 15], - ['origin', 10], - ]); - - const branch = await this.getBranch(remotes[0].repoPath); - const branchRemote = branch?.getRemoteName(); + let remotes = this._bestRemotesCache.get(cacheKey); + if (remotes == null) { + async function getBest(this: GitProviderService) { + const remotes = await this.getRemotesWithProviders(repoPath, { sort: true }, cancellation); + if (remotes.length === 0) return []; + if (remotes.length === 1) return [...remotes]; + + if (cancellation?.isCancellationRequested) throw new CancellationError(); + + const defaultRemote = remotes.find(r => r.default)?.name; + const currentBranchRemote = (await this.getBranch(remotes[0].repoPath))?.getRemoteName(); + + const weighted: [number, GitRemote][] = []; + + let originalFound = false; + + for (const remote of remotes) { + let weight; + switch (remote.name) { + case defaultRemote: + weight = 1000; + break; + case currentBranchRemote: + weight = 6; + break; + case 'upstream': + weight = 5; + break; + case 'origin': + weight = 4; + break; + default: + weight = 0; + } - if (branchRemote != null) { - weightedRemotes.set(branchRemote, 100); - } + // Only check remotes that have extra weighting and less than the default + if (weight > 0 && weight < 1000 && !originalFound) { + const integration = await remote.getIntegration(); + if ( + integration != null && + (integration.maybeConnected || + (integration.maybeConnected === undefined && (await integration.isConnected()))) + ) { + if (cancellation?.isCancellationRequested) throw new CancellationError(); + + const repo = await integration.getRepositoryMetadata(remote.provider.repoDesc, { + cancellation: cancellation, + }); + + if (cancellation?.isCancellationRequested) throw new CancellationError(); + + if (repo != null) { + weight += repo.isFork ? -3 : 3; + // Once we've found the "original" (not a fork) don't bother looking for more + originalFound = !repo.isFork; + } + } + } - let bestRemote; - let weight = 0; - for (const r of remotes) { - if (r.default) { - bestRemote = r; - break; + weighted.push([weight, remote]); } - // Don't choose a remote unless its weighted above - const matchedWeight = weightedRemotes.get(r.name) ?? -1; - if (matchedWeight > weight) { - bestRemote = r; - weight = matchedWeight; - } + // Sort by the weight, but if both are 0 (no weight) then sort by name + weighted.sort(([aw, ar], [bw, br]) => (bw === 0 && aw === 0 ? sortCompare(ar.name, br.name) : bw - aw)); + return weighted.map(wr => wr[1]); } - remote = bestRemote ?? null; + remotes = getBest.call(this); + this._bestRemotesCache.set(cacheKey, remotes); } - this._bestRemotesCache.set(cacheKey, remote); - - return remote ?? undefined; + return [...(await remotes)]; } - async getBestRemoteWithRichProvider( - repoPath: string | Uri | undefined, - options?: { includeDisconnected?: boolean }, - ): Promise | undefined>; - async getBestRemoteWithRichProvider( - remotes: GitRemote[], - options?: { includeDisconnected?: boolean }, - ): Promise | undefined>; - @gate( - (remotesOrRepoPath, options) => - `${ - remotesOrRepoPath == null || typeof remotesOrRepoPath === 'string' - ? remotesOrRepoPath - : remotesOrRepoPath instanceof Uri - ? remotesOrRepoPath.toString() - : `${remotesOrRepoPath.length}:${remotesOrRepoPath[0]?.repoPath ?? ''}` - }|${options?.includeDisconnected ?? false}`, - ) - @log({ - args: { - 0: remotesOrRepoPath => - Array.isArray(remotesOrRepoPath) ? remotesOrRepoPath.map(r => r.name).join(',') : remotesOrRepoPath, + @log() + async getBestRemoteWithIntegration( + repoPath: string | Uri, + options?: { + filter?: (remote: GitRemote, integration: HostingIntegration) => boolean; + includeDisconnected?: boolean; }, - }) - async getBestRemoteWithRichProvider( - remotesOrRepoPath: GitRemote[] | string | Uri | undefined, - options?: { includeDisconnected?: boolean }, - ): Promise | undefined> { - if (remotesOrRepoPath == null) return undefined; - - let remotes; - let repoPath; - if (Array.isArray(remotesOrRepoPath)) { - if (remotesOrRepoPath.length === 0) return undefined; - - remotes = remotesOrRepoPath; - repoPath = remotesOrRepoPath[0].repoPath; - } else { - repoPath = remotesOrRepoPath; - } - - if (typeof repoPath === 'string') { - repoPath = this.getAbsoluteUri(repoPath); - } - - const cacheKey = asRepoComparisonKey(repoPath); - - let richRemote = this._bestRemotesCache.get(`rich+connected|${cacheKey}`); - if (richRemote != null) return richRemote; - if (richRemote === null && !options?.includeDisconnected) return undefined; - - if (options?.includeDisconnected) { - richRemote = this._bestRemotesCache.get(`rich|${cacheKey}`); - if (richRemote !== undefined) return richRemote ?? undefined; - } - - const remote = await (remotes != null - ? this.getBestRemoteWithProvider(remotes) - : this.getBestRemoteWithProvider(repoPath)); - - if (!remote?.hasRichProvider()) { - this._bestRemotesCache.set(`rich|${cacheKey}`, null); - this._bestRemotesCache.set(`rich+connected|${cacheKey}`, null); - return undefined; - } - - const { provider } = remote; - const connected = provider.maybeConnected ?? (await provider.isConnected()); - if (connected) { - this._bestRemotesCache.set(`rich|${cacheKey}`, remote); - this._bestRemotesCache.set(`rich+connected|${cacheKey}`, remote); - } else { - this._bestRemotesCache.set(`rich|${cacheKey}`, remote); - this._bestRemotesCache.set(`rich+connected|${cacheKey}`, null); - - if (!options?.includeDisconnected) return undefined; + cancellation?: CancellationToken, + ): Promise | undefined> { + const remotes = await this.getBestRemotesWithProviders(repoPath, cancellation); + + const includeDisconnected = options?.includeDisconnected ?? false; + for (const r of remotes) { + if (r.hasIntegration()) { + const integration = await this.container.integrations.getByRemote(r); + if (integration != null) { + if (options?.filter?.(r, integration) === false) continue; + + if (includeDisconnected || integration.maybeConnected === true) return r; + if (integration.maybeConnected === undefined && (r.default || remotes.length === 1)) { + if (await integration.isConnected()) return r; + } + } + } } - return remote; + return undefined; } - @log({ args: { 1: false } }) + @log() async getRemotes( - repoPath: string | Uri | undefined, - options?: { providers?: RemoteProviders; sort?: boolean }, - ): Promise[]> { + repoPath: string | Uri, + options?: { sort?: boolean }, + _cancellation?: CancellationToken, + ): Promise { if (repoPath == null) return []; const { provider, path } = this.getProvider(repoPath); @@ -2030,28 +2185,30 @@ export class GitProviderService implements Disposable { @log() async getRemotesWithProviders( - repoPath: string | Uri | undefined, + repoPath: string | Uri, options?: { sort?: boolean }, - ): Promise[]> { - if (repoPath == null) return []; - - const repository = this.container.git.getRepository(repoPath); - const remotes = await (repository != null - ? repository.getRemotes(options) - : this.getRemotes(repoPath, options)); + cancellation?: CancellationToken, + ): Promise[]> { + const remotes = await this.getRemotes(repoPath, options, cancellation); + return remotes.filter((r: GitRemote): r is GitRemote => r.provider != null); + } - return remotes.filter( - ( - r: GitRemote, - ): r is GitRemote => r.provider != null, - ); + @log() + async getRemotesWithIntegrations( + repoPath: string | Uri, + options?: { sort?: boolean }, + cancellation?: CancellationToken, + ): Promise[]> { + const remotes = await this.getRemotes(repoPath, options, cancellation); + return remotes.filter((r: GitRemote): r is GitRemote => r.hasIntegration()); } getBestRepository(): Repository | undefined; - getBestRepository(uri?: Uri): Repository | undefined; + // eslint-disable-next-line @typescript-eslint/unified-signatures + getBestRepository(uri?: Uri, editor?: TextEditor): Repository | undefined; + // eslint-disable-next-line @typescript-eslint/unified-signatures getBestRepository(editor?: TextEditor): Repository | undefined; - getBestRepository(uri?: TextEditor | Uri, editor?: TextEditor): Repository | undefined; - @log({ exit: r => `returned ${r?.path}` }) + @log({ exit: true }) getBestRepository(editorOrUri?: TextEditor | Uri, editor?: TextEditor): Repository | undefined { const count = this.repositoryCount; if (count === 0) return undefined; @@ -2069,10 +2226,11 @@ export class GitProviderService implements Disposable { } getBestRepositoryOrFirst(): Repository | undefined; - getBestRepositoryOrFirst(uri?: Uri): Repository | undefined; + // eslint-disable-next-line @typescript-eslint/unified-signatures + getBestRepositoryOrFirst(uri?: Uri, editor?: TextEditor): Repository | undefined; + // eslint-disable-next-line @typescript-eslint/unified-signatures getBestRepositoryOrFirst(editor?: TextEditor): Repository | undefined; - getBestRepositoryOrFirst(uri?: TextEditor | Uri, editor?: TextEditor): Repository | undefined; - @log({ exit: r => `returned ${r?.path}` }) + @log({ exit: true }) getBestRepositoryOrFirst(editorOrUri?: TextEditor | Uri, editor?: TextEditor): Repository | undefined { const count = this.repositoryCount; if (count === 0) return undefined; @@ -2091,30 +2249,68 @@ export class GitProviderService implements Disposable { ); } - @log({ exit: r => `returned ${r?.path}` }) - async getOrOpenRepository( + getOrOpenRepository( uri: Uri, - options?: { closeOnOpen?: boolean; detectNested?: boolean }, + options?: { closeOnOpen?: boolean; detectNested?: boolean; force?: boolean }, + ): Promise; + getOrOpenRepository( + path: string, + options?: { closeOnOpen?: boolean; detectNested?: boolean; force?: boolean }, + ): Promise; + getOrOpenRepository( + pathOrUri: string | Uri, + options?: { closeOnOpen?: boolean; detectNested?: boolean; force?: boolean }, + ): Promise; + @log({ exit: true }) + async getOrOpenRepository( + pathOrUri?: string | Uri, + options?: { closeOnOpen?: boolean; detectNested?: boolean; force?: boolean }, ): Promise { + if (pathOrUri == null) return undefined; + const scope = getLogScope(); + let uri: Uri; + if (typeof pathOrUri === 'string') { + if (!pathOrUri) return undefined; + + uri = this.getAbsoluteUri(pathOrUri); + } else { + uri = pathOrUri; + } + const path = getBestPath(uri); let repository: Repository | undefined; repository = this.getRepository(uri); + if (repository == null && this._discoveringRepositories?.pending) { + await this._discoveringRepositories.promise; + repository = this.getRepository(uri); + } + let isDirectory: boolean | undefined; const detectNested = options?.detectNested ?? configuration.get('detectNestedRepositories', uri); if (!detectNested) { if (repository != null) return repository; - } else if (this._visitedPaths.has(path)) { + } else if (!options?.force && this._visitedPaths.has(path)) { return repository; } else { const stats = await workspace.fs.stat(uri); + + const bestPath = getBestPath(uri); + + Logger.debug( + scope, + `Ensuring URI is a folder; repository=${repository?.toString()}, uri=${uri.toString(true)} stats.type=${ + stats.type + }, bestPath=${bestPath}, visitedPaths.has=${this._visitedPaths.has(bestPath)}`, + ); + // If the uri isn't a directory, go up one level if ((stats.type & FileType.Directory) !== FileType.Directory) { uri = Uri.joinPath(uri, '..'); - if (this._visitedPaths.has(getBestPath(uri))) return repository; + if (!options?.force && this._visitedPaths.has(bestPath)) return repository; } isDirectory = true; @@ -2140,26 +2336,37 @@ export class GitProviderService implements Disposable { root = this._repositories.getClosest(provider.getAbsoluteUri(uri, repoUri)); } - const autoRepositoryDetection = - configuration.getAny( - CoreGitConfiguration.AutoRepositoryDetection, - ) ?? true; + const autoRepositoryDetection = configuration.getCore('git.autoRepositoryDetection') ?? true; - const closed = + let closed = options?.closeOnOpen ?? (autoRepositoryDetection !== true && autoRepositoryDetection !== 'openEditors'); + // If we are trying to open a file inside the .git folder, then treat the repository as closed, unless explicitly requested it to be open + // This avoids showing the root repo in worktrees during certain operations (e.g. rebase) and vice-versa + if (!closed && options?.closeOnOpen !== false && !isDirectory && uri.path.includes('/.git/')) { + closed = true; + } Logger.log(scope, `Repository found in '${repoUri.toString(true)}'`); const repositories = provider.openRepository(root?.folder, repoUri, false, undefined, closed); + + const added: Repository[] = []; + for (const repository of repositories) { this._repositories.add(repository); + if (!repository.closed) { + added.push(repository); + } } this._pendingRepositories.delete(key); this.updateContext(); - // Send a notification that the repositories changed - queueMicrotask(() => this.fireRepositoriesChanged(repositories)); + + if (added.length) { + // Send a notification that the repositories changed + queueMicrotask(() => this.fireRepositoriesChanged(added)); + } repository = repositories.length === 1 ? repositories[0] : this.getRepository(uri); return repository; @@ -2172,9 +2379,7 @@ export class GitProviderService implements Disposable { return promise; } - @log({ - args: { 0: e => (e != null ? `TextEditor(${Logger.toLoggable(e.document.uri)})` : undefined) }, - }) + @log() async getOrOpenRepositoryForEditor(editor?: TextEditor): Promise { editor = editor ?? window.activeTextEditor; @@ -2186,7 +2391,7 @@ export class GitProviderService implements Disposable { getRepository(uri: Uri): Repository | undefined; getRepository(path: string): Repository | undefined; getRepository(pathOrUri: string | Uri): Repository | undefined; - @log({ exit: r => `returned ${r?.path}` }) + @log({ exit: true }) getRepository(pathOrUri?: string | Uri): Repository | undefined { if (this.repositoryCount === 0) return undefined; if (pathOrUri == null) return undefined; @@ -2246,7 +2451,11 @@ export class GitProviderService implements Disposable { @log({ args: { 1: false } }) async getTags( repoPath: string | Uri | undefined, - options?: { cursor?: string; filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, + options?: { + filter?: (t: GitTag) => boolean; + paging?: PagingOptions; + sort?: boolean | TagSortOptions; + }, ): Promise> { if (repoPath == null) return { values: [] }; @@ -2281,16 +2490,22 @@ export class GitProviderService implements Disposable { return provider.getRevisionContent(rp, path, ref); } - @log() - async getUniqueRepositoryId(repoPath: string | Uri): Promise { + @log({ exit: true }) + async getFirstCommitSha(repoPath: string | Uri): Promise { const { provider, path } = this.getProvider(repoPath); - const id = await provider.getUniqueRepositoryId(path); - if (id != null) return id; + try { + return await provider.getFirstCommitSha?.(path); + } catch { + return undefined; + } + } - return defaultRepositoryId; + @log({ exit: true }) + getUniqueRepositoryId(repoPath: string | Uri): Promise { + return this.getFirstCommitSha(repoPath); } - @log({ args: { 1: false } }) + @log({ args: { 1: false }, exit: true }) async hasBranchOrTag( repoPath: string | Uri | undefined, options?: { @@ -2303,7 +2518,7 @@ export class GitProviderService implements Disposable { return provider.hasBranchOrTag(path, options); } - @log({ args: { 1: false } }) + @log({ exit: true }) async hasCommitBeenPushed(repoPath: string | Uri, ref: string): Promise { if (repoPath == null) return false; @@ -2311,7 +2526,7 @@ export class GitProviderService implements Disposable { return provider.hasCommitBeenPushed(path, ref); } - @log() + @log({ exit: true }) async hasRemotes(repoPath: string | Uri | undefined): Promise { if (repoPath == null) return false; @@ -2321,7 +2536,7 @@ export class GitProviderService implements Disposable { return repository.hasRemotes(); } - @log() + @log({ exit: true }) async hasTrackingBranch(repoPath: string | undefined): Promise { if (repoPath == null) return false; @@ -2331,12 +2546,23 @@ export class GitProviderService implements Disposable { return repository.hasUpstreamBranch(); } - @log({ - args: { - 0: r => r.uri.toString(true), - 1: e => (e != null ? `TextEditor(${Logger.toLoggable(e.document.uri)})` : undefined), - }, - }) + @log({ exit: true }) + hasUnsafeRepositories(): boolean { + for (const provider of this._providers.values()) { + if (provider.hasUnsafeRepositories?.()) return true; + } + return false; + } + + @log({ exit: true }) + async isAncestorOf(repoPath: string | Uri, ref1: string, ref2: string): Promise { + if (repoPath == null) return false; + + const { provider, path } = this.getProvider(repoPath); + return provider.isAncestorOf(path, ref1, ref2); + } + + @log({ exit: true }) isRepositoryForEditor(repository: Repository, editor?: TextEditor): boolean { editor = editor ?? window.activeTextEditor; if (editor == null) return false; @@ -2383,13 +2609,13 @@ export class GitProviderService implements Disposable { } async resolveReference( - repoPath: string, + repoPath: string | Uri, ref: string, path?: string, options?: { force?: boolean; timeout?: number }, ): Promise; async resolveReference( - repoPath: string, + repoPath: string | Uri, ref: string, uri?: Uri, options?: { force?: boolean; timeout?: number }, @@ -2402,15 +2628,15 @@ export class GitProviderService implements Disposable { pathOrUri?: string | Uri, options?: { timeout?: number }, ) { - if (pathOrUri != null && GitRevision.isUncommittedParent(ref)) { + if (pathOrUri != null && isUncommittedParent(ref)) { ref = 'HEAD'; } if ( !ref || - ref === GitRevision.deletedOrMissing || - (pathOrUri == null && GitRevision.isSha(ref)) || - (pathOrUri != null && GitRevision.isUncommitted(ref)) + ref === deletedOrMissing || + (pathOrUri == null && isSha(ref)) || + (pathOrUri != null && isUncommitted(ref)) ) { return ref; } @@ -2450,16 +2676,37 @@ export class GitProviderService implements Disposable { return provider.searchCommits(path, search, options); } - @log() + @log({ args: false }) + async runGitCommandViaTerminal( + repoPath: string | Uri, + command: string, + args: string[], + options?: { execute?: boolean }, + ): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.runGitCommandViaTerminal?.(path, command, args, options); + } + + @log({ exit: true }) validateBranchOrTagName(repoPath: string | Uri, ref: string): Promise { const { provider, path } = this.getProvider(repoPath); return provider.validateBranchOrTagName(path, ref); } - @log() - async validateReference(repoPath: string | Uri, ref: string) { + @log({ args: { 1: false }, exit: true }) + async validatePatch(repoPath: string | Uri, contents: string): Promise { + try { + const { provider, path } = this.getProvider(repoPath); + return (await provider.validatePatch?.(path || undefined, contents)) ?? false; + } catch { + return false; + } + } + + @log({ exit: true }) + async validateReference(repoPath: string | Uri, ref: string): Promise { if (ref == null || ref.length === 0) return false; - if (ref === GitRevision.deletedOrMissing || GitRevision.isUncommitted(ref)) return true; + if (ref === deletedOrMissing || isUncommitted(ref)) return true; const { provider, path } = this.getProvider(repoPath); return provider.validateReference(path, ref); @@ -2481,43 +2728,61 @@ export class GitProviderService implements Disposable { return provider.stageDirectory(path, directoryOrUri); } - unStageFile(repoPath: string | Uri, path: string): Promise; - unStageFile(repoPath: string | Uri, uri: Uri): Promise; + unstageFile(repoPath: string | Uri, path: string): Promise; + unstageFile(repoPath: string | Uri, uri: Uri): Promise; @log() - unStageFile(repoPath: string | Uri, pathOrUri: string | Uri): Promise { + unstageFile(repoPath: string | Uri, pathOrUri: string | Uri): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.unStageFile(path, pathOrUri); + return provider.unstageFile(path, pathOrUri); } - unStageDirectory(repoPath: string | Uri, directory: string): Promise; - unStageDirectory(repoPath: string | Uri, uri: Uri): Promise; + unstageDirectory(repoPath: string | Uri, directory: string): Promise; + unstageDirectory(repoPath: string | Uri, uri: Uri): Promise; @log() - unStageDirectory(repoPath: string | Uri, directoryOrUri: string | Uri): Promise { + unstageDirectory(repoPath: string | Uri, directoryOrUri: string | Uri): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.unStageDirectory(path, directoryOrUri); + return provider.unstageDirectory(path, directoryOrUri); } @log() - stashApply(repoPath: string | Uri, stashName: string, options?: { deleteAfter?: boolean }): Promise { + async stashApply(repoPath: string | Uri, stashName: string, options?: { deleteAfter?: boolean }): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.stashApply(path, stashName, options); + return provider.stashApply?.(path, stashName, options); } @log() - stashDelete(repoPath: string | Uri, stashName: string, ref?: string): Promise { + async stashDelete(repoPath: string | Uri, stashName: string, ref?: string): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.stashDelete?.(path, stashName, ref); + } + + @log() + async stashRename( + repoPath: string | Uri, + stashName: string, + ref: string, + message: string, + stashOnRef?: string, + ): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.stashDelete(path, stashName, ref); + return provider.stashRename?.(path, stashName, ref, message, stashOnRef); } @log({ args: { 2: uris => uris?.length } }) - stashSave( + async stashSave( repoPath: string | Uri, message?: string, uris?: Uri[], - options?: { includeUntracked?: boolean; keepIndex?: boolean }, + options?: { includeUntracked?: boolean; keepIndex?: boolean; onlyStaged?: boolean }, ): Promise { const { provider, path } = this.getProvider(repoPath); - return provider.stashSave(path, message, uris, options); + return provider.stashSave?.(path, message, uris, options); + } + + @log() + async stashSaveSnapshot(repoPath: string | Uri, message?: string): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.stashSaveSnapshot?.(path, message); } @log() @@ -2545,7 +2810,7 @@ export class GitProviderService implements Disposable { return (await provider.getWorktrees?.(path)) ?? []; } - @log() + @log({ exit: true }) async getWorktreesDefaultUri(path: string | Uri): Promise { const { provider, path: rp } = this.getProvider(path); let defaultUri = await provider.getWorktreesDefaultUri?.(rp); @@ -2564,6 +2829,7 @@ export class GitProviderService implements Disposable { const { provider, path: rp } = this.getProvider(repoPath); return Promise.resolve(provider.deleteWorktree?.(rp, path, options)); } + @log() async getOpenScmRepositories(): Promise { const results = await Promise.allSettled([...this._providers.values()].map(p => p.getOpenScmRepositories())); @@ -2578,13 +2844,13 @@ export class GitProviderService implements Disposable { } @log() - getScmRepository(repoPath: string): Promise { + getScmRepository(repoPath: string | Uri): Promise { const { provider, path } = this.getProvider(repoPath); return provider.getScmRepository(path); } @log() - getOrOpenScmRepository(repoPath: string): Promise { + getOrOpenScmRepository(repoPath: string | Uri): Promise { const { provider, path } = this.getProvider(repoPath); return provider.getOrOpenScmRepository(path); } diff --git a/src/git/gitUri.authority.ts b/src/git/gitUri.authority.ts new file mode 100644 index 0000000000000..172b122ad8ece --- /dev/null +++ b/src/git/gitUri.authority.ts @@ -0,0 +1,23 @@ +import { decodeUtf8Hex, encodeUtf8Hex } from '@env/hex'; + +export function decodeGitLensRevisionUriAuthority(authority: string): T { + return JSON.parse(decodeUtf8Hex(authority)) as T; +} + +export function encodeGitLensRevisionUriAuthority(metadata: T): string { + return encodeUtf8Hex(JSON.stringify(metadata)); +} + +export function decodeRemoteHubAuthority(authority: string): { scheme: string; metadata: T | undefined } { + const [scheme, encoded] = authority.split('+'); + + let metadata: T | undefined; + if (encoded) { + try { + const data = JSON.parse(decodeUtf8Hex(encoded)); + metadata = data as T; + } catch {} + } + + return { scheme: scheme, metadata: metadata }; +} diff --git a/src/git/gitUri.ts b/src/git/gitUri.ts index c42c93fc1757d..975eec34823c7 100644 --- a/src/git/gitUri.ts +++ b/src/git/gitUri.ts @@ -1,21 +1,22 @@ import { Uri } from 'vscode'; -import { decodeUtf8Hex, encodeUtf8Hex } from '@env/hex'; -import { UriComparer } from '../comparers'; +import { getQueryDataFromScmGitUri } from '../@types/vscode.git.uri'; import { Schemes } from '../constants'; import { Container } from '../container'; -import { Logger } from '../logger'; import type { GitHubAuthorityMetadata } from '../plus/remotehub'; +import { UriComparer } from '../system/comparers'; import { debug } from '../system/decorators/log'; import { memoize } from '../system/decorators/memoize'; -import { formatPath } from '../system/formatPath'; -import { basename, getBestPath, normalizePath, relativeDir, splitPath } from '../system/path'; -// import { CharCode } from '../system/string'; -import { isVirtualUri } from '../system/utils'; +import { basename, normalizePath } from '../system/path'; +import { formatPath } from '../system/vscode/formatPath'; +import { getBestPath, relativeDir, splitPath } from '../system/vscode/path'; +import { isVirtualUri } from '../system/vscode/utils'; import type { RevisionUriData } from './gitProvider'; +import { decodeGitLensRevisionUriAuthority, decodeRemoteHubAuthority } from './gitUri.authority'; +import { uncommittedStaged } from './models/constants'; import type { GitFile } from './models/file'; -import { GitRevision } from './models/reference'; +import { isUncommitted, isUncommittedStaged, shortenRevision } from './models/reference'; -const slash = 47; //CharCode.Slash; +const slash = 47; //slash; export interface GitCommitish { fileName?: string; @@ -35,6 +36,7 @@ interface UriEx { new (): Uri; new (scheme: string, authority: string, path: string, query: string, fragment: string): Uri; // Use this ctor, because vscode doesn't validate it + // eslint-disable-next-line @typescript-eslint/unified-signatures new (components: UriComponents): Uri; } @@ -43,7 +45,9 @@ export class GitUri extends (Uri as any as UriEx) { readonly sha?: string; constructor(uri?: Uri); + // eslint-disable-next-line @typescript-eslint/unified-signatures constructor(uri: Uri, commit: GitCommitish); + // eslint-disable-next-line @typescript-eslint/unified-signatures constructor(uri: Uri, repoPath: string | undefined); constructor(uri?: Uri, commitOrRepoPath?: GitCommitish | string) { if (uri == null) { @@ -53,15 +57,21 @@ export class GitUri extends (Uri as any as UriEx) { } if (uri.scheme === Schemes.GitLens) { + let path = uri.path; + + const metadata = decodeGitLensRevisionUriAuthority(uri.authority); + if (metadata.uncPath != null && !path.startsWith(metadata.uncPath)) { + path = `${metadata.uncPath}${uri.path}`; + } + super({ scheme: uri.scheme, authority: uri.authority, - path: uri.path, + path: path, query: uri.query, fragment: uri.fragment, }); - const metadata = decodeGitLensRevisionUriAuthority(uri.authority); this.repoPath = metadata.repoPath; let ref = metadata.ref; @@ -69,7 +79,7 @@ export class GitUri extends (Uri as any as UriEx) { ref = commitOrRepoPath.sha; } - if (GitRevision.isUncommittedStaged(ref) || !GitRevision.isUncommitted(ref)) { + if (!isUncommitted(ref) || isUncommittedStaged(ref)) { this.sha = ref; } @@ -82,14 +92,14 @@ export class GitUri extends (Uri as any as UriEx) { const [, owner, repo] = uri.path.split('/', 3); this.repoPath = uri.with({ path: `/${owner}/${repo}` }).toString(); - const data = decodeRemoteHubAuthority(uri); + const data = decodeRemoteHubAuthority(uri.authority); let ref = data.metadata?.ref?.id; if (commitOrRepoPath != null && typeof commitOrRepoPath !== 'string') { ref = commitOrRepoPath.sha; } - if (ref && (GitRevision.isUncommittedStaged(ref) || !GitRevision.isUncommitted(ref))) { + if (ref && (!isUncommitted(ref) || isUncommittedStaged(ref))) { this.sha = ref; } @@ -154,7 +164,7 @@ export class GitUri extends (Uri as any as UriEx) { fragment: uri.fragment, }); this.repoPath = commitOrRepoPath.repoPath; - if (GitRevision.isUncommittedStaged(commitOrRepoPath.sha) || !GitRevision.isUncommitted(commitOrRepoPath.sha)) { + if (!isUncommitted(commitOrRepoPath.sha) || isUncommittedStaged(commitOrRepoPath.sha)) { this.sha = commitOrRepoPath.sha; } } @@ -171,12 +181,12 @@ export class GitUri extends (Uri as any as UriEx) { @memoize() get isUncommitted(): boolean { - return GitRevision.isUncommitted(this.sha); + return isUncommitted(this.sha); } @memoize() get isUncommittedStaged(): boolean { - return GitRevision.isUncommittedStaged(this.sha); + return isUncommittedStaged(this.sha); } @memoize() @@ -186,7 +196,7 @@ export class GitUri extends (Uri as any as UriEx) { @memoize() get shortSha(): string { - return GitRevision.shorten(this.sha); + return shortenRevision(this.sha); } @memoize() @@ -242,9 +252,7 @@ export class GitUri extends (Uri as any as UriEx) { return new GitUri(uri); } - @debug({ - exit: uri => `returned ${Logger.toLoggable(uri)}`, - }) + @debug({ exit: true }) static async fromUri(uri: Uri): Promise { if (isGitUri(uri)) return uri; if (!Container.instance.git.isTrackable(uri)) return new GitUri(uri); @@ -252,11 +260,7 @@ export class GitUri extends (Uri as any as UriEx) { // If this is a git uri, find its repoPath if (uri.scheme === Schemes.Git) { - let data: { path: string; ref: string } | undefined; - try { - data = JSON.parse(uri.query); - } catch {} - + const data = getQueryDataFromScmGitUri(uri); if (data?.path) { const repository = await Container.instance.git.getOrOpenRepository(Uri.file(data.path)); if (repository == null) { @@ -268,7 +272,7 @@ export class GitUri extends (Uri as any as UriEx) { switch (data.ref) { case '': case '~': - ref = GitRevision.uncommittedStaged; + ref = uncommittedStaged; break; case null: @@ -331,25 +335,3 @@ export const unknownGitUri = Object.freeze(new GitUri()); export function isGitUri(uri: any): uri is GitUri { return uri instanceof GitUri; } - -export function decodeGitLensRevisionUriAuthority(authority: string): T { - return JSON.parse(decodeUtf8Hex(authority)) as T; -} - -export function encodeGitLensRevisionUriAuthority(metadata: T): string { - return encodeUtf8Hex(JSON.stringify(metadata)); -} - -function decodeRemoteHubAuthority(uri: Uri): { scheme: string; metadata: T | undefined } { - const [scheme, encoded] = uri.authority.split('+'); - - let metadata: T | undefined; - if (encoded) { - try { - const data = JSON.parse(decodeUtf8Hex(encoded)); - metadata = data as T; - } catch {} - } - - return { scheme: scheme, metadata: metadata }; -} diff --git a/src/git/models/author.ts b/src/git/models/author.ts index 8d2ef94dda48f..c05aff069b637 100644 --- a/src/git/models/author.ts +++ b/src/git/models/author.ts @@ -1,8 +1,19 @@ -import type { RemoteProviderReference } from './remoteProvider'; +import type { ProviderReference } from './remoteProvider'; -export interface Account { - provider: RemoteProviderReference; +export interface CommitAuthor { + provider: ProviderReference; + readonly id: string | undefined; + readonly username: string | undefined; name: string | undefined; email: string | undefined; avatarUrl: string | undefined; } + +export interface UnidentifiedAuthor extends CommitAuthor { + readonly id: undefined; + readonly username: undefined; +} + +export interface Account extends CommitAuthor { + readonly id: string; +} diff --git a/src/git/models/branch.ts b/src/git/models/branch.ts index b8db5e91ed32b..f13e1e4a05275 100644 --- a/src/git/models/branch.ts +++ b/src/git/models/branch.ts @@ -1,42 +1,45 @@ -import { BranchSorting, configuration, DateStyle } from '../../configuration'; -import { Container } from '../../container'; -import { getLoggableName } from '../../logger'; +import type { CancellationToken } from 'vscode'; +import type { BranchSorting } from '../../config'; +import type { GitConfigKeys } from '../../constants'; +import type { Container } from '../../container'; import { formatDate, fromNow } from '../../system/date'; import { debug } from '../../system/decorators/log'; import { memoize } from '../../system/decorators/memoize'; -import { cancellable } from '../../system/promise'; +import { getLoggableName } from '../../system/logger'; +import { PageableResult } from '../../system/paging'; +import type { MaybePausedResult } from '../../system/promise'; +import { pauseOnCancelOrTimeout } from '../../system/promise'; import { sortCompare } from '../../system/string'; -import type { RemoteProvider } from '../remotes/remoteProvider'; -import type { RichRemoteProvider } from '../remotes/richRemoteProvider'; +import { configuration } from '../../system/vscode/configuration'; import type { PullRequest, PullRequestState } from './pullRequest'; import type { GitBranchReference, GitReference } from './reference'; -import { GitRevision } from './reference'; +import { getBranchTrackingWithoutRemote, shortenRevision } from './reference'; import type { GitRemote } from './remote'; +import type { Repository } from './repository'; import { getUpstreamStatus } from './status'; -const whitespaceRegex = /\s/; -const detachedHEADRegex = /^(?=.*\bHEAD\b)?(?=.*\bdetached\b).*$/; +const detachedHEADRegex = /^(HEAD|\(.*\))$/; export interface GitTrackingState { ahead: number; behind: number; } -export const enum GitBranchStatus { - Ahead = 'ahead', - Behind = 'behind', - Diverged = 'diverged', - Local = 'local', - MissingUpstream = 'missingUpstream', - Remote = 'remote', - UpToDate = 'upToDate', - Unpublished = 'unpublished', -} +export type GitBranchStatus = + | 'local' + | 'detached' + | 'ahead' + | 'behind' + | 'diverged' + | 'upToDate' + | 'missingUpstream' + | 'remote'; export interface BranchSortOptions { current?: boolean; missingUpstream?: boolean; orderBy?: BranchSorting; + openedWorktreesByBranch?: Set; } export function getBranchId(repoPath: string, remote: boolean, name: string): string { @@ -51,6 +54,7 @@ export class GitBranch implements GitBranchReference { readonly state: GitTrackingState; constructor( + private readonly container: Container, public readonly repoPath: string, public readonly name: string, public readonly remote: boolean, @@ -82,8 +86,8 @@ export class GitBranch implements GitBranchReference { } get formattedDate(): string { - return Container.instance.BranchDateFormatting.dateStyle === DateStyle.Absolute - ? this.formatDate(Container.instance.BranchDateFormatting.dateFormat) + return this.container.BranchDateFormatting.dateStyle === 'absolute' + ? this.formatDate(this.container.BranchDateFormatting.dateFormat) : this.formatDateFromNow(); } @@ -91,6 +95,17 @@ export class GitBranch implements GitBranchReference { return this.detached ? this.sha! : this.name; } + get status(): GitBranchStatus { + if (this.remote) return 'remote'; + if (this.upstream == null) return this.detached ? 'detached' : 'local'; + + if (this.upstream.missing) return 'missingUpstream'; + if (this.state.ahead && this.state.behind) return 'diverged'; + if (this.state.ahead) return 'ahead'; + if (this.state.behind) return 'behind'; + return 'upToDate'; + } + @memoize(format => format ?? 'MMMM Do, YYYY h:mma') formatDate(format?: string | null): string { return this.date != null ? formatDate(this.date, format ?? 'MMMM Do, YYYY h:mma') : ''; @@ -100,27 +115,29 @@ export class GitBranch implements GitBranchReference { return this.date != null ? fromNow(this.date) : ''; } - private _pullRequest: Promise | undefined; - @debug() async getAssociatedPullRequest(options?: { avatarSize?: number; include?: PullRequestState[]; - limit?: number; - timeout?: number; + expiryOverride?: boolean | number; }): Promise { - if (this._pullRequest == null) { - async function getCore(this: GitBranch): Promise { - const remote = await this.getRemoteWithProvider(); - if (remote == null) return undefined; - - const branch = this.getTrackingWithoutRemote() ?? this.getNameWithoutRemote(); - return Container.instance.git.getPullRequestForBranch(branch, remote, options); - } - this._pullRequest = getCore.call(this); + const remote = await this.getRemote(); + if (remote?.provider == null) return undefined; + + const integration = await this.container.integrations.getByRemote(remote); + if (integration == null) return undefined; + + if (this.upstream?.missing) { + if (!this.sha) return undefined; + + return integration?.getPullRequestForCommit(remote.provider.repoDesc, this.sha); } - return cancellable(this._pullRequest, options?.timeout); + return integration?.getPullRequestForBranch( + remote.provider.repoDesc, + this.getTrackingWithoutRemote() ?? this.getNameWithoutRemote(), + options, + ); } @memoize() @@ -132,12 +149,12 @@ export class GitBranch implements GitBranchReference { @memoize() getNameWithoutRemote(): string { - return this.remote ? this.name.substring(this.name.indexOf('/') + 1) : this.name; + return this.remote ? this.name.substring(getRemoteNameSlashIndex(this.name) + 1) : this.name; } @memoize() getTrackingWithoutRemote(): string | undefined { - return this.upstream?.name.substring(this.upstream.name.indexOf('/') + 1); + return getBranchTrackingWithoutRemote(this); } @memoize() @@ -145,21 +162,8 @@ export class GitBranch implements GitBranchReference { const remoteName = this.getRemoteName(); if (remoteName == null) return undefined; - const remotes = await Container.instance.git.getRemotes(this.repoPath); - if (remotes.length === 0) return undefined; - - return remotes.find(r => r.name === remoteName); - } - - @memoize() - async getRemoteWithProvider(): Promise | undefined> { - const remoteName = this.getRemoteName(); - if (remoteName == null) return undefined; - - const remotes = await Container.instance.git.getRemotesWithProviders(this.repoPath); - if (remotes.length === 0) return undefined; - - return remotes.find(r => r.name === remoteName); + const remotes = await this.container.git.getRemotes(this.repoPath); + return remotes.length ? remotes.find(r => r.name === remoteName) : undefined; } @memoize() @@ -170,24 +174,6 @@ export class GitBranch implements GitBranchReference { return undefined; } - @memoize() - async getStatus(): Promise { - if (this.remote) return GitBranchStatus.Remote; - - if (this.upstream != null) { - if (this.upstream.missing) return GitBranchStatus.MissingUpstream; - if (this.state.ahead && this.state.behind) return GitBranchStatus.Diverged; - if (this.state.ahead) return GitBranchStatus.Ahead; - if (this.state.behind) return GitBranchStatus.Behind; - return GitBranchStatus.UpToDate; - } - - const remotes = await Container.instance.git.getRemotesWithProviders(this.repoPath); - if (remotes.length > 0) return GitBranchStatus.Unpublished; - - return GitBranchStatus.Local; - } - getTrackingStatus(options?: { count?: boolean; empty?: string; @@ -201,29 +187,106 @@ export class GitBranch implements GitBranchReference { } get starred() { - const starred = Container.instance.storage.getWorkspace('starred:branches'); + const starred = this.container.storage.getWorkspace('starred:branches'); return starred !== undefined && starred[this.id] === true; } star() { - return Container.instance.git.getRepository(this.repoPath)?.star(this); + return this.container.git.getRepository(this.repoPath)?.star(this); } unstar() { - return Container.instance.git.getRepository(this.repoPath)?.unstar(this); + return this.container.git.getRepository(this.repoPath)?.unstar(this); } } export function formatDetachedHeadName(sha: string): string { - return `(${GitRevision.shorten(sha)}...)`; + return `(${shortenRevision(sha)}...)`; +} + +export function getRemoteNameSlashIndex(name: string): number { + return name.startsWith('remotes/') ? name.indexOf('/', 8) : name.indexOf('/'); +} + +export function getBranchNameAndRemote(ref: GitBranchReference): [name: string, remote: string | undefined] { + if (ref.remote) { + const index = getRemoteNameSlashIndex(ref.name); + if (index === -1) return [ref.name, undefined]; + + return [ref.name.substring(index + 1), ref.name.substring(0, index)]; + } + + if (ref.upstream?.name != null) { + const index = getRemoteNameSlashIndex(ref.upstream.name); + if (index === -1) return [ref.name, undefined]; + + return [ref.name, ref.upstream.name.substring(0, index)]; + } + + return [ref.name, undefined]; } export function getBranchNameWithoutRemote(name: string): string { - return name.substring(name.indexOf('/') + 1); + return name.substring(getRemoteNameSlashIndex(name) + 1); +} + +export async function getDefaultBranchName( + container: Container, + repoPath: string, + remoteName?: string, + options?: { cancellation?: CancellationToken }, +): Promise { + const name = await container.git.getDefaultBranchName(repoPath, remoteName); + if (name != null) return name; + + const remote = await container.git.getBestRemoteWithIntegration(repoPath); + if (remote == null) return undefined; + + const integration = await remote.getIntegration(); + const defaultBranch = await integration?.getDefaultBranch?.(remote.provider.repoDesc, options); + return `${remote.name}/${defaultBranch?.name}`; } export function getRemoteNameFromBranchName(name: string): string { - return name.substring(0, name.indexOf('/')); + return name.substring(0, getRemoteNameSlashIndex(name)); +} + +export async function getTargetBranchName( + container: Container, + branch: GitBranch, + options?: { + associatedPullRequest?: Promise; + cancellation?: CancellationToken; + timeout?: number; + }, +): Promise> { + const targetBaseConfigKey: GitConfigKeys = `branch.${branch.name}.gk-target-base`; + + const targetBase = await container.git.getConfig(branch.repoPath, targetBaseConfigKey); + + if (options?.cancellation?.isCancellationRequested) return { value: undefined, paused: false }; + + if (targetBase != null) { + const [targetBranch] = ( + await container.git.getBranches(branch.repoPath, { filter: b => b.name === targetBase }) + ).values; + if (targetBranch != null) return { value: targetBranch.name, paused: false }; + } + + if (options?.cancellation?.isCancellationRequested) return { value: undefined, paused: false }; + + return pauseOnCancelOrTimeout( + (options?.associatedPullRequest ?? branch?.getAssociatedPullRequest())?.then(pr => { + if (pr?.refs?.base == null) return undefined; + + const name = `${branch.getRemoteName()}/${pr.refs.base.branch}`; + void container.git.setConfig(branch.repoPath, targetBaseConfigKey, name); + + return name; + }), + options?.cancellation, + options?.timeout, + ); } export function isBranch(branch: any): branch is GitBranch { @@ -233,38 +296,41 @@ export function isBranch(branch: any): branch is GitBranch { export function isDetachedHead(name: string): boolean { // If there is whitespace in the name assume this is not a valid branch name // Deals with detached HEAD states - return whitespaceRegex.test(name) || detachedHEADRegex.test(name); + name = name.trim(); + return name.length ? detachedHEADRegex.test(name) : true; } export function isOfBranchRefType(branch: GitReference | undefined) { return branch?.refType === 'branch'; } -export function splitBranchNameAndRemote(name: string): [name: string, remote: string | undefined] { - const index = name.indexOf('/'); - if (index === -1) return [name, undefined]; - - return [name.substring(index + 1), name.substring(0, index)]; -} - export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) { options = { current: true, orderBy: configuration.get('sortBranchesBy'), ...options }; switch (options.orderBy) { - case BranchSorting.DateAsc: + case 'date:asc': return branches.sort( (a, b) => - (options!.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.openedWorktreesByBranch + ? (options.openedWorktreesByBranch.has(a.id) ? -1 : 1) - + (options.openedWorktreesByBranch.has(b.id) ? -1 : 1) + : 0) || (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (b.remote ? -1 : 1) - (a.remote ? -1 : 1) || - (a.date == null ? -1 : a.date.getTime()) - (b.date == null ? -1 : b.date.getTime()), + (a.date == null ? -1 : a.date.getTime()) - (b.date == null ? -1 : b.date.getTime()) || + sortCompare(a.name, b.name), ); - case BranchSorting.NameAsc: + case 'name:asc': return branches.sort( (a, b) => - (options!.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.openedWorktreesByBranch + ? (options.openedWorktreesByBranch.has(a.id) ? -1 : 1) - + (options.openedWorktreesByBranch.has(b.id) ? -1 : 1) + : 0) || (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (a.name === 'main' ? -1 : 1) - (b.name === 'main' ? -1 : 1) || (a.name === 'master' ? -1 : 1) - (b.name === 'master' ? -1 : 1) || @@ -272,11 +338,15 @@ export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) (b.remote ? -1 : 1) - (a.remote ? -1 : 1) || sortCompare(a.name, b.name), ); - case BranchSorting.NameDesc: + case 'name:desc': return branches.sort( (a, b) => - (options!.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.openedWorktreesByBranch + ? (options.openedWorktreesByBranch.has(a.id) ? -1 : 1) - + (options.openedWorktreesByBranch.has(b.id) ? -1 : 1) + : 0) || (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (a.name === 'main' ? -1 : 1) - (b.name === 'main' ? -1 : 1) || (a.name === 'master' ? -1 : 1) - (b.name === 'master' ? -1 : 1) || @@ -284,15 +354,47 @@ export function sortBranches(branches: GitBranch[], options?: BranchSortOptions) (b.remote ? -1 : 1) - (a.remote ? -1 : 1) || sortCompare(b.name, a.name), ); - case BranchSorting.DateDesc: + case 'date:desc': default: return branches.sort( (a, b) => - (options!.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.missingUpstream ? (a.upstream?.missing ? -1 : 1) - (b.upstream?.missing ? -1 : 1) : 0) || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (options.openedWorktreesByBranch + ? (options.openedWorktreesByBranch.has(a.id) ? -1 : 1) - + (options.openedWorktreesByBranch.has(b.id) ? -1 : 1) + : 0) || (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (b.remote ? -1 : 1) - (a.remote ? -1 : 1) || - (b.date == null ? -1 : b.date.getTime()) - (a.date == null ? -1 : a.date.getTime()), + (b.date == null ? -1 : b.date.getTime()) - (a.date == null ? -1 : a.date.getTime()) || + sortCompare(b.name, a.name), ); } } + +export async function getLocalBranchByUpstream( + repo: Repository, + remoteBranchName: string, + branches?: PageableResult | Map, +): Promise { + let qualifiedRemoteBranchName; + if (remoteBranchName.startsWith('remotes/')) { + qualifiedRemoteBranchName = remoteBranchName; + remoteBranchName = remoteBranchName.substring(8); + } else { + qualifiedRemoteBranchName = `remotes/${remoteBranchName}`; + } + + branches ??= new PageableResult(p => repo.getBranches(p != null ? { paging: p } : undefined)); + for await (const branch of branches.values()) { + if ( + !branch.remote && + branch.upstream?.name != null && + (branch.upstream.name === remoteBranchName || branch.upstream.name === qualifiedRemoteBranchName) + ) { + return branch; + } + } + + return undefined; +} diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index 9c8745b1e7b85..30cc2b8832f11 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -1,23 +1,23 @@ import { Uri } from 'vscode'; +import type { EnrichedAutolink } from '../../annotations/autolinks'; import { getAvatarUri, getCachedAvatarUri } from '../../avatars'; -import type { GravatarDefaultStyle } from '../../configuration'; -import { DateSource, DateStyle } from '../../configuration'; +import type { GravatarDefaultStyle } from '../../config'; import { GlyphChars } from '../../constants'; import type { Container } from '../../container'; -import { getLoggableName } from '../../logger'; import { formatDate, fromNow } from '../../system/date'; import { gate } from '../../system/decorators/gate'; import { memoize } from '../../system/decorators/memoize'; -import { cancellable } from '../../system/promise'; +import { getLoggableName } from '../../system/logger'; import { pad, pluralize } from '../../system/string'; import type { PreviousLineComparisonUrisResult } from '../gitProvider'; import { GitUri } from '../gitUri'; -import type { RichRemoteProvider } from '../remotes/richRemoteProvider'; +import type { RemoteProvider } from '../remotes/remoteProvider'; +import { uncommitted, uncommittedStaged } from './constants'; import type { GitFile } from './file'; import { GitFileChange, GitFileWorkingTreeStatus } from './file'; import type { PullRequest } from './pullRequest'; import type { GitReference, GitRevisionReference, GitStashReference } from './reference'; -import { GitRevision } from './reference'; +import { isSha, isUncommitted, isUncommittedParent, isUncommittedStaged } from './reference'; import type { GitRemote } from './remote'; import type { Repository } from './repository'; @@ -53,8 +53,8 @@ export class GitCommit implements GitRevisionReference { stashName?: string | undefined, stashOnRef?: string | undefined, ) { - this.ref = this.sha; - this.shortSha = this.sha.substring(0, this.container.CommitShaFormatting.length); + this.ref = sha; + this.shortSha = sha.substring(0, this.container.CommitShaFormatting.length); this.tips = tips; if (stashName) { @@ -74,6 +74,9 @@ export class GitCommit implements GitRevisionReference { } else { this._summary = summary; } + } else if (isUncommitted(sha, true)) { + this._summary = summary; + this._message = 'Uncommitted Changes'; } else { this._summary = `${summary} ${GlyphChars.Ellipsis}`; } @@ -117,9 +120,7 @@ export class GitCommit implements GitRevisionReference { } get date(): Date { - return this.container.CommitDateFormatting.dateSource === DateSource.Committed - ? this.committer.date - : this.author.date; + return this.container.CommitDateFormatting.dateSource === 'committed' ? this.committer.date : this.author.date; } private _file: GitFileChange | undefined; @@ -133,19 +134,19 @@ export class GitCommit implements GitRevisionReference { } get formattedDate(): string { - return this.container.CommitDateFormatting.dateStyle === DateStyle.Absolute + return this.container.CommitDateFormatting.dateStyle === 'absolute' ? this.formatDate(this.container.CommitDateFormatting.dateFormat) : this.formatDateFromNow(); } @memoize() get isUncommitted(): boolean { - return GitRevision.isUncommitted(this.sha); + return isUncommitted(this.sha); } @memoize() get isUncommittedStaged(): boolean { - return GitRevision.isUncommittedStaged(this.sha); + return isUncommittedStaged(this.sha); } private _message: string | undefined; @@ -172,17 +173,21 @@ export class GitCommit implements GitRevisionReference { } private _resolvedPreviousSha: string | undefined; + get resolvedPreviousSha(): string | undefined { + return this._resolvedPreviousSha; + } + get unresolvedPreviousSha(): string { const previousSha = this._resolvedPreviousSha ?? (this.file != null ? this.file.previousSha : this.parents[0]) ?? `${this.sha}^`; - return GitRevision.isUncommittedParent(previousSha) ? 'HEAD' : previousSha; + return isUncommittedParent(previousSha) ? 'HEAD' : previousSha; } private _etagFileSystem: number | undefined; - hasFullDetails(): this is GitCommit & SomeNonNullable { + hasFullDetails(): this is GitCommitWithFullDetails { return ( this.message != null && this.files != null && @@ -194,29 +199,19 @@ export class GitCommit implements GitRevisionReference { ); } - assertsFullDetails(): asserts this is GitCommit & SomeNonNullable { - if (!this.hasFullDetails()) { - throw new Error(`GitCommit(${this.sha}) is not fully loaded`); - } - } - @gate() async ensureFullDetails(): Promise { if (this.hasFullDetails()) return; // If the commit is "uncommitted", then have the files list be all uncommitted files if (this.isUncommitted) { - this._message = 'Uncommitted Changes'; - const repository = this.container.git.getRepository(this.repoPath); this._etagFileSystem = repository?.etagFileSystem; if (this._etagFileSystem != null) { const status = await this.container.git.getStatusForRepo(this.repoPath); if (status != null) { - this._files = status.files.map( - f => new GitFileChange(this.repoPath, f.path, f.status, f.originalPath), - ); + this._files = status.files.flatMap(f => f.getPseudoFileChanges()); } this._etagFileSystem = repository?.etagFileSystem; } @@ -225,6 +220,8 @@ export class GitCommit implements GitRevisionReference { this._files = this.file != null ? [this.file] : []; } + this._recomputeStats = true; + return; } @@ -327,26 +324,29 @@ export class GitCommit implements GitRevisionReference { this._stats = { ...this._stats, changedFiles: changedFiles, additions: additions, deletions: deletions }; } - async findFile(path: string): Promise; - async findFile(uri: Uri): Promise; - async findFile(pathOrUri: string | Uri): Promise { + async findFile(path: string, staged?: boolean): Promise; + async findFile(uri: Uri, staged?: boolean): Promise; + async findFile(pathOrUri: string | Uri, staged?: boolean): Promise { if (!this.hasFullDetails()) { await this.ensureFullDetails(); if (this._files == null) return undefined; } const relativePath = this.container.git.getRelativePath(pathOrUri, this.repoPath); + if (this.isUncommitted && staged != null) { + return this._files?.find(f => f.path === relativePath && f.staged === staged); + } return this._files?.find(f => f.path === relativePath); } formatDate(format?: string | null) { - return this.container.CommitDateFormatting.dateSource === DateSource.Committed + return this.container.CommitDateFormatting.dateSource === 'committed' ? this.committer.formatDate(format) : this.author.formatDate(format); } formatDateFromNow(short?: boolean) { - return this.container.CommitDateFormatting.dateSource === DateSource.Committed + return this.container.CommitDateFormatting.dateSource === 'committed' ? this.committer.fromNow(short) : this.author.fromNow(short); } @@ -421,23 +421,39 @@ export class GitCommit implements GitRevisionReference { return status; } - private _pullRequest: Promise | undefined; - async getAssociatedPullRequest(options?: { - remote?: GitRemote; - timeout?: number; - }): Promise { - if (this._pullRequest == null) { - async function getCore(this: GitCommit): Promise { - const remote = - options?.remote ?? (await this.container.git.getBestRemoteWithRichProvider(this.repoPath)); - if (remote?.provider == null) return undefined; + async getAssociatedPullRequest( + remote?: GitRemote, + options?: { expiryOverride?: boolean | number }, + ): Promise { + remote ??= await this.container.git.getBestRemoteWithIntegration(this.repoPath); + if (!remote?.hasIntegration()) return undefined; - return this.container.git.getPullRequestForCommit(this.ref, remote, options); - } - this._pullRequest = getCore.call(this); + return (await this.container.integrations.getByRemote(remote))?.getPullRequestForCommit( + remote.provider.repoDesc, + this.ref, + options, + ); + } + + async getEnrichedAutolinks(remote?: GitRemote): Promise | undefined> { + if (this.isUncommitted) return undefined; + + remote ??= await this.container.git.getBestRemoteWithIntegration(this.repoPath); + if (remote?.provider == null) return undefined; + + // TODO@eamodio should we cache these? Seems like we would use more memory than it's worth + // async function getCore(this: GitCommit): Promise | undefined> { + if (this.message == null) { + await this.ensureFullDetails(); } - return cancellable(this._pullRequest, options?.timeout); + return this.container.autolinks.getEnrichedAutolinks(this.message ?? this.summary, remote); + // } + + // const enriched = this.container.cache.getEnrichedAutolinks(this.sha, remote, () => ({ + // value: getCore.call(this), + // })); + // return enriched; } getAvatarUri(options?: { defaultStyle?: GravatarDefaultStyle; size?: number }): Uri | Promise { @@ -448,12 +464,12 @@ export class GitCommit implements GitRevisionReference { return this.author.getCachedAvatarUri(options); } - async getCommitForFile(file: string | GitFile): Promise { + async getCommitForFile(file: string | GitFile, staged?: boolean): Promise { const path = typeof file === 'string' ? this.container.git.getRelativePath(file, this.repoPath) : file.path; - const foundFile = await this.findFile(path); + const foundFile = await this.findFile(path, staged); if (foundFile == null) return undefined; - const commit = this.with({ files: { file: foundFile } }); + const commit = this.with({ sha: foundFile.staged ? uncommittedStaged : this.sha, files: { file: foundFile } }); return commit; } @@ -488,7 +504,7 @@ export class GitCommit implements GitRevisionReference { this.repoPath, this.file.uri, editorLine, - ref ?? (this.sha === GitRevision.uncommitted ? undefined : this.sha), + ref ?? (this.sha === uncommitted ? undefined : this.sha), ) : Promise.resolve(undefined); } @@ -498,13 +514,13 @@ export class GitCommit implements GitRevisionReference { if (this._previousShaPromise == null) { async function getCore(this: GitCommit) { if (this.file != null) { - if (this.file.previousSha != null && GitRevision.isSha(this.file.previousSha)) { + if (this.file.previousSha != null && isSha(this.file.previousSha)) { return this.file.previousSha; } const sha = await this.container.git.resolveReference( this.repoPath, - GitRevision.isUncommitted(this.sha, true) ? 'HEAD' : `${this.sha}^`, + isUncommitted(this.sha, true) ? 'HEAD' : `${this.sha}^`, this.file.originalPath ?? this.file.path, ); @@ -513,11 +529,14 @@ export class GitCommit implements GitRevisionReference { } const parent = this.parents[0]; - if (parent != null && GitRevision.isSha(parent)) return parent; + if (parent != null && isSha(parent)) { + this._resolvedPreviousSha = parent; + return parent; + } const sha = await this.container.git.resolveReference( this.repoPath, - GitRevision.isUncommitted(this.sha, true) ? 'HEAD' : `${this.sha}^`, + isUncommitted(this.sha, true) ? 'HEAD' : `${this.sha}^`, ); this._resolvedPreviousSha = sha; @@ -544,6 +563,7 @@ export class GitCommit implements GitRevisionReference { parents?: string[]; files?: { file?: GitFileChange | null; files?: GitFileChange[] | null } | null; lines?: GitCommitLine[]; + stats?: GitCommitStats; }): GitCommit { let files; if (changes.files != null) { @@ -574,7 +594,7 @@ export class GitCommit implements GitRevisionReference { this.getChangedValue(changes.parents, this.parents) ?? [], this.message, files, - this.stats, + this.getChangedValue(changes.stats, this.stats), this.getChangedValue(changes.lines, this.lines), this.tips, this.stashName, @@ -663,3 +683,11 @@ export interface GitStashCommit extends GitCommit { readonly stashName: string; readonly number: string; } + +type GitCommitWithFullDetails = GitCommit & SomeNonNullable; + +export function assertsCommitHasFullDetails(commit: GitCommit): asserts commit is GitCommitWithFullDetails { + if (!commit.hasFullDetails()) { + throw new Error(`GitCommit(${commit.sha}) is not fully loaded`); + } +} diff --git a/src/git/models/constants.ts b/src/git/models/constants.ts new file mode 100644 index 0000000000000..c60947cc64d4f --- /dev/null +++ b/src/git/models/constants.ts @@ -0,0 +1,3 @@ +export const deletedOrMissing = '0000000000000000000000000000000000000000-'; +export const uncommitted = '0000000000000000000000000000000000000000'; +export const uncommittedStaged = '0000000000000000000000000000000000000000:'; diff --git a/src/git/models/contributor.ts b/src/git/models/contributor.ts index 0d6c82fdcd116..f56fc9a42e615 100644 --- a/src/git/models/contributor.ts +++ b/src/git/models/contributor.ts @@ -1,69 +1,15 @@ +import type { QuickInputButton } from 'vscode'; import { Uri } from 'vscode'; import { getAvatarUri } from '../../avatars'; -import type { GravatarDefaultStyle } from '../../configuration'; -import { configuration, ContributorSorting } from '../../configuration'; +import type { ContributorSorting, GravatarDefaultStyle } from '../../config'; +import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; import { sortCompare } from '../../system/string'; - -export interface ContributorSortOptions { - current?: true; - orderBy?: ContributorSorting; -} +import { configuration } from '../../system/vscode/configuration'; +import type { GitUser } from './user'; export class GitContributor { - static is(contributor: any): contributor is GitContributor { - return contributor instanceof GitContributor; - } - - static sort(contributors: GitContributor[], options?: ContributorSortOptions) { - options = { current: true, orderBy: configuration.get('sortContributorsBy'), ...options }; - - switch (options.orderBy) { - case ContributorSorting.CountAsc: - return contributors.sort( - (a, b) => - (a.current ? -1 : 1) - (b.current ? -1 : 1) || - a.count - b.count || - (a.date?.getTime() ?? 0) - (b.date?.getTime() ?? 0), - ); - case ContributorSorting.DateDesc: - return contributors.sort( - (a, b) => - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || - (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0) || - b.count - a.count, - ); - case ContributorSorting.DateAsc: - return contributors.sort( - (a, b) => - (options!.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || - (a.date?.getTime() ?? 0) - (b.date?.getTime() ?? 0) || - b.count - a.count, - ); - case ContributorSorting.NameAsc: - return contributors.sort( - (a, b) => - (a.current ? -1 : 1) - (b.current ? -1 : 1) || - sortCompare(a.name ?? a.username!, b.name ?? b.username!), - ); - case ContributorSorting.NameDesc: - return contributors.sort( - (a, b) => - (a.current ? -1 : 1) - (b.current ? -1 : 1) || - sortCompare(b.name ?? b.username!, a.name ?? a.username!), - ); - case ContributorSorting.CountDesc: - default: - return contributors.sort( - (a, b) => - (a.current ? -1 : 1) - (b.current ? -1 : 1) || - b.count - a.count || - (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0), - ); - } - } - constructor( public readonly repoPath: string, public readonly name: string | undefined, @@ -104,3 +50,147 @@ export class GitContributor { return `${this.name}${this.email ? ` <${this.email}>` : ''}`; } } + +export function matchContributor(c: GitContributor, user: GitUser): boolean { + return c.name === user.name && c.email === user.email && c.username === user.username; +} + +export function isContributor(contributor: any): contributor is GitContributor { + return contributor instanceof GitContributor; +} + +export type ContributorQuickPickItem = QuickPickItemOfT; + +export async function createContributorQuickPickItem( + contributor: GitContributor, + picked?: boolean, + options?: { alwaysShow?: boolean; buttons?: QuickInputButton[] }, +): Promise { + const item: ContributorQuickPickItem = { + label: contributor.label, + description: contributor.current ? 'you' : contributor.email, + alwaysShow: options?.alwaysShow, + buttons: options?.buttons, + picked: picked, + item: contributor, + iconPath: configuration.get('gitCommands.avatars') ? await contributor.getAvatarUri() : undefined, + }; + + if (options?.alwaysShow == null && picked) { + item.alwaysShow = true; + } + return item; +} + +export interface ContributorSortOptions { + current?: true; + orderBy?: ContributorSorting; +} + +interface ContributorQuickPickSortOptions extends ContributorSortOptions { + picked?: boolean; +} + +export function sortContributors(contributors: GitContributor[], options?: ContributorSortOptions): GitContributor[]; +export function sortContributors( + contributors: ContributorQuickPickItem[], + options?: ContributorQuickPickSortOptions, +): ContributorQuickPickItem[]; +export function sortContributors( + contributors: GitContributor[] | ContributorQuickPickItem[], + options?: (ContributorSortOptions & { picked?: never }) | ContributorQuickPickSortOptions, +) { + options = { picked: true, current: true, orderBy: configuration.get('sortContributorsBy'), ...options }; + + const getContributor = (contributor: GitContributor | ContributorQuickPickItem): GitContributor => { + return isContributor(contributor) ? contributor : contributor.item; + }; + + const comparePicked = ( + a: GitContributor | ContributorQuickPickItem, + b: GitContributor | ContributorQuickPickItem, + ): number => { + if (!options.picked || isContributor(a) || isContributor(b)) return 0; + return (a.picked ? -1 : 1) - (b.picked ? -1 : 1); + }; + + switch (options.orderBy) { + case 'count:asc': + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + a.count - b.count || + (a.date?.getTime() ?? 0) - (b.date?.getTime() ?? 0) + ); + }); + case 'date:desc': + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0) || + b.count - a.count + ); + }); + case 'date:asc': + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + (a.date?.getTime() ?? 0) - (b.date?.getTime() ?? 0) || + b.count - a.count + ); + }); + case 'name:asc': + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + sortCompare(a.name ?? a.username!, b.name ?? b.username!) + ); + }); + case 'name:desc': + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + sortCompare(b.name ?? b.username!, a.name ?? a.username!) + ); + }); + case 'count:desc': + default: + return contributors.sort((a, b) => { + const pickedCompare = comparePicked(a, b); + a = getContributor(a); + b = getContributor(b); + + return ( + pickedCompare || + (options.current ? (a.current ? -1 : 1) - (b.current ? -1 : 1) : 0) || + b.count - a.count || + (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0) + ); + }); + } +} diff --git a/src/git/models/defaultBranch.ts b/src/git/models/defaultBranch.ts index 95fd4a0ac51b5..9e6efcdfb4606 100644 --- a/src/git/models/defaultBranch.ts +++ b/src/git/models/defaultBranch.ts @@ -1,6 +1,6 @@ -import type { RemoteProviderReference } from './remoteProvider'; +import type { ProviderReference } from './remoteProvider'; export interface DefaultBranch { - provider: RemoteProviderReference; + provider: ProviderReference; name: string; } diff --git a/src/git/models/diff.ts b/src/git/models/diff.ts index 1fe9ed07b269c..afe146d4e801e 100644 --- a/src/git/models/diff.ts +++ b/src/git/models/diff.ts @@ -1,50 +1,38 @@ -import { GitDiffParser } from '../parsers/diffParser'; +import type { GitFileChange } from './file'; -export interface GitDiffLine { - line: string; - state: 'added' | 'removed' | 'unchanged'; +export interface GitDiff { + readonly contents: string; + readonly from: string; + readonly to: string; } export interface GitDiffHunkLine { - hunk: GitDiffHunk; - current: GitDiffLine | undefined; - previous: GitDiffLine | undefined; + current: string | undefined; + previous: string | undefined; + state: 'added' | 'changed' | 'removed' | 'unchanged'; } -export class GitDiffHunk { - constructor( - public readonly diff: string, - public current: { - count: number; - position: { start: number; end: number }; - }, - public previous: { - count: number; - position: { start: number; end: number }; - }, - ) {} - - get lines(): GitDiffHunkLine[] { - return this.parseHunk().lines; - } - - get state(): 'added' | 'changed' | 'removed' { - return this.parseHunk().state; - } - - private parsedHunk: { lines: GitDiffHunkLine[]; state: 'added' | 'changed' | 'removed' } | undefined; - private parseHunk() { - if (this.parsedHunk == null) { - this.parsedHunk = GitDiffParser.parseHunk(this); - } - return this.parsedHunk; - } +export interface GitDiffHunk { + readonly contents: string; + readonly current: { + readonly count: number; + readonly position: { readonly start: number; readonly end: number }; + }; + readonly previous: { + readonly count: number; + readonly position: { readonly start: number; readonly end: number }; + }; + readonly lines: Map; } -export interface GitDiff { +export interface GitDiffFile { readonly hunks: GitDiffHunk[]; + readonly contents?: string; +} - readonly diff?: string; +export interface GitDiffLine { + readonly hunk: GitDiffHunk; + readonly line: GitDiffHunkLine; } export interface GitDiffShortStat { @@ -53,4 +41,8 @@ export interface GitDiffShortStat { readonly changedFiles: number; } +export interface GitDiffFiles { + readonly files: GitFileChange[]; +} + export type GitDiffFilter = 'A' | 'C' | 'D' | 'M' | 'R' | 'T' | 'U' | 'X' | 'B' | '*'; diff --git a/src/git/models/file.ts b/src/git/models/file.ts index c855f47df75bc..7752232ec8722 100644 --- a/src/git/models/file.ts +++ b/src/git/models/file.ts @@ -1,10 +1,11 @@ import type { Uri } from 'vscode'; +import { ThemeIcon } from 'vscode'; import { GlyphChars } from '../../constants'; import { Container } from '../../container'; import { memoize } from '../../system/decorators/memoize'; -import { formatPath } from '../../system/formatPath'; -import { relativeDir, splitPath } from '../../system/path'; import { pad, pluralize } from '../../system/string'; +import { formatPath } from '../../system/vscode/formatPath'; +import { relativeDir, splitPath } from '../../system/vscode/path'; import type { GitCommit } from './commit'; export declare type GitFileStatus = GitFileConflictStatus | GitFileIndexStatus | GitFileWorkingTreeStatus; @@ -54,117 +55,116 @@ export interface GitFileWithCommit extends GitFile { readonly commit: GitCommit; } -export namespace GitFile { - export function is(file: any | undefined): file is GitFile { - return ( - file != null && - 'fileName' in file && - typeof file.fileName === 'string' && - 'status' in file && - typeof file.status === 'string' && - file.status.length === 1 - ); - } +export function isGitFile(file: any | undefined): file is GitFile { + return ( + file != null && + 'fileName' in file && + typeof file.fileName === 'string' && + 'status' in file && + typeof file.status === 'string' && + file.status.length === 1 + ); +} - export function getFormattedDirectory( - file: GitFile, - includeOriginal: boolean = false, - relativeTo?: string, - ): string { - const directory = relativeDir(file.path, relativeTo); - return includeOriginal && (file.status === 'R' || file.status === 'C') && file.originalPath - ? `${directory} ${pad(GlyphChars.ArrowLeft, 1, 1)} ${file.originalPath}` - : directory; - } +export function getGitFileFormattedDirectory( + file: GitFile, + includeOriginal: boolean = false, + relativeTo?: string, +): string { + const directory = relativeDir(file.path, relativeTo); + return includeOriginal && (file.status === 'R' || file.status === 'C') && file.originalPath + ? `${directory} ${pad(GlyphChars.ArrowLeft, 1, 1)} ${file.originalPath}` + : directory; +} - export function getFormattedPath( - file: GitFile, - options: { relativeTo?: string; suffix?: string; truncateTo?: number } = {}, - ): string { - return formatPath(file.path, options); - } +export function getGitFileFormattedPath( + file: GitFile, + options: { relativeTo?: string; suffix?: string; truncateTo?: number } = {}, +): string { + return formatPath(file.path, options); +} - export function getOriginalRelativePath(file: GitFile, relativeTo?: string): string { - if (!file.originalPath) return ''; +export function getGitFileOriginalRelativePath(file: GitFile, relativeTo?: string): string { + if (!file.originalPath) return ''; - return splitPath(file.originalPath, relativeTo)[0]; - } + return splitPath(file.originalPath, relativeTo)[0]; +} - export function getRelativePath(file: GitFile, relativeTo?: string): string { - return splitPath(file.path, relativeTo)[0]; - } +export function getGitFileRelativePath(file: GitFile, relativeTo?: string): string { + return splitPath(file.path, relativeTo)[0]; +} - const statusIconsMap = { - '.': undefined, - '!': 'icon-status-ignored.svg', - '?': 'icon-status-untracked.svg', - A: 'icon-status-added.svg', - D: 'icon-status-deleted.svg', - M: 'icon-status-modified.svg', - R: 'icon-status-renamed.svg', - C: 'icon-status-copied.svg', - AA: 'icon-status-conflict.svg', - AU: 'icon-status-conflict.svg', - UA: 'icon-status-conflict.svg', - DD: 'icon-status-conflict.svg', - DU: 'icon-status-conflict.svg', - UD: 'icon-status-conflict.svg', - UU: 'icon-status-conflict.svg', - T: 'icon-status-modified.svg', - U: 'icon-status-modified.svg', - }; - - export function getStatusIcon(status: GitFileStatus): string { - return statusIconsMap[status] ?? 'icon-status-unknown.svg'; - } +const statusIconsMap = { + '.': undefined, + '!': 'icon-status-ignored.svg', + '?': 'icon-status-untracked.svg', + A: 'icon-status-added.svg', + D: 'icon-status-deleted.svg', + M: 'icon-status-modified.svg', + R: 'icon-status-renamed.svg', + C: 'icon-status-copied.svg', + AA: 'icon-status-conflict.svg', + AU: 'icon-status-conflict.svg', + UA: 'icon-status-conflict.svg', + DD: 'icon-status-conflict.svg', + DU: 'icon-status-conflict.svg', + UD: 'icon-status-conflict.svg', + UU: 'icon-status-conflict.svg', + T: 'icon-status-modified.svg', + U: 'icon-status-modified.svg', +}; + +export function getGitFileStatusIcon(status: GitFileStatus): string { + return statusIconsMap[status] ?? 'icon-status-unknown.svg'; +} - const statusCodiconsMap = { - '.': undefined, - '!': '$(diff-ignored)', - '?': '$(diff-added)', - A: '$(diff-added)', - D: '$(diff-removed)', - M: '$(diff-modified)', - R: '$(diff-renamed)', - C: '$(diff-added)', - AA: '$(warning)', - AU: '$(warning)', - UA: '$(warning)', - DD: '$(warning)', - DU: '$(warning)', - UD: '$(warning)', - UU: '$(warning)', - T: '$(diff-modified)', - U: '$(diff-modified)', - }; - - export function getStatusCodicon(status: GitFileStatus, missing: string = GlyphChars.Space.repeat(4)): string { - return statusCodiconsMap[status] ?? missing; - } +const statusCodiconsMap = { + '.': undefined, + '!': 'diff-ignored', + '?': 'diff-added', + A: 'diff-added', + D: 'diff-removed', + M: 'diff-modified', + R: 'diff-renamed', + C: 'diff-added', + AA: 'warning', + AU: 'warning', + UA: 'warning', + DD: 'warning', + DU: 'warning', + UD: 'warning', + UU: 'warning', + T: 'diff-modified', + U: 'diff-modified', +}; + +export function getGitFileStatusThemeIcon(status: GitFileStatus): ThemeIcon | undefined { + const codicon = statusCodiconsMap[status]; + return codicon != null ? new ThemeIcon(codicon) : undefined; +} - const statusTextMap = { - '.': 'Unchanged', - '!': 'Ignored', - '?': 'Untracked', - A: 'Added', - D: 'Deleted', - M: 'Modified', - R: 'Renamed', - C: 'Copied', - AA: 'Conflict', - AU: 'Conflict', - UA: 'Conflict', - DD: 'Conflict', - DU: 'Conflict', - UD: 'Conflict', - UU: 'Conflict', - T: 'Modified', - U: 'Updated but Unmerged', - }; - - export function getStatusText(status: GitFileStatus): string { - return statusTextMap[status] ?? 'Unknown'; - } +const statusTextMap = { + '.': 'Unchanged', + '!': 'Ignored', + '?': 'Untracked', + A: 'Added', + D: 'Deleted', + M: 'Modified', + R: 'Renamed', + C: 'Copied', + AA: 'Conflict', + AU: 'Conflict', + UA: 'Conflict', + DD: 'Conflict', + DU: 'Conflict', + UD: 'Conflict', + UU: 'Conflict', + T: 'Modified', + U: 'Updated but Unmerged', +}; + +export function getGitFileStatusText(status: GitFileStatus): string { + return statusTextMap[status] ?? 'Unknown'; } export interface GitFileChangeStats { @@ -174,17 +174,15 @@ export interface GitFileChangeStats { } export interface GitFileChangeShape { + readonly repoPath: string; readonly path: string; - readonly originalPath?: string | undefined; readonly status: GitFileStatus; - readonly repoPath: string; + + readonly originalPath?: string | undefined; + readonly staged?: boolean; } export class GitFileChange implements GitFileChangeShape { - static is(file: any): file is GitFileChange { - return file instanceof GitFileChange; - } - constructor( public readonly repoPath: string, public readonly path: string, @@ -192,6 +190,7 @@ export class GitFileChange implements GitFileChangeShape { public readonly originalPath?: string | undefined, public readonly previousSha?: string | undefined, public readonly stats?: GitFileChangeStats | undefined, + public readonly staged?: boolean, ) {} get hasConflicts() { @@ -269,3 +268,7 @@ export class GitFileChange implements GitFileChangeShape { return status; } } + +export function isGitFileChange(file: any): file is GitFileChange { + return file instanceof GitFileChange; +} diff --git a/src/git/models/graph.ts b/src/git/models/graph.ts index 4565508a98d68..8be2fdb8e4573 100644 --- a/src/git/models/graph.ts +++ b/src/git/models/graph.ts @@ -1,20 +1,33 @@ -import type { GraphRow, Head, Remote, RowContexts, RowStats, Tag } from '@gitkraken/gitkraken-components'; +import type { + GraphRow, + Head, + HostingServiceType, + Remote, + RowContexts, + RowStats, + Tag, +} from '@gitkraken/gitkraken-components'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { Brand, Unbrand } from '../../system/brand'; import type { GitBranch } from './branch'; +import type { GitStashCommit } from './commit'; import type { GitRemote } from './remote'; +import type { GitWorktree } from './worktree'; + +export type GitGraphHostingServiceType = HostingServiceType; export type GitGraphRowHead = Head; export type GitGraphRowRemoteHead = Remote; export type GitGraphRowTag = Tag; export type GitGraphRowContexts = RowContexts; export type GitGraphRowStats = RowStats; -export const enum GitGraphRowType { - Commit = 'commit-node', - MergeCommit = 'merge-node', - Stash = 'stash-node', - Working = 'work-dir-changes', - Conflict = 'merge-conflict-node', - Rebase = 'unsupported-rebase-warning-node', -} +export type GitGraphRowType = + | 'commit-node' + | 'merge-node' + | 'stash-node' + | 'work-dir-changes' + | 'merge-conflict-node' + | 'unsupported-rebase-warning-node'; export interface GitGraphRow extends GraphRow { type: GitGraphRowType; @@ -22,7 +35,6 @@ export interface GitGraphRow extends GraphRow { remotes?: GitGraphRowRemoteHead[]; tags?: GitGraphRowTag[]; contexts?: GitGraphRowContexts; - stats?: GitGraphRowStats; } export interface GitGraph { @@ -32,14 +44,24 @@ export interface GitGraph { /** A set of all "seen" commit ids */ readonly ids: Set; readonly includes: { stats?: boolean } | undefined; - /** A set of all skipped commit ids -- typically for stash index/untracked commits */ - readonly skippedIds?: Set; + /** A set of all remapped commit ids -- typically for stash index/untracked commits + * (key = remapped from id, value = remapped to id) + */ + readonly remappedIds?: Map; readonly branches: Map; readonly remotes: Map; + readonly downstreams: Map; + readonly stashes: Map | undefined; + readonly worktrees: GitWorktree[] | undefined; + readonly worktreesByBranch: Map | undefined; + /** The rows for the set of commits requested */ readonly rows: GitGraphRow[]; readonly id?: string; + readonly rowsStats?: GitGraphRowsStats; + readonly rowsStatsDeferred?: { isLoaded: () => boolean; promise: Promise }; + readonly paging?: { readonly limit: number | undefined; readonly startingCursor: string | undefined; @@ -48,3 +70,46 @@ export interface GitGraph { more?(limit: number, id?: string): Promise; } + +export type GitGraphRowsStats = Map; + +export function convertHostingServiceTypeToGkProviderId(type: GitGraphHostingServiceType): GkProviderId | undefined { + switch (type) { + case 'github': + return 'github' satisfies Unbrand as Brand; + case 'githubEnterprise': + return 'githubEnterprise' satisfies Unbrand as Brand; + case 'gitlab': + return 'gitlab' satisfies Unbrand as Brand; + case 'gitlabSelfHosted': + return 'gitlabSelfHosted' satisfies Unbrand as Brand; + case 'bitbucket': + return 'bitbucket' satisfies Unbrand as Brand; + case 'bitbucketServer': + return 'bitbucketServer' satisfies Unbrand as Brand; + case 'azureDevops': + return 'azureDevops' satisfies Unbrand as Brand; + default: + return undefined; + } +} + +export function getGkProviderThemeIconString( + providerIdOrHostingType: GkProviderId | GitGraphHostingServiceType | undefined, +): string { + switch (providerIdOrHostingType) { + case 'azureDevops': + return 'gitlens-provider-azdo'; + case 'bitbucket': + case 'bitbucketServer': + return 'gitlens-provider-bitbucket'; + case 'github': + case 'githubEnterprise': + return 'gitlens-provider-github'; + case 'gitlab': + case 'gitlabSelfHosted': + return 'gitlens-provider-gitlab'; + default: + return 'cloud'; + } +} diff --git a/src/git/models/issue.ts b/src/git/models/issue.ts index 8ad6c37b9351a..a7ed52499d3f3 100644 --- a/src/git/models/issue.ts +++ b/src/git/models/issue.ts @@ -1,47 +1,57 @@ import { ColorThemeKind, ThemeColor, ThemeIcon, window } from 'vscode'; -import { Colors } from '../../constants'; -import type { RemoteProviderReference } from './remoteProvider'; +import type { Colors } from '../../constants.colors'; +import type { ProviderReference } from './remoteProvider'; -export const enum IssueOrPullRequestType { - Issue = 'Issue', - PullRequest = 'PullRequest', +export type IssueOrPullRequestType = 'issue' | 'pullrequest'; +export type IssueOrPullRequestState = 'opened' | 'closed' | 'merged'; +export enum RepositoryAccessLevel { + Admin = 100, + Maintain = 40, + Write = 30, + Triage = 20, + Read = 10, + None = 0, } export interface IssueOrPullRequest { readonly type: IssueOrPullRequestType; - readonly provider: RemoteProviderReference; + readonly provider: ProviderReference; readonly id: string; + readonly nodeId: string | undefined; readonly title: string; readonly url: string; - readonly date: Date; + readonly createdDate: Date; + readonly updatedDate: Date; readonly closedDate?: Date; readonly closed: boolean; + readonly state: IssueOrPullRequestState; + readonly commentsCount?: number; + readonly thumbsUpCount?: number; } export interface IssueLabel { - color: string; + color?: string; name: string; } export interface IssueMember { + id: string; name: string; - avatarUrl: string; - url: string; + avatarUrl?: string; + url?: string; } export interface IssueRepository { owner: string; repo: string; + accessLevel?: RepositoryAccessLevel; } export interface IssueShape extends IssueOrPullRequest { - updatedDate: Date; author: IssueMember; assignees: IssueMember[]; - repository: IssueRepository; + repository?: IssueRepository; labels?: IssueLabel[]; - commentsCount?: number; - thumbsUpCount?: number; } export interface SearchedIssue { @@ -59,28 +69,43 @@ export function serializeIssueOrPullRequest(value: IssueOrPullRequest): IssueOrP icon: value.provider.icon, }, id: value.id, + nodeId: value.nodeId, title: value.title, url: value.url, - date: value.date, + createdDate: value.createdDate, + updatedDate: value.updatedDate, closedDate: value.closedDate, closed: value.closed, + state: value.state, }; return serialized; } -export namespace IssueOrPullRequest { - export function getHtmlIcon(issue: IssueOrPullRequest): string { - if (issue.type === IssueOrPullRequestType.PullRequest) { - if (issue.closed) { - return ``; + } + + if (issue.type === 'pullrequest') { + switch (issue.state) { + case 'merged': + return ``; - } - return ``; + case 'closed': + return ``; + case 'opened': + return ``; + default: + return ``; } - + } else { if (issue.closed) { return ``; } +} + +export function getIssueOrPullRequestMarkdownIcon(issue?: IssueOrPullRequest): string { + if (issue == null) { + return `$(link)`; + } - export function getMarkdownIcon(issue: IssueOrPullRequest): string { - if (issue.type === IssueOrPullRequestType.PullRequest) { - if (issue.closed) { + if (issue.type === 'pullrequest') { + switch (issue.state) { + case 'merged': return `$(git-merge)`; + case 'closed': + return `$(git-pull-request-closed)`; + case 'opened': + return `$(git-pull-request)`; - } - return `$(git-pull-request)`; + default: + return `$(git-pull-request)`; } - + } else { if (issue.closed) { return `$(issues)`; } +} - export function getThemeIcon(issue: IssueOrPullRequest): ThemeIcon { - if (issue.type === IssueOrPullRequestType.PullRequest) { - if (issue.closed) { - return new ThemeIcon('git-pull-request', new ThemeColor(Colors.MergedPullRequestIconColor)); - } - return new ThemeIcon('git-pull-request', new ThemeColor(Colors.OpenPullRequestIconColor)); - } +export function getIssueOrPullRequestThemeIcon(issue?: IssueOrPullRequest): ThemeIcon { + if (issue == null) { + return new ThemeIcon('link', new ThemeColor('gitlens.closedAutolinkedIssueIconColor' satisfies Colors)); + } + if (issue.type === 'pullrequest') { + switch (issue.state) { + case 'merged': + return new ThemeIcon( + 'git-merge', + new ThemeColor('gitlens.mergedPullRequestIconColor' satisfies Colors), + ); + case 'closed': + return new ThemeIcon( + 'git-pull-request-closed', + new ThemeColor('gitlens.closedPullRequestIconColor' satisfies Colors), + ); + case 'opened': + return new ThemeIcon( + 'git-pull-request', + new ThemeColor('gitlens.openPullRequestIconColor' satisfies Colors), + ); + default: + return new ThemeIcon('git-pull-request'); + } + } else { if (issue.closed) { - return new ThemeIcon('pass', new ThemeColor(Colors.ClosedAutolinkedIssueIconColor)); + return new ThemeIcon('pass', new ThemeColor('gitlens.closedAutolinkedIssueIconColor' satisfies Colors)); } - return new ThemeIcon('issues', new ThemeColor(Colors.OpenAutolinkedIssueIconColor)); + return new ThemeIcon('issues', new ThemeColor('gitlens.openAutolinkedIssueIconColor' satisfies Colors)); } } @@ -138,22 +196,29 @@ export function serializeIssue(value: IssueShape): IssueShape { icon: value.provider.icon, }, id: value.id, + nodeId: value.nodeId, title: value.title, url: value.url, - date: value.date, + createdDate: value.createdDate, + updatedDate: value.updatedDate, closedDate: value.closedDate, closed: value.closed, - updatedDate: value.updatedDate, + state: value.state, author: { + id: value.author.id, name: value.author.name, avatarUrl: value.author.avatarUrl, url: value.author.url, }, - repository: { - owner: value.repository.owner, - repo: value.repository.repo, - }, + repository: + value.repository == null + ? undefined + : { + owner: value.repository.owner, + repo: value.repository.repo, + }, assignees: value.assignees.map(assignee => ({ + id: assignee.id, name: assignee.name, avatarUrl: assignee.avatarUrl, url: assignee.url, @@ -172,16 +237,18 @@ export function serializeIssue(value: IssueShape): IssueShape { } export class Issue implements IssueShape { - readonly type = IssueOrPullRequestType.Issue; + readonly type = 'issue'; constructor( - public readonly provider: RemoteProviderReference, + public readonly provider: ProviderReference, public readonly id: string, + public readonly nodeId: string | undefined, public readonly title: string, public readonly url: string, - public readonly date: Date, - public readonly closed: boolean, + public readonly createdDate: Date, public readonly updatedDate: Date, + public readonly closed: boolean, + public readonly state: IssueOrPullRequestState, public readonly author: IssueMember, public readonly repository: IssueRepository, public readonly assignees: IssueMember[], diff --git a/src/git/models/patch.ts b/src/git/models/patch.ts new file mode 100644 index 0000000000000..ad9319f8ad036 --- /dev/null +++ b/src/git/models/patch.ts @@ -0,0 +1,27 @@ +import type { Uri } from 'vscode'; +import type { GitCommit } from './commit'; +import type { GitDiffFiles } from './diff'; +import type { Repository } from './repository'; + +/** + * For a single commit `to` is the commit SHA and `from` is its parent `^` + * For a commit range `to` is the tip SHA and `from` is the base SHA + * For a WIP `to` is the "uncommitted" SHA and `from` is the current HEAD SHA + */ +export interface PatchRevisionRange { + from: string; + to: string; +} + +export interface GitPatch { + readonly type: 'local'; + readonly contents: string; + + readonly id?: undefined; + readonly uri?: Uri; + + baseRef?: string; + commit?: GitCommit; + files?: GitDiffFiles['files']; + repository?: Repository; +} diff --git a/src/git/models/pullRequest.ts b/src/git/models/pullRequest.ts index 25c622188f742..d0370474069f4 100644 --- a/src/git/models/pullRequest.ts +++ b/src/git/models/pullRequest.ts @@ -1,18 +1,16 @@ -import { ColorThemeKind, ThemeColor, ThemeIcon, window } from 'vscode'; -import { DateStyle } from '../../configuration'; -import { Colors } from '../../constants'; +import { Uri, window } from 'vscode'; +import { Schemes } from '../../constants'; import { Container } from '../../container'; +import type { RepositoryIdentityDescriptor } from '../../gk/models/repositoryIdentities'; import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; -import type { IssueOrPullRequest } from './issue'; -import { IssueOrPullRequestType } from './issue'; -import type { RemoteProviderReference } from './remoteProvider'; +import type { LeftRightCommitCountResult } from '../gitProvider'; +import type { IssueOrPullRequest, IssueRepository, IssueOrPullRequestState as PullRequestState } from './issue'; +import { createRevisionRange, shortenRevision } from './reference'; +import type { ProviderReference } from './remoteProvider'; +import type { Repository } from './repository'; -export const enum PullRequestState { - Open = 'Open', - Closed = 'Closed', - Merged = 'Merged', -} +export type { PullRequestState }; export const enum PullRequestReviewDecision { Approved = 'Approved', @@ -26,12 +24,34 @@ export const enum PullRequestMergeableState { Conflicting = 'Conflicting', } +export const enum PullRequestStatusCheckRollupState { + Success = 'success', + Pending = 'pending', + Failed = 'failed', +} + +export const enum PullRequestMergeMethod { + Merge = 'merge', + Squash = 'squash', + Rebase = 'rebase', +} + +export const enum PullRequestReviewState { + Approved = 'APPROVED', + ChangesRequested = 'CHANGES_REQUESTED', + Commented = 'COMMENTED', + Dismissed = 'DISMISSED', + Pending = 'PENDING', + ReviewRequested = 'REVIEW_REQUESTED', +} + export interface PullRequestRef { owner: string; repo: string; branch: string; sha: string; exists: boolean; + url: string; } export interface PullRequestRefs { @@ -41,25 +61,25 @@ export interface PullRequestRefs { } export interface PullRequestMember { + id: string; name: string; - avatarUrl: string; - url: string; + avatarUrl?: string; + url?: string; } export interface PullRequestReviewer { - isCodeOwner: boolean; + isCodeOwner?: boolean; reviewer: PullRequestMember; + state: PullRequestReviewState; } export interface PullRequestShape extends IssueOrPullRequest { readonly author: PullRequestMember; - readonly state: PullRequestState; readonly mergedDate?: Date; readonly refs?: PullRequestRefs; readonly isDraft?: boolean; readonly additions?: number; readonly deletions?: number; - readonly comments?: number; readonly mergeableState?: PullRequestMergeableState; readonly reviewDecision?: PullRequestReviewDecision; readonly reviewRequests?: PullRequestReviewer[]; @@ -81,12 +101,15 @@ export function serializePullRequest(value: PullRequest): PullRequestShape { icon: value.provider.icon, }, id: value.id, + nodeId: value.nodeId, title: value.title, url: value.url, - date: value.date, + createdDate: value.createdDate, + updatedDate: value.updatedDate, closedDate: value.closedDate, closed: value.closed, author: { + id: value.author.id, name: value.author.name, avatarUrl: value.author.avatarUrl, url: value.author.url, @@ -102,6 +125,7 @@ export function serializePullRequest(value: PullRequest): PullRequestShape { repo: value.refs.head.repo, sha: value.refs.head.sha, branch: value.refs.head.branch, + url: value.refs.head.url, }, base: { exists: value.refs.base.exists, @@ -109,6 +133,7 @@ export function serializePullRequest(value: PullRequest): PullRequestShape { repo: value.refs.base.repo, sha: value.refs.base.sha, branch: value.refs.base.branch, + url: value.refs.base.url, }, isCrossRepository: value.refs.isCrossRepository, } @@ -116,7 +141,8 @@ export function serializePullRequest(value: PullRequest): PullRequestShape { isDraft: value.isDraft, additions: value.additions, deletions: value.deletions, - comments: value.comments, + commentsCount: value.commentsCount, + thumbsUpCount: value.thumbsUpCount, reviewDecision: value.reviewDecision, reviewRequests: value.reviewRequests, assignees: value.assignees, @@ -125,86 +151,58 @@ export function serializePullRequest(value: PullRequest): PullRequestShape { } export class PullRequest implements PullRequestShape { - static is(pr: any): pr is PullRequest { - return pr instanceof PullRequest; - } - - static getMarkdownIcon(pullRequest: PullRequest): string { - switch (pullRequest.state) { - case PullRequestState.Open: - return `$(git-pull-request)`; - case PullRequestState.Closed: - return `$(git-pull-request-closed)`; - case PullRequestState.Merged: - return `$(git-merge)`; - default: - return '$(git-pull-request)'; - } - } - - static getThemeIcon(pullRequest: PullRequest): ThemeIcon { - switch (pullRequest.state) { - case PullRequestState.Open: - return new ThemeIcon('git-pull-request', new ThemeColor(Colors.OpenPullRequestIconColor)); - case PullRequestState.Closed: - return new ThemeIcon('git-pull-request-closed', new ThemeColor(Colors.ClosedPullRequestIconColor)); - case PullRequestState.Merged: - return new ThemeIcon('git-merge', new ThemeColor(Colors.MergedPullRequestIconColor)); - default: - return new ThemeIcon('git-pull-request'); - } - } - - readonly type = IssueOrPullRequestType.PullRequest; + readonly type = 'pullrequest'; constructor( - public readonly provider: RemoteProviderReference, + public readonly provider: ProviderReference, public readonly author: { + readonly id: string; readonly name: string; - readonly avatarUrl: string; - readonly url: string; + readonly avatarUrl?: string; + readonly url?: string; }, public readonly id: string, + public readonly nodeId: string | undefined, public readonly title: string, public readonly url: string, + public readonly repository: IssueRepository, public readonly state: PullRequestState, - public readonly date: Date, + public readonly createdDate: Date, + public readonly updatedDate: Date, public readonly closedDate?: Date, public readonly mergedDate?: Date, public readonly mergeableState?: PullRequestMergeableState, + public readonly viewerCanUpdate?: boolean, public readonly refs?: PullRequestRefs, public readonly isDraft?: boolean, public readonly additions?: number, public readonly deletions?: number, - public readonly comments?: number, + public readonly commentsCount?: number, + public readonly thumbsUpCount?: number, public readonly reviewDecision?: PullRequestReviewDecision, public readonly reviewRequests?: PullRequestReviewer[], + public readonly latestReviews?: PullRequestReviewer[], public readonly assignees?: PullRequestMember[], + public readonly statusCheckRollupState?: PullRequestStatusCheckRollupState, ) {} get closed(): boolean { - return this.state === PullRequestState.Closed; + return this.state === 'closed'; } get formattedDate(): string { - return Container.instance.PullRequestDateFormatting.dateStyle === DateStyle.Absolute + return Container.instance.PullRequestDateFormatting.dateStyle === 'absolute' ? this.formatDate(Container.instance.PullRequestDateFormatting.dateFormat) : this.formatDateFromNow(); } @memoize(format => format ?? 'MMMM Do, YYYY h:mma') formatDate(format?: string | null) { - return formatDate(this.mergedDate ?? this.closedDate ?? this.date, format ?? 'MMMM Do, YYYY h:mma'); + return formatDate(this.mergedDate ?? this.closedDate ?? this.updatedDate, format ?? 'MMMM Do, YYYY h:mma'); } formatDateFromNow() { - return fromNow(this.mergedDate ?? this.closedDate ?? this.date); + return fromNow(this.mergedDate ?? this.closedDate ?? this.updatedDate); } @memoize(format => format ?? 'MMMM Do, YYYY h:mma') @@ -231,10 +229,189 @@ export class PullRequest implements PullRequestShape { @memoize(format => format ?? 'MMMM Do, YYYY h:mma') formatUpdatedDate(format?: string | null) { - return formatDate(this.date, format ?? 'MMMM Do, YYYY h:mma') ?? ''; + return formatDate(this.updatedDate, format ?? 'MMMM Do, YYYY h:mma') ?? ''; } formatUpdatedDateFromNow() { - return fromNow(this.date); + return fromNow(this.updatedDate); + } +} + +export function isPullRequest(pr: any): pr is PullRequest { + return pr instanceof PullRequest; +} + +export interface PullRequestComparisonRefs { + repoPath: string; + base: { ref: string; label: string }; + head: { ref: string; label: string }; +} + +export function getComparisonRefsForPullRequest(repoPath: string, prRefs: PullRequestRefs): PullRequestComparisonRefs { + const refs: PullRequestComparisonRefs = { + repoPath: repoPath, + base: { ref: prRefs.base.sha, label: `${prRefs.base.branch} (${shortenRevision(prRefs.base.sha)})` }, + head: { ref: prRefs.head.sha, label: prRefs.head.branch }, + }; + return refs; +} + +export type PullRequestRepositoryIdentityDescriptor = RequireSomeWithProps< + RequireSome, 'provider'>, + 'provider', + 'id' | 'domain' | 'repoDomain' | 'repoName' +> & + RequireSomeWithProps, 'remote'>, 'remote', 'domain'>; + +export function getRepositoryIdentityForPullRequest( + pr: PullRequest, + headRepo: boolean = true, +): PullRequestRepositoryIdentityDescriptor { + if (headRepo) { + return { + remote: { + url: pr.refs?.head?.url, + domain: pr.provider.domain, + }, + name: `${pr.refs?.head?.owner ?? pr.repository.owner}/${pr.refs?.head?.repo ?? pr.repository.repo}`, + provider: { + id: pr.provider.id, + domain: pr.provider.domain, + repoDomain: pr.refs?.head?.owner ?? pr.repository.owner, + repoName: pr.refs?.head?.repo ?? pr.repository.repo, + }, + }; + } + + return { + remote: { + url: pr.refs?.base?.url ?? pr.url, + domain: pr.provider.domain, + }, + name: `${pr.refs?.base?.owner ?? pr.repository.owner}/${pr.refs?.base?.repo ?? pr.repository.repo}`, + provider: { + id: pr.provider.id, + domain: pr.provider.domain, + repoDomain: pr.refs?.base?.owner ?? pr.repository.owner, + repoName: pr.refs?.base?.repo ?? pr.repository.repo, + repoOwnerDomain: pr.refs?.base?.owner ?? pr.repository.owner, + }, + }; +} + +export function getVirtualUriForPullRequest(pr: PullRequest): Uri | undefined { + if (pr.provider.id !== 'github') return undefined; + + const uri = Uri.parse(pr.refs?.base?.url ?? pr.url); + return uri.with({ scheme: Schemes.Virtual, authority: 'github', path: uri.path }); +} + +export async function getOrOpenPullRequestRepository( + container: Container, + pr: PullRequest, + options?: { promptIfNeeded?: boolean }, +): Promise { + const identity = getRepositoryIdentityForPullRequest(pr); + let repo = await container.repositoryIdentity.getRepository(identity, { + openIfNeeded: true, + keepOpen: false, + prompt: false, + }); + + if (repo == null) { + const virtualUri = getVirtualUriForPullRequest(pr); + if (virtualUri != null) { + repo = await container.git.getOrOpenRepository(virtualUri, { closeOnOpen: true, detectNested: false }); + } + } + + if (repo == null) { + const baseIdentity = getRepositoryIdentityForPullRequest(pr, false); + repo = await container.repositoryIdentity.getRepository(baseIdentity, { + openIfNeeded: true, + keepOpen: false, + prompt: false, + }); + } + + if (repo == null && options?.promptIfNeeded) { + repo = await container.repositoryIdentity.getRepository(identity, { + openIfNeeded: true, + keepOpen: false, + prompt: true, + }); + } + + return repo; +} + +export async function ensurePullRequestRefs( + container: Container, + pr: PullRequest, + repo: Repository, + options?: { promptMessage?: string }, + refs?: PullRequestComparisonRefs, +): Promise { + if (pr.refs == null) return undefined; + + refs ??= getComparisonRefsForPullRequest(repo.path, pr.refs); + const range = createRevisionRange(refs.base.ref, refs.head.ref, '...'); + let counts = await container.git.getLeftRightCommitCount(repo.path, range); + if (counts == null) { + if (await ensurePullRequestRemote(pr, repo, options)) { + counts = await container.git.getLeftRightCommitCount(repo.path, range); + } + } + + return counts; +} + +export async function ensurePullRequestRemote( + pr: PullRequest, + repo: Repository, + options?: { promptMessage?: string }, +): Promise { + const identity = getRepositoryIdentityForPullRequest(pr); + if (identity.remote.url == null) return false; + + const prRemoteUrl = identity.remote.url.replace(/\.git$/, ''); + + let found = false; + for (const remote of await repo.getRemotes()) { + if (remote.matches(prRemoteUrl)) { + found = true; + break; + } } + + if (found) return true; + + const confirm = { title: 'Add Remote' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showInformationMessage( + `${ + options?.promptMessage ?? `Unable to find a remote for PR #${pr.id}.` + }\nWould you like to add a remote for '${identity.provider.repoDomain}?`, + { modal: true }, + confirm, + cancel, + ); + + if (result === confirm) { + await repo.addRemote(identity.provider.repoDomain, identity.remote.url, { fetch: true }); + return true; + } + + return false; +} + +export async function getOpenedPullRequestRepo( + container: Container, + pr: PullRequest, + repoPath?: string, +): Promise { + if (repoPath) return container.git.getRepository(repoPath); + + const repo = await getOrOpenPullRequestRepository(container, pr, { promptIfNeeded: true }); + return repo; } diff --git a/src/git/models/rebase.ts b/src/git/models/rebase.ts index 81bf4d3b8a035..ccb8e59bb0d01 100644 --- a/src/git/models/rebase.ts +++ b/src/git/models/rebase.ts @@ -1,4 +1,4 @@ -import type { GitBranchReference, GitRevisionReference } from './reference'; +import type { GitBranchReference, GitRevisionReference, GitTagReference } from './reference'; export interface GitRebaseStatus { type: 'rebase'; @@ -6,11 +6,11 @@ export interface GitRebaseStatus { HEAD: GitRevisionReference; onto: GitRevisionReference; mergeBase: string | undefined; - current: GitBranchReference | undefined; + current: GitBranchReference | GitTagReference | undefined; incoming: GitBranchReference; steps: { - current: { number: number; commit: GitRevisionReference }; + current: { number: number; commit: GitRevisionReference | undefined }; total: number; }; } diff --git a/src/git/models/reference.ts b/src/git/models/reference.ts index 62dd429f2a0e4..123085b2a0583 100644 --- a/src/git/models/reference.ts +++ b/src/git/models/reference.ts @@ -1,8 +1,16 @@ -import { configuration } from '../../configuration'; import { GlyphChars } from '../../constants'; -import { getBranchNameWithoutRemote, getRemoteNameFromBranchName, splitBranchNameAndRemote } from './branch'; - -const rangeRegex = /^(\S*?)(\.\.\.?)(\S*)\s*$/; +import { capitalize } from '../../system/string'; +import { configuration } from '../../system/vscode/configuration'; +import type { GitBranch } from './branch'; +import { getBranchNameWithoutRemote, getRemoteNameFromBranchName, getRemoteNameSlashIndex } from './branch'; +import type { GitCommit, GitStashCommit } from './commit'; +import { deletedOrMissing, uncommitted, uncommittedStaged } from './constants'; +import type { GitTag } from './tag'; + +const rangeRegex = /^([\w\-/]+(?:\.[\w\-/]+)*)?(\.\.\.?)([\w\-/]+(?:\.[\w\-/]+)*)?$/; +const qualifiedRangeRegex = /^([\w\-/]+(?:\.[\w\-/]+)*)(\.\.\.?)([\w\-/]+(?:\.[\w\-/]+)*)$/; +const qualifiedDoubleDotRange = /^([\w\-/]+(?:\.[\w\-/]+)*)(\.\.)([\w\-/]+(?:\.[\w\-/]+)*)$/; +const qualifiedTripleDotRange = /^([\w\-/]+(?:\.[\w\-/]+)*)(\.\.\.)([\w\-/]+(?:\.[\w\-/]+)*)$/; const shaLikeRegex = /(^[0-9a-f]{40}([\^@~:]\S*)?$)|(^[0]{40}(:|-)$)/; const shaRegex = /(^[0-9a-f]{40}$)|(^[0]{40}(:|-)$)/; const shaParentRegex = /(^[0-9a-f]{40})\^[0-3]?$/; @@ -10,97 +18,112 @@ const shaShortenRegex = /^(.*?)([\^@~:].*)?$/; const uncommittedRegex = /^[0]{40}(?:[\^@~:]\S*)?:?$/; const uncommittedStagedRegex = /^[0]{40}([\^@~]\S*)?:$/; +export type GitRevisionRange = + | `${'..' | '...'}${string}` + | `${string}${'..' | '...'}` + | `${string}${'..' | '...'}${string}`; + function isMatch(regex: RegExp, ref: string | undefined) { return !ref ? false : regex.test(ref); } -export namespace GitRevision { - export const deletedOrMissing = '0000000000000000000000000000000000000000-'; - export const uncommitted = '0000000000000000000000000000000000000000'; - export const uncommittedStaged = '0000000000000000000000000000000000000000:'; - - export function createRange( - ref1: string | undefined, - ref2: string | undefined, - notation: '..' | '...' = '..', - ): string { - return `${ref1 ?? ''}${notation}${ref2 ?? ''}`; - } +export function createRevisionRange( + left: string | undefined, + right: string | undefined, + notation: '..' | '...', +): GitRevisionRange { + return `${left ?? ''}${notation}${right ?? ''}`; +} - export function isRange(ref: string | undefined) { - return ref?.includes('..') ?? false; - } +export function getRevisionRangeParts( + ref: GitRevisionRange, +): { left: string | undefined; right: string | undefined; notation: '..' | '...' } | undefined { + const match = rangeRegex.exec(ref); + if (match == null) return undefined; + + const [, left, notation, right] = match; + return { + left: left || undefined, + right: right || undefined, + notation: notation as '..' | '...', + }; +} - export function isSha(ref: string) { - return isMatch(shaRegex, ref); +export function isRevisionRange( + ref: string | undefined, + rangeType: 'any' | 'qualified' | 'qualified-double-dot' | 'qualified-triple-dot' = 'any', +): ref is GitRevisionRange { + if (ref == null) return false; + + switch (rangeType) { + case 'qualified': + return qualifiedRangeRegex.test(ref); + case 'qualified-double-dot': + return qualifiedDoubleDotRange.test(ref); + case 'qualified-triple-dot': + return qualifiedTripleDotRange.test(ref); + default: + return rangeRegex.test(ref); } +} - export function isShaLike(ref: string) { - return isMatch(shaLikeRegex, ref); - } +export function isSha(ref: string) { + return isMatch(shaRegex, ref); +} - export function isShaParent(ref: string) { - return isMatch(shaParentRegex, ref); - } +export function isShaLike(ref: string) { + return isMatch(shaLikeRegex, ref); +} - export function isUncommitted(ref: string | undefined, exact: boolean = false) { - return ref === uncommitted || ref === uncommittedStaged || (!exact && isMatch(uncommittedRegex, ref)); - } +export function isShaParent(ref: string) { + return isMatch(shaParentRegex, ref); +} - export function isUncommittedParent(ref: string | undefined) { - return ref === `${uncommitted}^` || ref === `${uncommittedStaged}^`; - } +export function isUncommitted(ref: string | undefined, exact: boolean = false) { + return ref === uncommitted || ref === uncommittedStaged || (!exact && isMatch(uncommittedRegex, ref)); +} - export function isUncommittedStaged(ref: string | undefined, exact: boolean = false): boolean { - return ref === uncommittedStaged || (!exact && isMatch(uncommittedStagedRegex, ref)); - } +export function isUncommittedParent(ref: string | undefined) { + return ref === `${uncommitted}^` || ref === `${uncommittedStaged}^`; +} - export function shorten( - ref: string | undefined, - options?: { - force?: boolean; - strings?: { uncommitted?: string; uncommittedStaged?: string; working?: string }; - }, - ) { - if (ref === deletedOrMissing) return '(deleted)'; - - if (!ref) return options?.strings?.working ?? ''; - if (isUncommitted(ref)) { - return isUncommittedStaged(ref) - ? options?.strings?.uncommittedStaged ?? 'Index' - : options?.strings?.uncommitted ?? 'Working Tree'; - } +export function isUncommittedStaged(ref: string | undefined, exact: boolean = false): boolean { + return ref === uncommittedStaged || (!exact && isMatch(uncommittedStagedRegex, ref)); +} - if (GitRevision.isRange(ref)) return ref; - if (!options?.force && !isShaLike(ref)) return ref; +export function shortenRevision( + ref: string | undefined, + options?: { + force?: boolean; + strings?: { uncommitted?: string; uncommittedStaged?: string; working?: string }; + }, +) { + if (ref === deletedOrMissing) return '(deleted)'; + + if (!ref) return options?.strings?.working ?? ''; + if (isUncommitted(ref)) { + return isUncommittedStaged(ref) + ? options?.strings?.uncommittedStaged ?? 'Index' + : options?.strings?.uncommitted ?? 'Working Tree'; + } - // Don't allow shas to be shortened to less than 5 characters - const len = Math.max(5, configuration.get('advanced.abbreviatedShaLength')); + if (isRevisionRange(ref)) return ref; + if (!options?.force && !isShaLike(ref)) return ref; - // If we have a suffix, append it - const match = shaShortenRegex.exec(ref); - if (match != null) { - const [, rev, suffix] = match; + // Don't allow shas to be shortened to less than 5 characters + const len = Math.max(5, configuration.get('advanced.abbreviatedShaLength')); - if (suffix != null) { - return `${rev.substr(0, len)}${suffix}`; - } - } + // If we have a suffix, append it + const match = shaShortenRegex.exec(ref); + if (match != null) { + const [, rev, suffix] = match; - return ref.substr(0, len); + if (suffix != null) { + return `${rev.substring(0, len)}${suffix}`; + } } - export function splitRange(ref: string): { ref1: string; ref2: string; notation: '..' | '...' } | undefined { - const match = rangeRegex.exec(ref); - if (match == null) return undefined; - - const [, ref1, notation, ref2] = match; - return { - ref1: ref1, - notation: notation as '..' | '...', - ref2: ref2, - }; - } + return ref.substring(0, len); } export interface GitBranchReference { @@ -108,6 +131,7 @@ export interface GitBranchReference { id?: string; name: string; ref: string; + sha?: string; readonly remote: boolean; readonly upstream?: { name: string; missing: boolean }; repoPath: string; @@ -118,6 +142,7 @@ export interface GitRevisionReference { id?: undefined; name: string; ref: string; + sha: string; repoPath: string; number?: string | undefined; @@ -129,10 +154,12 @@ export interface GitStashReference { id?: undefined; name: string; ref: string; + sha: string; repoPath: string; - number: string | undefined; + number: string; message?: string | undefined; + stashOnRef?: string | undefined; } export interface GitTagReference { @@ -140,254 +167,286 @@ export interface GitTagReference { id?: string; name: string; ref: string; + sha?: string; repoPath: string; } export type GitReference = GitBranchReference | GitRevisionReference | GitStashReference | GitTagReference; -export namespace GitReference { - export function create( - ref: string, - repoPath: string, - options: { - refType: 'branch'; - name: string; - id?: string; - remote: boolean; - upstream?: { name: string; missing: boolean }; - }, - ): GitBranchReference; - export function create( - ref: string, - repoPath: string, - options?: { refType: 'revision'; name?: string; message?: string }, - ): GitRevisionReference; - export function create( - ref: string, - repoPath: string, - options: { refType: 'stash'; name: string; number: string | undefined; message?: string }, - ): GitStashReference; - export function create( - ref: string, - repoPath: string, - options: { refType: 'tag'; name: string; id?: string }, - ): GitTagReference; - export function create( - ref: string, - repoPath: string, - options: - | { - id?: string; - refType: 'branch'; - name: string; - remote: boolean; - upstream?: { name: string; missing: boolean }; - } - | { refType?: 'revision'; name?: string; message?: string } - | { refType: 'stash'; name: string; number: string | undefined; message?: string } - | { id?: string; refType: 'tag'; name: string } = { refType: 'revision' }, - ): GitReference { - switch (options.refType) { - case 'branch': - return { - refType: 'branch', - repoPath: repoPath, - ref: ref, - name: options.name, - id: options.id, - remote: options.remote, - upstream: options.upstream, - }; - case 'stash': - return { - refType: 'stash', - repoPath: repoPath, - ref: ref, - name: options.name, - number: options.number, - message: options.message, - }; - case 'tag': - return { - refType: 'tag', - repoPath: repoPath, - ref: ref, - name: options.name, - id: options.id, - }; - default: - return { - refType: 'revision', - repoPath: repoPath, - ref: ref, - name: - options.name ?? GitRevision.shorten(ref, { force: true, strings: { working: 'Working Tree' } }), - message: options.message, - }; - } - } - - export function fromBranch(branch: GitBranchReference) { - return create(branch.ref, branch.repoPath, { - id: branch.id, - refType: branch.refType, - name: branch.name, - remote: branch.remote, - upstream: branch.upstream, - }); +export function createReference( + ref: string, + repoPath: string, + options: { + refType: 'branch'; + name: string; + id?: string; + remote: boolean; + upstream?: { name: string; missing: boolean }; + }, +): GitBranchReference; +export function createReference( + ref: string, + repoPath: string, + options?: { refType: 'revision'; name?: string; message?: string }, +): GitRevisionReference; +export function createReference( + ref: string, + repoPath: string, + options: { refType: 'stash'; name: string; number: string | undefined; message?: string; stashOnRef?: string }, +): GitStashReference; +export function createReference( + ref: string, + repoPath: string, + options: { refType: 'tag'; name: string; id?: string }, +): GitTagReference; +export function createReference( + ref: string, + repoPath: string, + options: + | { + id?: string; + refType: 'branch'; + name: string; + remote: boolean; + upstream?: { name: string; missing: boolean }; + } + | { refType?: 'revision'; name?: string; message?: string } + | { refType: 'stash'; name: string; number: string | undefined; message?: string; stashOnRef?: string } + | { id?: string; refType: 'tag'; name: string } = { refType: 'revision' }, +): GitReference { + switch (options.refType) { + case 'branch': + return { + refType: 'branch', + repoPath: repoPath, + ref: ref, + name: options.name, + id: options.id, + remote: options.remote, + upstream: options.upstream, + }; + case 'stash': + return { + refType: 'stash', + repoPath: repoPath, + ref: ref, + sha: ref, + name: options.name, + number: options.number, + message: options.message, + stashOnRef: options.stashOnRef, + }; + case 'tag': + return { + refType: 'tag', + repoPath: repoPath, + ref: ref, + name: options.name, + id: options.id, + }; + default: + return { + refType: 'revision', + repoPath: repoPath, + ref: ref, + sha: ref, + name: options.name ?? shortenRevision(ref, { force: true, strings: { working: 'Working Tree' } }), + message: options.message, + }; } +} - export function fromRevision(revision: GitRevisionReference) { - if (revision.refType === 'stash') { - return create(revision.ref, revision.repoPath, { - refType: revision.refType, - name: revision.name, - number: revision.number, - message: revision.message, - }); - } +export function getReferenceFromBranch(branch: GitBranch) { + return createReference(branch.ref, branch.repoPath, { + id: branch.id, + refType: branch.refType, + name: branch.name, + remote: branch.remote, + upstream: branch.upstream, + }); +} - return create(revision.ref, revision.repoPath, { +export function getReferenceFromRevision( + revision: GitCommit | GitStashCommit | GitRevisionReference, + options?: { excludeMessage?: boolean }, +) { + if (revision.refType === 'stash') { + return createReference(revision.ref, revision.repoPath, { refType: revision.refType, name: revision.name, - message: revision.message, + number: revision.number, + message: options?.excludeMessage ? undefined : revision.message, }); } - export function fromTag(tag: GitTagReference) { - return create(tag.ref, tag.repoPath, { - id: tag.id, - refType: tag.refType, - name: tag.name, - }); - } + return createReference(revision.ref, revision.repoPath, { + refType: revision.refType, + name: revision.name, + message: options?.excludeMessage ? undefined : revision.message, + }); +} - export function getNameWithoutRemote(ref: GitReference) { - if (ref.refType === 'branch') { - return ref.remote ? getBranchNameWithoutRemote(ref.name) : ref.name; - } - return ref.name; - } +export function getReferenceFromTag(tag: GitTag) { + return createReference(tag.ref, tag.repoPath, { + id: tag.id, + refType: tag.refType, + name: tag.name, + }); +} - export function isBranch(ref: GitReference | undefined): ref is GitBranchReference { - return ref?.refType === 'branch'; +export function getNameWithoutRemote(ref: GitReference) { + if (ref.refType === 'branch') { + return ref.remote ? getBranchNameWithoutRemote(ref.name) : ref.name; } + return ref.name; +} - export function isRevision(ref: GitReference | undefined): ref is GitRevisionReference { - return ref?.refType === 'revision'; - } +export function getBranchTrackingWithoutRemote(ref: GitBranchReference) { + return ref.upstream?.name.substring(getRemoteNameSlashIndex(ref.upstream.name) + 1); +} - export function isRevisionRange(ref: GitReference | undefined): ref is GitRevisionReference { - return ref?.refType === 'revision' && GitRevision.isRange(ref.ref); - } +export function isGitReference(ref: unknown): ref is GitReference { + if (ref == null || typeof ref !== 'object') return false; - export function isStash(ref: GitReference | undefined): ref is GitStashReference { - return ref?.refType === 'stash' || (ref?.refType === 'revision' && Boolean((ref as any)?.stashName)); - } + const r = ref as GitReference; + return ( + typeof r.refType === 'string' && + typeof r.repoPath === 'string' && + typeof r.ref === 'string' && + typeof r.name === 'string' + ); +} + +export function isBranchReference(ref: GitReference | undefined): ref is GitBranchReference { + return ref?.refType === 'branch'; +} - export function isTag(ref: GitReference | undefined): ref is GitTagReference { - return ref?.refType === 'tag'; +export function isRevisionReference(ref: GitReference | undefined): ref is GitRevisionReference { + return ref?.refType === 'revision'; +} + +export function isRevisionRangeReference(ref: GitReference | undefined): ref is GitRevisionReference { + return ref?.refType === 'revision' && isRevisionRange(ref.ref); +} + +export function isStashReference(ref: GitReference | undefined): ref is GitStashReference { + return ref?.refType === 'stash' || (ref?.refType === 'revision' && Boolean((ref as any)?.stashName)); +} + +export function isTagReference(ref: GitReference | undefined): ref is GitTagReference { + return ref?.refType === 'tag'; +} + +export function getReferenceTypeLabel(ref: GitReference | undefined) { + switch (ref?.refType) { + case 'branch': + return 'Branch'; + case 'tag': + return 'Tag'; + default: + return 'Commit'; } +} - export function toString( - refs: GitReference | GitReference[] | undefined, - options?: { capitalize?: boolean; expand?: boolean; icon?: boolean; label?: boolean; quoted?: boolean } | false, - ) { - if (refs == null) return ''; - - options = - options === false - ? {} - : { expand: true, icon: true, label: options?.label ?? options?.expand ?? true, ...options }; - - let result; - if (!Array.isArray(refs) || refs.length === 1) { - const ref = Array.isArray(refs) ? refs[0] : refs; - let refName = options?.quoted ? `'${ref.name}'` : ref.name; - switch (ref.refType) { - case 'branch': - if (ref.remote) { - refName = `${getRemoteNameFromBranchName(refName)}: ${getBranchNameWithoutRemote(refName)}`; - refName = options?.quoted ? `'${refName}'` : refName; - } +export function getReferenceLabel( + refs: GitReference | GitReference[] | undefined, + options?: { capitalize?: boolean; expand?: boolean; icon?: boolean; label?: boolean; quoted?: boolean } | false, +) { + if (refs == null) return ''; + + options = + options === false + ? {} + : { expand: true, icon: true, label: options?.label ?? options?.expand ?? true, ...options }; + + let result; + if (!Array.isArray(refs) || refs.length === 1) { + const ref = Array.isArray(refs) ? refs[0] : refs; + let refName = options?.quoted ? `'${ref.name}'` : ref.name; + switch (ref.refType) { + case 'branch': { + if (ref.remote) { + refName = `${getRemoteNameFromBranchName(refName)}: ${getBranchNameWithoutRemote(refName)}`; + refName = options?.quoted ? `'${refName}'` : refName; + } - result = `${options.label ? `${ref.remote ? 'remote ' : ''}branch ` : ''}${ - options.icon ? `$(git-branch)${GlyphChars.Space}${refName}` : refName - }`; - break; - case 'tag': - result = `${options.label ? 'tag ' : ''}${ - options.icon ? `$(tag)${GlyphChars.Space}${refName}` : refName - }`; - break; - default: { - if (GitReference.isStash(ref)) { - let message; - if (options.expand && ref.message) { - message = `${ref.number != null ? `${ref.number}: ` : ''}${ - ref.message.length > 20 - ? `${ref.message.substring(0, 20).trimRight()}${GlyphChars.Ellipsis}` - : ref.message - }`; - } + let label; + if (options.label) { + if (options.capitalize && options.expand) { + label = `${ref.remote ? 'Remote ' : ''}Branch `; + } else { + label = `${ref.remote ? 'remote ' : ''}branch `; + } + } else { + label = ''; + } - result = `${options.label ? 'stash ' : ''}${ - options.icon - ? `$(archive)${GlyphChars.Space}${message ?? ref.name}` - : `${message ?? ref.number ?? ref.name}` + result = `${label}${options.icon ? `$(git-branch)${GlyphChars.Space}${refName}` : refName}`; + break; + } + case 'tag': + result = `${options.label ? 'tag ' : ''}${ + options.icon ? `$(tag)${GlyphChars.Space}${refName}` : refName + }`; + break; + default: { + if (isStashReference(ref)) { + let message; + if (options.expand && ref.message) { + message = `${ref.number != null ? `#${ref.number}: ` : ''}${ + ref.message.length > 20 + ? `${ref.message.substring(0, 20).trimEnd()}${GlyphChars.Ellipsis}` + : ref.message }`; - } else if (GitRevision.isRange(ref.ref)) { - result = refName; - } else { - let message; - if (options.expand && ref.message) { - message = - ref.message.length > 20 - ? ` (${ref.message.substring(0, 20).trimRight()}${GlyphChars.Ellipsis})` - : ` (${ref.message})`; - } + } - let prefix; - if (options.expand && options.label && GitRevision.isShaParent(ref.ref)) { - refName = ref.name.endsWith('^') ? ref.name.substr(0, ref.name.length - 1) : ref.name; - if (options?.quoted) { - refName = `'${refName}'`; - } - prefix = 'before '; - } else { - prefix = ''; - } + result = `${options.label ? 'stash ' : ''}${ + options.icon + ? `$(archive)${GlyphChars.Space}${message ?? ref.name}` + : message ?? (ref.number ? `#${ref.number}` : ref.name) + }`; + } else if (isRevisionRange(ref.ref)) { + result = refName; + } else { + let message; + if (options.expand && ref.message) { + message = + ref.message.length > 20 + ? ` (${ref.message.substring(0, 20).trimEnd()}${GlyphChars.Ellipsis})` + : ` (${ref.message})`; + } - result = `${options.label ? `${prefix}commit ` : ''}${ - options.icon - ? `$(git-commit)${GlyphChars.Space}${refName}${message ?? ''}` - : `${refName}${message ?? ''}` - }`; + let prefix; + if (options.expand && options.label && isShaParent(ref.ref)) { + refName = ref.name.endsWith('^') ? ref.name.substring(0, ref.name.length - 1) : ref.name; + if (options?.quoted) { + refName = `'${refName}'`; + } + prefix = 'before '; + } else { + prefix = ''; } - break; + + result = `${options.label ? `${prefix}commit ` : ''}${ + options.icon + ? `$(git-commit)${GlyphChars.Space}${refName}${message ?? ''}` + : `${refName}${message ?? ''}` + }`; } + break; } - - return options.capitalize && options.expand && options.label !== false - ? `${result[0].toLocaleUpperCase()}${result.substring(1)}` - : result; } - const expanded = options.expand ? ` (${refs.map(r => r.name).join(', ')})` : ''; - switch (refs[0].refType) { - case 'branch': - return `${refs.length} branches${expanded}`; - case 'tag': - return `${refs.length} tags${expanded}`; - default: - return `${refs.length} ${GitReference.isStash(refs[0]) ? 'stashes' : 'commits'}${expanded}`; - } + return options.capitalize && options.expand && options.label !== false ? capitalize(result) : result; } -} -export function splitRefNameAndRemote(ref: GitReference): [name: string, remote: string | undefined] { - if (ref.refType === 'branch') { - return ref.remote ? splitBranchNameAndRemote(ref.name) : [ref.name, undefined]; + const expanded = options.expand ? ` (${refs.map(r => r.name).join(', ')})` : ''; + switch (refs[0].refType) { + case 'branch': + return `${refs.length} branches${expanded}`; + case 'tag': + return `${refs.length} tags${expanded}`; + default: + return `${refs.length} ${isStashReference(refs[0]) ? 'stashes' : 'commits'}${expanded}`; } - return [ref.name, undefined]; } diff --git a/src/git/models/reflog.ts b/src/git/models/reflog.ts index e88eceedcade8..562c9e69e0974 100644 --- a/src/git/models/reflog.ts +++ b/src/git/models/reflog.ts @@ -1,8 +1,7 @@ -import { DateStyle } from '../../config'; import { Container } from '../../container'; import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; -import { GitRevision } from './reference'; +import { shortenRevision } from './reference'; export interface GitReflog { readonly repoPath: string; @@ -38,7 +37,7 @@ export class GitReflogRecord { } get formattedDate(): string { - return Container.instance.CommitDateFormatting.dateStyle === DateStyle.Absolute + return Container.instance.CommitDateFormatting.dateStyle === 'absolute' ? this.formatDate(Container.instance.CommitDateFormatting.dateFormat) : this.formatDateFromNow(); } @@ -48,11 +47,11 @@ export class GitReflogRecord { if (this._selector == null || this._selector.length === 0) return ''; if (this._selector.startsWith('refs/heads')) { - return this._selector.substr(11); + return this._selector.substring(11); } if (this._selector.startsWith('refs/remotes')) { - return this._selector.substr(13); + return this._selector.substring(13); } return this._selector; @@ -64,7 +63,7 @@ export class GitReflogRecord { @memoize() get previousShortSha() { - return GitRevision.shorten(this._previousSha); + return shortenRevision(this._previousSha); } get selector() { @@ -73,7 +72,7 @@ export class GitReflogRecord { @memoize() get shortSha() { - return GitRevision.shorten(this.sha); + return shortenRevision(this.sha); } update(previousSha?: string, selector?: string) { diff --git a/src/git/models/remote.ts b/src/git/models/remote.ts index de5b86dee9a0d..f73f39dbb2e8f 100644 --- a/src/git/models/remote.ts +++ b/src/git/models/remote.ts @@ -2,79 +2,63 @@ import type { ColorTheme } from 'vscode'; import { Uri, window } from 'vscode'; import { GlyphChars } from '../../constants'; import { Container } from '../../container'; +import type { HostingIntegration } from '../../plus/integrations/integration'; import { memoize } from '../../system/decorators/memoize'; import { equalsIgnoreCase, sortCompare } from '../../system/string'; -import { isLightTheme } from '../../system/utils'; +import { isLightTheme } from '../../system/vscode/utils'; import { parseGitRemoteUrl } from '../parsers/remoteParser'; import type { RemoteProvider } from '../remotes/remoteProvider'; -import type { RichRemoteProvider } from '../remotes/richRemoteProvider'; +import { getRemoteProviderThemeIconString } from '../remotes/remoteProvider'; -export const enum GitRemoteType { - Fetch = 'fetch', - Push = 'push', -} - -export class GitRemote { - static getHighlanderProviders(remotes: GitRemote[]) { - if (remotes.length === 0) return undefined; - - const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); - if (remote != null) return [remote.provider]; - - const providerName = remotes[0].provider.name; - if (remotes.every(r => r.provider.name === providerName)) return remotes.map(r => r.provider); - - return undefined; - } - - static getHighlanderProviderName(remotes: GitRemote[]) { - if (remotes.length === 0) return undefined; - - const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); - if (remote != null) return remote.provider.name; - - const providerName = remotes[0].provider.name; - // Only use the real provider name if there is only 1 type of provider - if (remotes.every(r => r.provider.name === providerName)) return providerName; - - return undefined; - } - - static is(remote: any): remote is GitRemote { - return remote instanceof GitRemote; - } - - static sort(remotes: GitRemote[]) { - return remotes.sort( - (a, b) => - (a.default ? -1 : 1) - (b.default ? -1 : 1) || - (a.name === 'origin' ? -1 : 1) - (b.name === 'origin' ? -1 : 1) || - (a.name === 'upstream' ? -1 : 1) - (b.name === 'upstream' ? -1 : 1) || - sortCompare(a.name, b.name), - ); - } +export type GitRemoteType = 'fetch' | 'push'; +export class GitRemote { constructor( + private readonly container: Container, public readonly repoPath: string, - public readonly id: string, public readonly name: string, public readonly scheme: string, - public readonly domain: string, - public readonly path: string, + private readonly _domain: string, + private readonly _path: string, public readonly provider: TProvider, public readonly urls: { type: GitRemoteType; url: string }[], ) {} get default() { const defaultRemote = Container.instance.storage.getWorkspace('remote:default'); - return this.id === defaultRemote; + // Check for `this.remoteKey` matches to handle previously saved data + return this.name === defaultRemote || this.remoteKey === defaultRemote; + } + + @memoize() + get domain() { + return this.provider?.domain ?? this._domain; + } + + @memoize() + get id() { + return `${this.name}/${this.remoteKey}`; + } + + get maybeIntegrationConnected(): boolean | undefined { + return this.container.integrations.isMaybeConnected(this); + } + + @memoize() + get path() { + return this.provider?.path ?? this._path; + } + + @memoize() + get remoteKey() { + return this._domain ? `${this._domain}/${this._path}` : this.path; } @memoize() get url(): string { let bestUrl: string | undefined; for (const remoteUrl of this.urls) { - if (remoteUrl.type === GitRemoteType.Push) { + if (remoteUrl.type === 'push') { return remoteUrl.url; } @@ -86,8 +70,12 @@ export class GitRemote { - return this.provider?.hasRichIntegration() ?? false; + async getIntegration(): Promise { + return this.provider != null ? this.container.integrations.getByRemote(this) : undefined; + } + + hasIntegration(): this is GitRemote { + return this.provider != null && this.container.integrations.supports(this.provider.id); } matches(url: string): boolean; @@ -107,16 +95,41 @@ export class GitRemote[]) { + if (remotes.length === 0) return undefined; + + const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); + if (remote != null) return [remote.provider]; + + const providerName = remotes[0].provider.name; + if (remotes.every(r => r.provider.name === providerName)) return remotes.map(r => r.provider); + + return undefined; +} + +export function getHighlanderProviderName(remotes: GitRemote[]) { + if (remotes.length === 0) return undefined; + + const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); + if (remote != null) return remote.provider.name; + + const providerName = remotes[0].provider.name; + // Only use the real provider name if there is only 1 type of provider + if (remotes.every(r => r.provider.name === providerName)) return providerName; + + return undefined; +} + export function getRemoteArrowsGlyph(remote: GitRemote): GlyphChars { let arrows; let left; let right; for (const { type } of remote.urls) { - if (type === GitRemoteType.Fetch) { + if (type === 'fetch') { left = true; if (right) break; - } else if (type === GitRemoteType.Push) { + } else if (type === 'push') { right = true; if (left) break; @@ -136,6 +149,25 @@ export function getRemoteArrowsGlyph(remote: GitRemote): GlyphChars { return arrows; } +export function getRemoteIconUri( + container: Container, + remote: GitRemote, + asWebviewUri?: (uri: Uri) => Uri, + theme: ColorTheme = window.activeColorTheme, +): Uri | undefined { + if (remote.provider?.icon == null) return undefined; + + const uri = Uri.joinPath( + container.context.extensionUri, + `images/${isLightTheme(theme) ? 'light' : 'dark'}/icon-${remote.provider.icon}.svg`, + ); + return asWebviewUri != null ? asWebviewUri(uri) : uri; +} + +export function getRemoteThemeIconString(remote: GitRemote | undefined): string { + return getRemoteProviderThemeIconString(remote?.provider); +} + export function getRemoteUpstreamDescription(remote: GitRemote): string { const arrows = getRemoteArrowsGlyph(remote); @@ -149,17 +181,26 @@ export function getRemoteUpstreamDescription(remote: GitRemote): string { }${remote.path}`; } -export function getRemoteIconUri( - container: Container, - remote: GitRemote, - asWebviewUri?: (uri: Uri) => Uri, - theme: ColorTheme = window.activeColorTheme, -): Uri | undefined { - if (remote.provider?.icon == null) return undefined; +export function getVisibilityCacheKey(remote: GitRemote): string; +export function getVisibilityCacheKey(remotes: GitRemote[]): string; +export function getVisibilityCacheKey(remotes: GitRemote | GitRemote[]): string { + if (!Array.isArray(remotes)) return remotes.remoteKey; + return remotes + .map(r => r.remoteKey) + .sort() + .join(','); +} - const uri = Uri.joinPath( - container.context.extensionUri, - `images/${isLightTheme(theme) ? 'light' : 'dark'}/icon-${remote.provider.icon}.svg`, +export function isRemote(remote: any): remote is GitRemote { + return remote instanceof GitRemote; +} + +export function sortRemotes(remotes: T[]) { + return remotes.sort( + (a, b) => + (a.default ? -1 : 1) - (b.default ? -1 : 1) || + (a.name === 'origin' ? -1 : 1) - (b.name === 'origin' ? -1 : 1) || + (a.name === 'upstream' ? -1 : 1) - (b.name === 'upstream' ? -1 : 1) || + sortCompare(a.name, b.name), ); - return asWebviewUri != null ? asWebviewUri(uri) : uri; } diff --git a/src/git/models/remoteProvider.ts b/src/git/models/remoteProvider.ts index 4e76512a0262e..644db72aee269 100644 --- a/src/git/models/remoteProvider.ts +++ b/src/git/models/remoteProvider.ts @@ -1,6 +1,12 @@ -export interface RemoteProviderReference { +export interface ProviderReference { readonly id: string; readonly name: string; readonly domain: string; readonly icon: string; } + +export interface Provider extends ProviderReference { + getIgnoreSSLErrors(): boolean | 'force'; + reauthenticate(): Promise; + trackRequestException(): void; +} diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index c19803b261b46..3eac4a2e1e290 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -1,31 +1,32 @@ -import type { CancellationToken, ConfigurationChangeEvent, Event, WorkspaceFolder } from 'vscode'; -import { Disposable, EventEmitter, ProgressLocation, RelativePattern, Uri, window, workspace } from 'vscode'; -import { md5 } from '@env/crypto'; -import { ForcePushMode } from '../../@types/vscode.git.enums'; +import { md5, uuid } from '@env/crypto'; +import type { CancellationToken, ConfigurationChangeEvent, Event, Uri, WorkspaceFolder } from 'vscode'; +import { Disposable, EventEmitter, ProgressLocation, RelativePattern, window, workspace } from 'vscode'; import type { CreatePullRequestActionContext } from '../../api/gitlens'; -import { configuration } from '../../configuration'; -import { CoreGitCommands, CoreGitConfiguration, Schemes } from '../../constants'; +import type { RepositoriesSorting } from '../../config'; +import { Schemes } from '../../constants'; +import type { SearchQuery } from '../../constants.search'; import type { Container } from '../../container'; import type { FeatureAccess, Features, PlusFeatures } from '../../features'; -import { getLoggableName, Logger } from '../../logger'; -import { getLogScope } from '../../logScope'; import { showCreatePullRequestPrompt, showGenericErrorMessage } from '../../messages'; +import type { HostingIntegration } from '../../plus/integrations/integration'; +import type { RepoComparisonKey } from '../../repositories'; import { asRepoComparisonKey } from '../../repositories'; -import { filterMap, groupByMap } from '../../system/array'; -import { executeActionCommand, executeCoreGitCommand } from '../../system/command'; import { formatDate, fromNow } from '../../system/date'; import { gate } from '../../system/decorators/gate'; import { debug, log, logName } from '../../system/decorators/log'; +import type { Deferrable } from '../../system/function'; import { debounce } from '../../system/function'; -import { filter, join, some } from '../../system/iterable'; +import { filter, groupByMap, join, map, min, some } from '../../system/iterable'; +import { getLoggableName, Logger } from '../../system/logger'; +import { getLogScope } from '../../system/logger.scope'; import { updateRecordValue } from '../../system/object'; import { basename, normalizePath } from '../../system/path'; -import { runGitCommandInTerminal } from '../../terminal'; -import type { GitDir, GitProviderDescriptor, GitRepositoryCaches } from '../gitProvider'; -import type { RemoteProviders } from '../remotes/remoteProviders'; -import { loadRemoteProviders } from '../remotes/remoteProviders'; -import type { RichRemoteProvider } from '../remotes/richRemoteProvider'; -import type { GitSearch, SearchQuery } from '../search'; +import { sortCompare } from '../../system/string'; +import { executeActionCommand } from '../../system/vscode/command'; +import { configuration } from '../../system/vscode/configuration'; +import type { GitDir, GitProviderDescriptor, GitRepositoryCaches, PagingOptions } from '../gitProvider'; +import type { RemoteProvider } from '../remotes/remoteProvider'; +import type { GitSearch } from '../search'; import type { BranchSortOptions, GitBranch } from './branch'; import { getBranchNameWithoutRemote, getRemoteNameFromBranchName } from './branch'; import type { GitCommit } from './commit'; @@ -34,20 +35,32 @@ import type { GitDiffShortStat } from './diff'; import type { GitLog } from './log'; import type { GitMergeStatus } from './merge'; import type { GitRebaseStatus } from './rebase'; -import type { GitBranchReference, GitTagReference } from './reference'; -import { GitReference } from './reference'; +import type { GitBranchReference, GitReference, GitTagReference } from './reference'; +import { getNameWithoutRemote, isBranchReference } from './reference'; import type { GitRemote } from './remote'; import type { GitStash } from './stash'; import type { GitStatus } from './status'; import type { GitTag, TagSortOptions } from './tag'; import type { GitWorktree } from './worktree'; +export interface RepositoriesSortOptions { + orderBy?: RepositoriesSorting; +} + const emptyArray = Object.freeze([]) as unknown as any[]; const millisecondsPerMinute = 60 * 1000; const millisecondsPerHour = 60 * 60 * 1000; const millisecondsPerDay = 24 * 60 * 60 * 1000; +const dotGitWatcherGlobFiles = 'index,HEAD,*_HEAD,MERGE_*,rebase-merge/**,sequencer/**'; +const dotGitWatcherGlobWorktreeFiles = + 'worktrees/**/index,worktrees/**/HEAD,worktrees/**/*_HEAD,worktrees/**/MERGE_*,worktrees/**/rebase-merge/**,worktrees/**/sequencer/**'; + +const dotGitWatcherGlobRoot = `{${dotGitWatcherGlobFiles}}`; +const dotGitWatcherGlobCommon = `{config,refs/**,${dotGitWatcherGlobWorktreeFiles}}`; +const dotGitWatcherGlobCombined = `{${dotGitWatcherGlobFiles},config,refs/**,${dotGitWatcherGlobWorktreeFiles}}`; + export const enum RepositoryChange { Unknown = -1, @@ -60,9 +73,7 @@ export const enum RepositoryChange { Remotes = 5, Worktrees = 6, Config = 7, - /* - * Union of Cherry, Merge, and Rebase - */ + /** Union of Cherry, Merge, and Rebase */ Status = 8, CherryPick = 9, Merge = 10, @@ -73,18 +84,24 @@ export const enum RepositoryChange { Ignores = 101, RemoteProviders = 102, Starred = 103, + Opened = 104, } export const enum RepositoryChangeComparisonMode { Any, - All, Exclusive, } +const defaultFileSystemChangeDelay = 2500; +const defaultRepositoryChangeDelay = 250; + export class RepositoryChangeEvent { private readonly _changes: Set; - constructor(public readonly repository: Repository, changes: RepositoryChange[]) { + constructor( + public readonly repository: Repository, + changes: RepositoryChange[], + ) { this._changes = new Set(changes); } @@ -165,8 +182,32 @@ export class Repository implements Disposable { : 0; } - static sort(repositories: Repository[]) { - return repositories.sort((a, b) => (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || a.index - b.index); + static sort(repositories: Repository[], options?: RepositoriesSortOptions) { + options = { orderBy: configuration.get('sortRepositoriesBy'), ...options }; + + switch (options.orderBy) { + case 'name:asc': + return repositories.sort( + (a, b) => (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || sortCompare(a.name, b.name), + ); + case 'name:desc': + return repositories.sort( + (a, b) => (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || sortCompare(b.name, a.name), + ); + case 'lastFetched:asc': + return repositories.sort( + (a, b) => + (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (a._lastFetched ?? 0) - (b._lastFetched ?? 0), + ); + case 'lastFetched:desc': + return repositories.sort( + (a, b) => + (a.starred ? -1 : 1) - (b.starred ? -1 : 1) || (b._lastFetched ?? 0) - (a._lastFetched ?? 0), + ); + case 'discovered': + default: + return repositories; + } } private _onDidChange = new EventEmitter(); @@ -184,9 +225,13 @@ export class Repository implements Disposable { return this.name; } - readonly id: string; + readonly id: RepoComparisonKey; readonly index: number; - readonly name: string; + + private _name: string; + get name(): string { + return this._name; + } private _idHash: string | undefined; get idHash() { @@ -198,15 +243,10 @@ export class Repository implements Disposable { private _branch: Promise | undefined; private readonly _disposable: Disposable; - private _fireChangeDebounced: (() => void) | undefined = undefined; - private _fireFileSystemChangeDebounced: (() => void) | undefined = undefined; - private _fsWatchCounter = 0; - private _fsWatcherDisposable: Disposable | undefined; + private _fireChangeDebounced: Deferrable<() => void> | undefined = undefined; + private _fireFileSystemChangeDebounced: Deferrable<() => void> | undefined = undefined; private _pendingFileSystemChange?: RepositoryFileSystemChangeEvent; private _pendingRepoChange?: RepositoryChangeEvent; - private _providers: RemoteProviders | undefined; - private _remotes: Promise | undefined; - private _remotesDisposable: Disposable | undefined; private _suspended: boolean; constructor( @@ -219,16 +259,15 @@ export class Repository implements Disposable { suspended: boolean, closed: boolean = false, ) { - folder = workspace.getWorkspaceFolder(uri) ?? folder; if (folder != null) { if (root) { - this.name = folder.name; + this._name = folder.name; } else { const relativePath = container.git.getRelativePath(uri, folder.uri); - this.name = relativePath ? relativePath : folder.name; + this._name = relativePath ? relativePath : folder.name; } } else { - this.name = basename(uri.path); + this._name = basename(uri.path); // TODO@eamodio should we create a fake workspace folder? // folder = { @@ -237,6 +276,22 @@ export class Repository implements Disposable { // index: container.git.repositoryCount, // }; } + + // Update the name if it is a worktree + void this.getGitDir().then(gd => { + if (gd?.commonUri == null) return; + + let path = gd.commonUri.path; + if (path.endsWith('/.git')) { + path = path.substring(0, path.length - 5); + } + + const prefix = `${basename(path)}: `; + if (!this._name.startsWith(prefix)) { + this._name = `${prefix}${this._name}`; + } + }); + this.index = folder?.index ?? container.git.repositoryCount; this.id = asRepoComparisonKey(uri); @@ -247,9 +302,25 @@ export class Repository implements Disposable { this._disposable = Disposable.from( this.setupRepoWatchers(), configuration.onDidChange(this.onConfigurationChanged, this), + // Sending this event in the `'git:cache:reset'` below to avoid unnecessary work. While we will refresh more than needed, this doesn't happen often + // container.richRemoteProviders.onAfterDidChangeConnectionState(async e => { + // const uniqueKeys = new Set(); + // for (const remote of await this.getRemotes()) { + // if (remote.provider?.hasRichIntegration()) { + // uniqueKeys.add(remote.provider.key); + // } + // } + + // if (uniqueKeys.has(e.key)) { + // this.fireChange(RepositoryChange.RemoteProviders); + // } + // }), ); this.onConfigurationChanged(); + if (this._orderByLastFetched) { + void this.getLastFetched(); + } } private setupRepoWatchers() { @@ -262,13 +333,20 @@ export class Repository implements Disposable { }; } + @debug({ singleLine: true }) private async setupRepoWatchersCore() { + const scope = getLogScope(); + const disposables: Disposable[] = []; disposables.push( this.container.events.on('git:cache:reset', e => { if (!e.data.repoPath || e.data.repoPath === this.path) { this.resetCaches(...(e.data.caches ?? emptyArray)); + + if (e.data.caches?.includes('providers')) { + this.fireChange(RepositoryChange.RemoteProviders); + } } }), ); @@ -282,6 +360,8 @@ export class Repository implements Disposable { ); function watch(this: Repository, uri: Uri, pattern: string) { + Logger.debug(scope, `watching '${uri.toString(true)}' for repository changes`); + const watcher = workspace.createFileSystemWatcher(new RelativePattern(uri, pattern)); disposables.push( @@ -296,14 +376,10 @@ export class Repository implements Disposable { const gitDir = await this.getGitDir(); if (gitDir != null) { if (gitDir?.commonUri == null) { - watch.call( - this, - gitDir.uri, - '{index,HEAD,*_HEAD,MERGE_*,config,refs/**,rebase-merge/**,sequencer/**,worktrees/**}', - ); + watch.call(this, gitDir.uri, dotGitWatcherGlobCombined); } else { - watch.call(this, gitDir.uri, '{index,HEAD,*_HEAD,MERGE_*,rebase-merge/**,sequencer/**}'); - watch.call(this, gitDir.commonUri, '{config,refs/**,worktrees/**}'); + watch.call(this, gitDir.uri, dotGitWatcherGlobRoot); + watch.call(this, gitDir.commonUri, dotGitWatcherGlobCommon); } } @@ -311,14 +387,13 @@ export class Repository implements Disposable { } dispose() { - this.stopWatchingFileSystem(); + this.unWatchFileSystem(true); - this._remotesDisposable?.dispose(); this._disposable.dispose(); } toString(): string { - return `${getLoggableName(this)}(${this.id})`; + return getLoggableName(this); } get virtual(): boolean { @@ -333,19 +408,24 @@ export class Repository implements Disposable { return this._updatedAt; } + private _orderByLastFetched = false; + get orderByLastFetched(): boolean { + return this._orderByLastFetched; + } + private _updatedAt: number = 0; get updatedAt(): number { return this._updatedAt; } private onConfigurationChanged(e?: ConfigurationChangeEvent) { - if (configuration.changed(e, 'remotes', this.folder?.uri)) { - this._providers = loadRemoteProviders(configuration.get('remotes', this.folder?.uri ?? null)); + if (configuration.changed(e, 'sortRepositoriesBy')) { + this._orderByLastFetched = configuration.get('sortRepositoriesBy')?.startsWith('lastFetched:') ?? false; + } - if (e != null) { - this.resetCaches('remotes'); - this.fireChange(RepositoryChange.Remotes); - } + if (e != null && configuration.changed(e, 'remotes', this.folder?.uri)) { + this.resetCaches('remotes'); + this.fireChange(RepositoryChange.Remotes); } } @@ -370,6 +450,9 @@ export class Repository implements Disposable { } this._lastFetched = undefined; + if (this._orderByLastFetched) { + void this.getLastFetched(); + } const match = uri != null @@ -452,7 +535,8 @@ export class Repository implements Disposable { const changed = this._closed !== value; this._closed = value; if (changed) { - this.fireChange(RepositoryChange.Closed); + Logger.debug(`Repository(${this.id}).closed(${value})`); + this.fireChange(this._closed ? RepositoryChange.Closed : RepositoryChange.Opened); } } @@ -468,24 +552,24 @@ export class Repository implements Disposable { @log() async addRemote(name: string, url: string, options?: { fetch?: boolean }): Promise { - await this.container.git.addRemote(this.path, name, url, options); + await this.container.git.addRemote(this.uri, name, url, options); const [remote] = await this.getRemotes({ filter: r => r.url === url }); return remote; } @log() pruneRemote(name: string): Promise { - return this.container.git.pruneRemote(this.path, name); + return this.container.git.pruneRemote(this.uri, name); } @log() removeRemote(name: string): Promise { - return this.container.git.removeRemote(this.path, name); + return this.container.git.removeRemote(this.uri, name); } @log() branch(...args: string[]) { - this.runTerminalCommand('branch', ...args); + void this.runTerminalCommand('branch', ...args); } @log() @@ -500,7 +584,7 @@ export class Repository implements Disposable { if (options?.force) { args.push('--force'); } - this.runTerminalCommand('branch', ...args, ...branches.map(b => b.ref)); + void this.runTerminalCommand('branch', ...args, ...branches.map(b => b.ref)); if (options?.remote) { const trackingBranches = localBranches.filter(b => b.upstream != null); @@ -510,7 +594,7 @@ export class Repository implements Disposable { ); for (const [remote, branches] of branchesByOrigin.entries()) { - this.runTerminalCommand( + void this.runTerminalCommand( 'push', '-d', remote, @@ -526,19 +610,14 @@ export class Repository implements Disposable { const branchesByOrigin = groupByMap(remoteBranches, b => getRemoteNameFromBranchName(b.name)); for (const [remote, branches] of branchesByOrigin.entries()) { - this.runTerminalCommand( - 'push', - '-d', - remote, - ...branches.map(b => GitReference.getNameWithoutRemote(b)), - ); + void this.runTerminalCommand('push', '-d', remote, ...branches.map(b => getNameWithoutRemote(b))); } } } @log() cherryPick(...args: string[]) { - this.runTerminalCommand('cherry-pick', ...args); + void this.runTerminalCommand('cherry-pick', ...args); } containsUri(uri: Uri) { @@ -578,7 +657,7 @@ export class Repository implements Disposable { remote?: string; }) { try { - await this.container.git.fetch(this.path, options); + await this.container.git.fetch(this.uri, options); this.fireChange(RepositoryChange.Unknown); } catch (ex) { @@ -587,6 +666,13 @@ export class Repository implements Disposable { } } + async getBestRemoteWithIntegration(options?: { + filter?: (remote: GitRemote, integration: HostingIntegration) => boolean; + includeDisconnected?: boolean; + }): Promise | undefined> { + return this.container.git.getBestRemoteWithIntegration(this.uri, options); + } + async getBranch(name?: string): Promise { if (name) { const { @@ -596,36 +682,67 @@ export class Repository implements Disposable { } if (this._branch == null) { - this._branch = this.container.git.getBranch(this.path); + this._branch = this.container.git.getBranch(this.uri); } return this._branch; } getBranches(options?: { filter?: (b: GitBranch) => boolean; - paging?: { cursor?: string; limit?: number }; + paging?: PagingOptions; sort?: boolean | BranchSortOptions; }) { - return this.container.git.getBranches(this.path, options); + return this.container.git.getBranches(this.uri, options); } getChangedFilesCount(ref?: string): Promise { - return this.container.git.getChangedFilesCount(this.path, ref); + return this.container.git.getChangedFilesCount(this.uri, ref); } getCommit(ref: string): Promise { - return this.container.git.getCommit(this.path, ref); + return this.container.git.getCommit(this.uri, ref); + } + + @gate() + @log({ exit: true }) + async getCommonRepository(): Promise { + const gitDir = await this.getGitDir(); + if (gitDir?.commonUri == null) return this; + + // If the repository isn't already opened, then open it as a "closed" repo (won't show up in the UI) + return this.container.git.getOrOpenRepository(gitDir.commonUri, { + detectNested: false, + force: true, + closeOnOpen: true, + }); } - getContributors(options?: { all?: boolean; ref?: string; stats?: boolean }): Promise { - return this.container.git.getContributors(this.path, options); + @log({ exit: true }) + async getCommonRepositoryUri(): Promise { + const gitDir = await this.getGitDir(); + if (gitDir?.commonUri?.path.endsWith('/.git')) { + return gitDir.commonUri.with({ + path: gitDir.commonUri.path.substring(0, gitDir.commonUri.path.length - 5), + }); + } + + return gitDir?.commonUri; + } + + getContributors(options?: { + all?: boolean; + merges?: boolean | 'first-parent'; + ref?: string; + stats?: boolean; + }): Promise { + return this.container.git.getContributors(this.uri, options); } private _gitDir: GitDir | undefined; private _gitDirPromise: Promise | undefined; private getGitDir(): Promise { if (this._gitDirPromise == null) { - this._gitDirPromise = this.container.git.getGitDir(this.path).then(gd => (this._gitDir = gd)); + this._gitDirPromise = this.container.git.getGitDir(this.uri).then(gd => (this._gitDir = gd)); } return this._gitDirPromise; } @@ -633,38 +750,21 @@ export class Repository implements Disposable { private _lastFetched: number | undefined; @gate() async getLastFetched(): Promise { - if (this._lastFetched == null) { - if (!(await this.hasRemotes())) return 0; - } - - try { - const lastFetched = await this.container.git.getLastFetchedTimestamp(this.path); - // If we don't get a number, assume the fetch failed, and don't update the timestamp - if (lastFetched != null) { - this._lastFetched = lastFetched; - } - } catch { - this._lastFetched = undefined; + const lastFetched = await this.container.git.getLastFetchedTimestamp(this.uri); + // If we don't get a number, assume the fetch failed, and don't update the timestamp + if (lastFetched != null) { + this._lastFetched = lastFetched; } return this._lastFetched ?? 0; } - @gate() - async getMainRepository(): Promise { - const gitDir = await this.getGitDir(); - if (gitDir?.commonUri == null) return this; - - // If the repository isn't already opened, then open it as a "closed" repo (won't show up in the UI) - return this.container.git.getOrOpenRepository(gitDir.commonUri, { closeOnOpen: true }); - } - getMergeStatus(): Promise { - return this.container.git.getMergeStatus(this.path); + return this.container.git.getMergeStatus(this.uri); } getRebaseStatus(): Promise { - return this.container.git.getRebaseStatus(this.path); + return this.container.git.getRebaseStatus(this.uri); } async getRemote(remote: string): Promise { @@ -672,45 +772,19 @@ export class Repository implements Disposable { } async getRemotes(options?: { filter?: (remote: GitRemote) => boolean; sort?: boolean }): Promise { - if (this._remotes == null) { - if (this._providers == null) { - const remotesCfg = configuration.get('remotes', this.folder?.uri ?? null); - this._providers = loadRemoteProviders(remotesCfg); - } - - // Since we are caching the results, always sort - this._remotes = this.container.git.getRemotes(this.path, { providers: this._providers, sort: true }); - void this.subscribeToRemotes(this._remotes); - } - - return options?.filter != null ? (await this._remotes).filter(options.filter) : this._remotes; - } - - async getRichRemote(connectedOnly: boolean = false): Promise | undefined> { - return this.container.git.getBestRemoteWithRichProvider(await this.getRemotes(), { - includeDisconnected: !connectedOnly, - }); - } - - private async subscribeToRemotes(remotes: Promise) { - this._remotesDisposable?.dispose(); - this._remotesDisposable = undefined; - - this._remotesDisposable = Disposable.from( - ...filterMap(await remotes, r => { - if (!r.provider?.hasRichIntegration()) return undefined; - - return r.provider.onDidChange(() => this.fireChange(RepositoryChange.RemoteProviders)); - }), + const remotes = await this.container.git.getRemotes( + this.uri, + options?.sort != null ? { sort: options.sort } : undefined, ); + return options?.filter != null ? remotes.filter(options.filter) : remotes; } getStash(): Promise { - return this.container.git.getStash(this.path); + return this.container.git.getStash(this.uri); } getStatus(): Promise { - return this.container.git.getStatusForRepo(this.path); + return this.container.git.getStatusForRepo(this.uri); } async getTag(name: string): Promise { @@ -720,29 +794,30 @@ export class Repository implements Disposable { return tag; } - getTags(options?: { filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }) { - return this.container.git.getTags(this.path, options); + getTags(options?: { filter?: (t: GitTag) => boolean; paging?: PagingOptions; sort?: boolean | TagSortOptions }) { + return this.container.git.getTags(this.uri, options); } + @log() async createWorktree( uri: Uri, options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean }, ): Promise { - await this.container.git.createWorktree(this.path, uri.fsPath, options); + await this.container.git.createWorktree(this.uri, uri.fsPath, options); const url = uri.toString(); - return this.container.git.getWorktree(this.path, w => w.uri.toString() === url); + return this.container.git.getWorktree(this.uri, w => w.uri.toString() === url); } getWorktrees(): Promise { - return this.container.git.getWorktrees(this.path); + return this.container.git.getWorktrees(this.uri); } async getWorktreesDefaultUri(): Promise { - return this.container.git.getWorktreesDefaultUri(this.path); + return this.container.git.getWorktreesDefaultUri(this.uri); } deleteWorktree(uri: Uri, options?: { force?: boolean }): Promise { - return this.container.git.deleteWorktree(this.path, uri.fsPath, options); + return this.container.git.deleteWorktree(this.uri, uri.fsPath, options); } async hasRemotes(): Promise { @@ -750,8 +825,11 @@ export class Repository implements Disposable { return remotes?.length > 0; } - async hasRichRemote(connectedOnly: boolean = false): Promise { - const remote = await this.getRichRemote(connectedOnly); + async hasRemoteWithIntegration(options?: { + filter?: (remote: GitRemote, integration: HostingIntegration) => boolean; + includeDisconnected?: boolean; + }): Promise { + const remote = await this.getBestRemoteWithIntegration(options); return remote?.provider != null; } @@ -772,7 +850,7 @@ export class Repository implements Disposable { @log() merge(...args: string[]) { - this.runTerminalCommand('merge', ...args); + void this.runTerminalCommand('merge', ...args); } @gate() @@ -792,16 +870,13 @@ export class Repository implements Disposable { private async pullCore(options?: { rebase?: boolean }) { try { - const upstream = await this.hasUpstreamBranch(); - if (upstream) { - void (await executeCoreGitCommand( - options?.rebase ? CoreGitCommands.PullRebase : CoreGitCommands.Pull, - this.path, - )); - } else if (configuration.getAny(CoreGitConfiguration.FetchOnPull, Uri.file(this.path))) { - await this.container.git.fetch(this.path); + const withTags = configuration.getCore('git.pullTags', this.uri); + if (configuration.getCore('git.fetchOnPull', this.uri)) { + await this.container.git.fetch(this.uri); } + await this.container.git.pull(this.uri, { ...options, tags: withTags }); + this.fireChange(RepositoryChange.Unknown); } catch (ex) { Logger.error(ex); @@ -809,30 +884,6 @@ export class Repository implements Disposable { } } - @gate() - @log() - async push(options?: { - force?: boolean; - progress?: boolean; - reference?: GitReference; - publish?: { - remote: string; - }; - }) { - const { progress, ...opts } = { progress: true, ...options }; - if (!progress) return this.pushCore(opts); - - return window.withProgress( - { - location: ProgressLocation.Notification, - title: GitReference.isBranch(opts.reference) - ? `${opts.publish != null ? 'Publishing ' : 'Pushing '}${opts.reference.name}...` - : `Pushing ${this.formattedName}...`, - }, - () => this.pushCore(opts), - ); - } - private async showCreatePullRequestPrompt(remoteName: string, branch: GitBranchReference) { if (!this.container.actionRunners.count('createPullRequest')) return; if (!(await showCreatePullRequestPrompt(branch.name))) return; @@ -864,50 +915,38 @@ export class Repository implements Disposable { }); } - private async pushCore(options?: { + @gate() + @log() + async push(options?: { force?: boolean; + progress?: boolean; reference?: GitReference; - publish?: { - remote: string; - }; + publish?: { remote: string }; }) { + const { progress, ...opts } = { progress: true, ...options }; + if (!progress) return this.pushCore(opts); + + return window.withProgress( + { + location: ProgressLocation.Notification, + title: isBranchReference(opts.reference) + ? `${opts.publish != null ? 'Publishing ' : 'Pushing '}${opts.reference.name}...` + : `Pushing ${this.formattedName}...`, + }, + () => this.pushCore(opts), + ); + } + + private async pushCore(options?: { force?: boolean; reference?: GitReference; publish?: { remote: string } }) { try { - if (GitReference.isBranch(options?.reference)) { - const repo = await this.container.git.getOrOpenScmRepository(this.path); - if (repo == null) return; - - if (options?.publish != null) { - await repo?.push(options.publish.remote, options.reference.name, true); - void this.showCreatePullRequestPrompt(options.publish.remote, options.reference); - } else { - const branch = await this.getBranch(options?.reference.name); - if (branch == null) return; - - await repo?.push( - branch.getRemoteName(), - branch.name, - undefined, - options?.force ? ForcePushMode.ForceWithLease : undefined, - ); - } - } else if (options?.reference != null) { - const repo = await this.container.git.getOrOpenScmRepository(this.path); - if (repo == null) return; - - const branch = await this.getBranch(); - if (branch == null) return; - - await repo?.push( - branch.getRemoteName(), - `${options.reference.ref}:${branch.getNameWithoutRemote()}`, - undefined, - options?.force ? ForcePushMode.ForceWithLease : undefined, - ); - } else { - void (await executeCoreGitCommand( - options?.force ? CoreGitCommands.PushForce : CoreGitCommands.Push, - this.path, - )); + await this.container.git.push(this.uri, { + reference: options?.reference, + force: options?.force, + publish: options?.publish, + }); + + if (isBranchReference(options?.reference) && options?.publish != null) { + void this.showCreatePullRequestPrompt(options.publish.remote, options.reference); } this.fireChange(RepositoryChange.Unknown); @@ -919,7 +958,7 @@ export class Repository implements Disposable { @log() rebase(configs: string[] | undefined, ...args: string[]) { - this.runTerminalCommand( + void this.runTerminalCommand( configs != null && configs.length !== 0 ? `${configs.join(' ')} rebase` : 'rebase', ...args, ); @@ -927,7 +966,7 @@ export class Repository implements Disposable { @log() reset(...args: string[]) { - this.runTerminalCommand('reset', ...args); + void this.runTerminalCommand('reset', ...args); } @log({ singleLine: true }) @@ -936,11 +975,11 @@ export class Repository implements Disposable { this._branch = undefined; } - if (caches.length === 0 || caches.includes('remotes')) { - this._remotes = undefined; - this._remotesDisposable?.dispose(); - this._remotesDisposable = undefined; - } + // if (caches.length === 0 || caches.includes('remotes')) { + // this._remotes = undefined; + // this._remotesDisposable?.dispose(); + // this._remotesDisposable = undefined; + // } } resume() { @@ -955,13 +994,13 @@ export class Repository implements Disposable { } if (this._pendingFileSystemChange != null) { - this._fireFileSystemChangeDebounced!(); + this._fireFileSystemChangeDebounced?.(); } } @log() revert(...args: string[]) { - this.runTerminalCommand('revert', ...args); + void this.runTerminalCommand('revert', ...args); } @debug() @@ -969,7 +1008,7 @@ export class Repository implements Disposable { search: SearchQuery, options?: { limit?: number; ordering?: 'date' | 'author-date' | 'topo'; skip?: number }, ): Promise { - return this.container.git.richSearchCommits(this.path, search, options); + return this.container.git.richSearchCommits(this.uri, search, options); } @debug() @@ -977,11 +1016,11 @@ export class Repository implements Disposable { search: SearchQuery, options?: { cancellation?: CancellationToken; limit?: number; ordering?: 'date' | 'author-date' | 'topo' }, ): Promise { - return this.container.git.searchCommits(this.path, search, options); + return this.container.git.searchCommits(this.uri, search, options); } async setRemoteAsDefault(remote: GitRemote, value: boolean = true) { - await this.container.storage.storeWorkspace('remote:default', value ? remote.id : undefined); + await this.container.storage.storeWorkspace('remote:default', value ? remote.name : undefined); this.fireChange(RepositoryChange.Remotes, RepositoryChange.RemoteProviders); } @@ -998,7 +1037,7 @@ export class Repository implements Disposable { @gate() @log() async stashApply(stashName: string, options?: { deleteAfter?: boolean }) { - await this.container.git.stashApply(this.path, stashName, options); + await this.container.git.stashApply(this.uri, stashName, options); this.fireChange(RepositoryChange.Stash); } @@ -1006,15 +1045,35 @@ export class Repository implements Disposable { @gate() @log() async stashDelete(stashName: string, ref?: string) { - await this.container.git.stashDelete(this.path, stashName, ref); + await this.container.git.stashDelete(this.uri, stashName, ref); + + this.fireChange(RepositoryChange.Stash); + } + + @gate() + @log() + async stashRename(stashName: string, ref: string, message: string, stashOnRef?: string) { + await this.container.git.stashRename(this.uri, stashName, ref, message, stashOnRef); this.fireChange(RepositoryChange.Stash); } @gate() @log() - async stashSave(message?: string, uris?: Uri[], options?: { includeUntracked?: boolean; keepIndex?: boolean }) { - await this.container.git.stashSave(this.path, message, uris, options); + async stashSave( + message?: string, + uris?: Uri[], + options?: { includeUntracked?: boolean; keepIndex?: boolean; onlyStaged?: boolean }, + ): Promise { + await this.container.git.stashSave(this.uri, message, uris, options); + + this.fireChange(RepositoryChange.Stash); + } + + @gate() + @log() + async stashSaveSnapshot(message?: string): Promise { + await this.container.git.stashSaveSnapshot(this.uri, message); this.fireChange(RepositoryChange.Stash); } @@ -1037,7 +1096,7 @@ export class Repository implements Disposable { private async switchCore(ref: string, options?: { createBranch?: string }) { try { - await this.container.git.checkout(this.path, ref, options); + await this.container.git.checkout(this.uri, ref, options); this.fireChange(RepositoryChange.Unknown); } catch (ex) { @@ -1047,7 +1106,7 @@ export class Repository implements Disposable { } toAbsoluteUri(path: string, options?: { validate?: boolean }): Uri | undefined { - const uri = this.container.git.getAbsoluteUri(path, this.path); + const uri = this.container.git.getAbsoluteUri(path, this.uri); return !(options?.validate ?? true) || this.containsUri(uri) ? uri : undefined; } @@ -1079,8 +1138,32 @@ export class Repository implements Disposable { return this._etagFileSystem; } - startWatchingFileSystem(): Disposable { - this._fsWatchCounter++; + suspend() { + this._suspended = true; + } + + @log() + tag(...args: string[]) { + void this.runTerminalCommand('tag', ...args); + } + + @log() + tagDelete(tags: GitTagReference | GitTagReference[]) { + if (!Array.isArray(tags)) { + tags = [tags]; + } + + const args = ['--delete']; + void this.runTerminalCommand('tag', ...args, ...tags.map(t => t.ref)); + } + + private _fsWatcherDisposable: Disposable | undefined; + private _fsWatchers = new Map(); + private _fsChangeDelay: number = defaultFileSystemChangeDelay; + + watchFileSystem(delay: number = defaultFileSystemChangeDelay): Disposable { + const id = uuid(); + this._fsWatchers.set(id, delay); if (this._fsWatcherDisposable == null) { const watcher = workspace.createFileSystemWatcher(new RelativePattern(this.uri, '**')); this._fsWatcherDisposable = Disposable.from( @@ -1093,36 +1176,35 @@ export class Repository implements Disposable { this._etagFileSystem = Date.now(); } - return { dispose: () => this.stopWatchingFileSystem() }; + this.ensureMinFileSystemChangeDelay(); + + return { dispose: () => this.unWatchFileSystem(id) }; } - stopWatchingFileSystem(force: boolean = false) { - if (this._fsWatcherDisposable == null) return; - if (--this._fsWatchCounter > 0 && !force) return; + private unWatchFileSystem(forceOrId: true | string) { + if (typeof forceOrId !== 'boolean') { + this._fsWatchers.delete(forceOrId); + if (this._fsWatchers.size !== 0) { + this.ensureMinFileSystemChangeDelay(); + return; + } + } this._etagFileSystem = undefined; - this._fsWatchCounter = 0; - this._fsWatcherDisposable.dispose(); + this._fsChangeDelay = defaultFileSystemChangeDelay; + this._fsWatchers.clear(); + this._fsWatcherDisposable?.dispose(); this._fsWatcherDisposable = undefined; } - suspend() { - this._suspended = true; - } + private ensureMinFileSystemChangeDelay() { + const minDelay = min(this._fsWatchers.values()); + if (minDelay === this._fsChangeDelay) return; - @log() - tag(...args: string[]) { - this.runTerminalCommand('tag', ...args); - } - - @log() - tagDelete(tags: GitTagReference | GitTagReference[]) { - if (!Array.isArray(tags)) { - tags = [tags]; - } - - const args = ['--delete']; - this.runTerminalCommand('tag', ...args, ...tags.map(t => t.ref)); + this._fsChangeDelay = minDelay; + this._fireFileSystemChangeDebounced?.flush(); + this._fireFileSystemChangeDebounced?.cancel(); + this._fireFileSystemChangeDebounced = undefined; } @debug() @@ -1132,7 +1214,7 @@ export class Repository implements Disposable { this._updatedAt = Date.now(); if (this._fireChangeDebounced == null) { - this._fireChangeDebounced = debounce(this.fireChangeCore.bind(this), 250); + this._fireChangeDebounced = debounce(this.fireChangeCore.bind(this), defaultRepositoryChangeDelay); } this._pendingRepoChange = this._pendingRepoChange?.with(changes) ?? new RepositoryChangeEvent(this, changes); @@ -1165,7 +1247,10 @@ export class Repository implements Disposable { this._updatedAt = Date.now(); if (this._fireFileSystemChangeDebounced == null) { - this._fireFileSystemChangeDebounced = debounce(this.fireFileSystemChangeCore.bind(this), 2500); + this._fireFileSystemChangeDebounced = debounce( + this.fireFileSystemChangeCore.bind(this), + this._fsChangeDelay, + ); } if (this._pendingFileSystemChange == null) { @@ -1189,7 +1274,7 @@ export class Repository implements Disposable { this._pendingFileSystemChange = undefined; - const uris = await this.container.git.excludeIgnoredUris(this.path, e.uris); + const uris = await this.container.git.excludeIgnoredUris(this.uri, e.uris); if (uris.length === 0) return; if (uris.length !== e.uris.length) { @@ -1201,9 +1286,8 @@ export class Repository implements Disposable { this._onDidChangeFileSystem.fire(e); } - private runTerminalCommand(command: string, ...args: string[]) { - const parsedArgs = args.map(arg => (arg.startsWith('#') || /['();$|>&<]/.test(arg) ? `"${arg}"` : arg)); - runGitCommandInTerminal(command, parsedArgs.join(' '), this.path, true); + private async runTerminalCommand(command: string, ...args: string[]) { + await this.container.git.runGitCommandViaTerminal?.(this.uri, command, args, { execute: true }); setTimeout(() => this.fireChange(RepositoryChange.Unknown), 2500); } @@ -1212,3 +1296,40 @@ export class Repository implements Disposable { export function isRepository(repository: unknown): repository is Repository { return repository instanceof Repository; } + +export async function groupRepositories(repositories: Repository[]): Promise>> { + const repos = new Map(repositories.map(r => [r.id, r])); + + // Group worktree repos under the common repo when the common repo is also in the list + const result = new Map }>(); + for (const [, repo] of repos) { + const commonUri = await repo.getCommonRepositoryUri(); + if (commonUri == null) { + if (result.has(repo.id)) { + debugger; + } + result.set(repo.id, { repo: repo, worktrees: new Map() }); + continue; + } + + const commonId = asRepoComparisonKey(commonUri); + const commonRepo = repos.get(commonId); + if (commonRepo == null) { + if (result.has(repo.id)) { + debugger; + } + result.set(repo.id, { repo: repo, worktrees: new Map() }); + continue; + } + + let r = result.get(commonRepo.id); + if (r == null) { + r = { repo: commonRepo, worktrees: new Map() }; + result.set(commonRepo.id, r); + } else { + r.worktrees.set(repo.path, repo); + } + } + + return new Map(map(result, ([, r]) => [r.repo, r.worktrees])); +} diff --git a/src/git/models/repositoryMetadata.ts b/src/git/models/repositoryMetadata.ts new file mode 100644 index 0000000000000..44002f99d2ec0 --- /dev/null +++ b/src/git/models/repositoryMetadata.ts @@ -0,0 +1,12 @@ +import type { ProviderReference } from './remoteProvider'; + +export interface RepositoryMetadata { + provider: ProviderReference; + owner: string; + name: string; + isFork: boolean; + parent?: { + owner: string; + name: string; + }; +} diff --git a/src/git/models/status.ts b/src/git/models/status.ts index 1629505bbacb3..ff6ac706c23b6 100644 --- a/src/git/models/status.ts +++ b/src/git/models/status.ts @@ -3,12 +3,20 @@ import { GlyphChars } from '../../constants'; import { Container } from '../../container'; import { memoize } from '../../system/decorators/memoize'; import { pluralize } from '../../system/string'; -import type { GitTrackingState } from './branch'; +import type { GitBranchStatus, GitTrackingState } from './branch'; import { formatDetachedHeadName, getRemoteNameFromBranchName, isDetachedHead } from './branch'; import { GitCommit, GitCommitIdentity } from './commit'; -import type { GitFileStatus } from './file'; -import { GitFile, GitFileChange, GitFileConflictStatus, GitFileIndexStatus, GitFileWorkingTreeStatus } from './file'; -import { GitRevision } from './reference'; +import { uncommitted, uncommittedStaged } from './constants'; +import type { GitFile, GitFileStatus } from './file'; +import { + getGitFileFormattedDirectory, + getGitFileFormattedPath, + getGitFileStatusText, + GitFileChange, + GitFileConflictStatus, + GitFileIndexStatus, + GitFileWorkingTreeStatus, +} from './file'; import type { GitRemote } from './remote'; import type { GitUser } from './user'; @@ -35,7 +43,7 @@ export class GitStatus { public readonly sha: string, public readonly files: GitStatusFile[], public readonly state: GitTrackingState, - public readonly upstream?: string, + public readonly upstream?: { name: string; missing: boolean }, public readonly rebasing: boolean = false, ) { this.detached = isDetachedHead(branch); @@ -53,6 +61,11 @@ export class GitStatus { return this.files.length !== 0; } + @memoize() + get hasWorkingTreeChanges() { + return this.files.some(f => f.workingTreeStatus != null); + } + @memoize() get hasConflicts() { return this.files.some(f => f.conflicted); @@ -62,6 +75,16 @@ export class GitStatus { return this.detached ? this.sha : this.branch; } + get branchStatus(): GitBranchStatus { + if (this.upstream == null) return this.detached ? 'detached' : 'local'; + + if (this.upstream.missing) return 'missingUpstream'; + if (this.state.ahead && this.state.behind) return 'diverged'; + if (this.state.ahead) return 'ahead'; + if (this.state.behind) return 'behind'; + return 'upToDate'; + } + @memoize() computeWorkingTreeStatus(): ComputedWorkingTreeGitStatus { let conflictedAdds = 0; @@ -245,11 +268,11 @@ export class GitStatus { const remotes = await Container.instance.git.getRemotesWithProviders(this.repoPath); if (remotes.length === 0) return undefined; - const remoteName = getRemoteNameFromBranchName(this.upstream); + const remoteName = getRemoteNameFromBranchName(this.upstream?.name); return remotes.find(r => r.name === remoteName); } - getUpstreamStatus(options: { + getUpstreamStatus(options?: { empty?: string; expand?: boolean; icons?: boolean; @@ -257,11 +280,7 @@ export class GitStatus { separator?: string; suffix?: string; }): string { - return getUpstreamStatus( - this.upstream ? { name: this.upstream, missing: false } : undefined, - this.state, - options, - ); + return getUpstreamStatus(this.upstream, this.state, options); } } @@ -303,7 +322,7 @@ export function getUpstreamStatus( status += `${status.length === 0 ? '' : separator}${pluralize('commit', state.ahead, { infix: icons ? '$(arrow-up) ' : undefined, })} ahead`; - if (suffix.startsWith(` ${upstream.name.split('/')[0]}`)) { + if (suffix.includes(upstream.name.split('/')[0])) { status += ' of'; } } @@ -383,7 +402,7 @@ export class GitStatusFile implements GitFile { switch (y) { case 'A': - case '?': + // case '?': this.workingTreeStatus = GitFileWorkingTreeStatus.Added; break; case 'D': @@ -400,14 +419,11 @@ export class GitStatusFile implements GitFile { return this.conflictStatus != null; } - get edited() { - return this.workingTreeStatus != null; - } - get staged() { return this.indexStatus != null; } + @memoize() get status(): GitFileStatus { return (this.conflictStatus ?? this.indexStatus ?? this.workingTreeStatus)!; } @@ -417,105 +433,107 @@ export class GitStatusFile implements GitFile { return Container.instance.git.getAbsoluteUri(this.path, this.repoPath); } - getFormattedDirectory(includeOriginal: boolean = false): string { - return GitFile.getFormattedDirectory(this, includeOriginal); + get wip() { + return this.workingTreeStatus != null; } - getFormattedPath(options: { relativeTo?: string; suffix?: string; truncateTo?: number } = {}): string { - return GitFile.getFormattedPath(this, options); + getFormattedDirectory(includeOriginal: boolean = false): string { + return getGitFileFormattedDirectory(this, includeOriginal); } - getOcticon() { - return GitFile.getStatusCodicon(this.status); + getFormattedPath(options: { relativeTo?: string; suffix?: string; truncateTo?: number } = {}): string { + return getGitFileFormattedPath(this, options); } getStatusText(): string { - return GitFile.getStatusText(this.status); + return getGitFileStatusText(this.status); } getPseudoCommits(container: Container, user: GitUser | undefined): GitCommit[] { - const commits: GitCommit[] = []; - const now = new Date(); - if (this.conflictStatus != null) { - commits.push( + if (this.conflicted) { + const file = new GitFileChange( + this.repoPath, + this.path, + this.status, + this.originalPath, + 'HEAD', + undefined, + false, + ); + return [ new GitCommit( container, this.repoPath, - GitRevision.uncommitted, + uncommitted, new GitCommitIdentity('You', user?.email ?? undefined, now), new GitCommitIdentity('You', user?.email ?? undefined, now), 'Uncommitted changes', - [GitRevision.uncommittedStaged], + ['HEAD'], 'Uncommitted changes', - new GitFileChange( - this.repoPath, - this.path, - this.status, - this.originalPath, - GitRevision.uncommittedStaged, - ), + { file: file, files: [file] }, undefined, [], ), - ); - return commits; + ]; } - if (this.workingTreeStatus == null && this.indexStatus == null) return commits; - - if (this.workingTreeStatus != null && this.indexStatus != null) { - // Decrements the date to guarantee the staged entry will be sorted after the working entry (most recent first) - const older = new Date(now); - older.setMilliseconds(older.getMilliseconds() - 1); - + const commits: GitCommit[] = []; + const staged = this.staged; + + if (this.wip) { + const previousSha = staged ? uncommittedStaged : 'HEAD'; + const file = new GitFileChange( + this.repoPath, + this.path, + this.status, + this.originalPath, + previousSha, + undefined, + false, + ); commits.push( new GitCommit( container, this.repoPath, - GitRevision.uncommitted, + uncommitted, new GitCommitIdentity('You', user?.email ?? undefined, now), new GitCommitIdentity('You', user?.email ?? undefined, now), 'Uncommitted changes', - [GitRevision.uncommittedStaged], - 'Uncommitted changes', - new GitFileChange( - this.repoPath, - this.path, - this.status, - this.originalPath, - GitRevision.uncommittedStaged, - ), - undefined, - [], - ), - new GitCommit( - container, - this.repoPath, - GitRevision.uncommittedStaged, - new GitCommitIdentity('You', user?.email ?? undefined, older), - new GitCommitIdentity('You', user?.email ?? undefined, older), - 'Uncommitted changes', - ['HEAD'], + [previousSha], 'Uncommitted changes', - new GitFileChange(this.repoPath, this.path, this.status, this.originalPath, 'HEAD'), + { file: file, files: [file] }, undefined, [], ), ); - } else { + + // Decrements the date to guarantee the staged entry (if exists) will be sorted after the working entry (most recent first) + now.setMilliseconds(now.getMilliseconds() - 1); + } + + if (staged) { + const file = new GitFileChange( + this.repoPath, + this.path, + this.status, + this.originalPath, + 'HEAD', + undefined, + true, + ); commits.push( new GitCommit( container, this.repoPath, - this.workingTreeStatus != null ? GitRevision.uncommitted : GitRevision.uncommittedStaged, + uncommittedStaged, new GitCommitIdentity('You', user?.email ?? undefined, now), new GitCommitIdentity('You', user?.email ?? undefined, now), 'Uncommitted changes', ['HEAD'], 'Uncommitted changes', - new GitFileChange(this.repoPath, this.path, this.status, this.originalPath, 'HEAD'), + { file: file, files: [file] }, undefined, [], ), @@ -524,4 +542,37 @@ export class GitStatusFile implements GitFile { return commits; } + + getPseudoFileChanges(): GitFileChange[] { + if (this.conflicted) { + return [ + new GitFileChange(this.repoPath, this.path, this.status, this.originalPath, 'HEAD', undefined, false), + ]; + } + + const files: GitFileChange[] = []; + const staged = this.staged; + + if (this.wip) { + files.push( + new GitFileChange( + this.repoPath, + this.path, + this.status, + this.originalPath, + staged ? uncommittedStaged : 'HEAD', + undefined, + false, + ), + ); + } + + if (staged) { + files.push( + new GitFileChange(this.repoPath, this.path, this.status, this.originalPath, 'HEAD', undefined, true), + ); + } + + return files; + } } diff --git a/src/git/models/tag.ts b/src/git/models/tag.ts index a87f96285c84e..7c11bdb4f922e 100644 --- a/src/git/models/tag.ts +++ b/src/git/models/tag.ts @@ -1,9 +1,10 @@ -import { configuration, DateStyle, TagSorting } from '../../configuration'; +import type { TagSorting } from '../../config'; import { Container } from '../../container'; -import { getLoggableName } from '../../logger'; import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; +import { getLoggableName } from '../../system/logger'; import { sortCompare } from '../../system/string'; +import { configuration } from '../../system/vscode/configuration'; import type { GitReference, GitTagReference } from './reference'; export interface TagSortOptions { @@ -35,7 +36,7 @@ export class GitTag implements GitTagReference { } get formattedDate(): string { - return Container.instance.TagDateFormatting.dateStyle === DateStyle.Absolute + return Container.instance.TagDateFormatting.dateStyle === 'absolute' ? this.formatDate(Container.instance.TagDateFormatting.dateFormat) : this.formatDateFromNow(); } @@ -81,13 +82,13 @@ export function sortTags(tags: GitTag[], options?: TagSortOptions) { options = { orderBy: configuration.get('sortTagsBy'), ...options }; switch (options.orderBy) { - case TagSorting.DateAsc: + case 'date:asc': return tags.sort((a, b) => (a.date?.getTime() ?? 0) - (b.date?.getTime() ?? 0)); - case TagSorting.NameAsc: + case 'name:asc': return tags.sort((a, b) => sortCompare(a.name, b.name)); - case TagSorting.NameDesc: + case 'name:desc': return tags.sort((a, b) => sortCompare(b.name, a.name)); - case TagSorting.DateDesc: + case 'date:desc': default: return tags.sort((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0)); } diff --git a/src/git/models/tree.ts b/src/git/models/tree.ts index 6eaee3cb2a018..ec2f2ad4a3f51 100644 --- a/src/git/models/tree.ts +++ b/src/git/models/tree.ts @@ -1,6 +1,14 @@ export interface GitTreeEntry { - commitSha: string; + ref: string; + oid: string; path: string; size: number; type: 'blob' | 'tree'; } + +export interface GitLsFilesEntry { + mode: string; + oid: string; + path: string; + stage: number; +} diff --git a/src/git/models/worktree.ts b/src/git/models/worktree.ts index 192240e6c6dbe..0393f34234ef8 100644 --- a/src/git/models/worktree.ts +++ b/src/git/models/worktree.ts @@ -1,28 +1,60 @@ -import type { Uri, WorkspaceFolder } from 'vscode'; -import { workspace } from 'vscode'; +import type { QuickInputButton, Uri, WorkspaceFolder } from 'vscode'; +import { ThemeIcon, workspace } from 'vscode'; +import type { BranchSorting } from '../../config'; +import { GlyphChars } from '../../constants'; import { Container } from '../../container'; +import type { QuickPickItemOfT } from '../../quickpicks/items/common'; +import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; -import { normalizePath, relative } from '../../system/path'; +import { filterMap } from '../../system/iterable'; +import { PageableResult } from '../../system/paging'; +import { normalizePath } from '../../system/path'; +import { pad, sortCompare } from '../../system/string'; +import { configuration } from '../../system/vscode/configuration'; +import { relative } from '../../system/vscode/path'; +import { getWorkspaceFriendlyPath } from '../../system/vscode/utils'; +import { getBranchIconPath } from '../utils/branch-utils'; import type { GitBranch } from './branch'; -import { GitRevision } from './reference'; +import { shortenRevision } from './reference'; +import type { Repository } from './repository'; import type { GitStatus } from './status'; export class GitWorktree { - static is(worktree: any): worktree is GitWorktree { - return worktree instanceof GitWorktree; - } - constructor( - public readonly main: boolean, + private readonly container: Container, + public readonly isDefault: boolean, public readonly type: 'bare' | 'branch' | 'detached', public readonly repoPath: string, public readonly uri: Uri, public readonly locked: boolean | string, public readonly prunable: boolean | string, public readonly sha?: string, - public readonly branch?: string, + public readonly branch?: GitBranch, ) {} + get date(): Date | undefined { + return this.branch?.date; + } + + @memoize() + get friendlyPath(): string { + const folder = this.workspaceFolder; + if (folder != null) return getWorkspaceFriendlyPath(this.uri); + + const relativePath = normalizePath(relative(this.repoPath, this.uri.fsPath)); + return relativePath || normalizePath(this.uri.fsPath); + } + + get formattedDate(): string { + return this.container.BranchDateFormatting.dateStyle === 'absolute' + ? this.formatDate(this.container.BranchDateFormatting.dateFormat) + : this.formatDateFromNow(); + } + + get hasChanges(): boolean | undefined { + return this._status?.hasChanges; + } + get opened(): boolean { return this.workspaceFolder?.uri.toString() === this.uri.toString(); } @@ -32,50 +64,328 @@ export class GitWorktree { case 'bare': return '(bare)'; case 'detached': - return GitRevision.shorten(this.sha); + return shortenRevision(this.sha); default: - return this.branch || this.friendlyPath; + return this.branch?.name || this.friendlyPath; } } - @memoize() - get friendlyPath(): string { - const path = GitWorktree.getFriendlyPath(this.uri); - return path; - } - @memoize() get workspaceFolder(): WorkspaceFolder | undefined { return workspace.getWorkspaceFolder(this.uri); } - private _branch: Promise | undefined; - getBranch(): Promise { - if (this.type !== 'branch' || this.branch == null) return Promise.resolve(undefined); + @memoize(format => format ?? 'MMMM Do, YYYY h:mma') + formatDate(format?: string | null): string { + return this.date != null ? formatDate(this.date, format ?? 'MMMM Do, YYYY h:mma') : ''; + } - if (this._branch == null) { - this._branch = Container.instance.git - .getBranches(this.repoPath, { filter: b => b.name === this.branch }) - .then(b => b.values[0]); - } - return this._branch; + formatDateFromNow(): string { + return this.date != null ? fromNow(this.date) : ''; } - private _status: Promise | undefined; - getStatus(options?: { force?: boolean }): Promise { + private _status: GitStatus | undefined; + private _statusPromise: Promise | undefined; + async getStatus(options?: { force?: boolean }): Promise { if (this.type === 'bare') return Promise.resolve(undefined); - if (this._status == null || options?.force) { - this._status = Container.instance.git.getStatusForRepo(this.uri.fsPath); + if (this._statusPromise == null || options?.force) { + // eslint-disable-next-line no-async-promise-executor + this._statusPromise = new Promise(async (resolve, reject) => { + try { + const status = await Container.instance.git.getStatusForRepo(this.uri.fsPath); + this._status = status; + resolve(status); + } catch (ex) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(ex); + } + }); } - return this._status; + return this._statusPromise; } +} - static getFriendlyPath(uri: Uri): string { - const folder = workspace.getWorkspaceFolder(uri); - if (folder == null) return normalizePath(uri.fsPath); +export interface WorktreeQuickPickItem extends QuickPickItemOfT { + readonly opened: boolean; + readonly hasChanges: boolean | undefined; +} + +export function createWorktreeQuickPickItem( + worktree: GitWorktree, + picked?: boolean, + missing?: boolean, + options?: { + alwaysShow?: boolean; + buttons?: QuickInputButton[]; + checked?: boolean; + includeStatus?: boolean; + message?: boolean; + path?: boolean; + type?: boolean; + status?: GitStatus; + }, +) { + let description = ''; + let detail = ''; + if (options?.type) { + description = 'worktree'; + } + + if (options?.includeStatus) { + let status = ''; + let blank = 0; + if (options?.status != null) { + if (options.status.upstream?.missing) { + status += GlyphChars.Warning; + blank += 3; + } else { + if (options.status.state.behind) { + status += GlyphChars.ArrowDown; + } else { + blank += 2; + } + + if (options.status.state.ahead) { + status += GlyphChars.ArrowUp; + } else { + blank += 2; + } + + if (options.status.hasChanges) { + status += '\u00B1'; + } else { + blank += 2; + } + } + } else { + blank += 6; + } + + if (blank > 0) { + status += ' '.repeat(blank); + } + + const formattedDate = worktree.formattedDate; + if (formattedDate) { + if (description) { + description += ` ${GlyphChars.Dot} ${worktree.formattedDate}`; + } else { + description = formattedDate; + } + } + + if (status) { + detail += detail ? ` ${GlyphChars.Dot} ${status}` : status; + } + } + + let iconPath; + let label; + switch (worktree.type) { + case 'bare': + label = '(bare)'; + iconPath = new ThemeIcon('folder'); + break; + case 'branch': + label = worktree.branch?.name ?? 'unknown'; + iconPath = getBranchIconPath(Container.instance, worktree.branch); + break; + case 'detached': + label = shortenRevision(worktree.sha); + iconPath = new ThemeIcon('git-commit'); + break; + } + + const item: WorktreeQuickPickItem = { + label: options?.checked ? `${label}${pad('$(check)', 2)}` : label, + description: description ? ` ${description}` : undefined, + detail: options?.path + ? `${detail ? `${detail} ` : ''}${missing ? `${GlyphChars.Warning} (missing)` : '$(folder)'} ${ + worktree.friendlyPath + }` + : detail, + alwaysShow: options?.alwaysShow, + buttons: options?.buttons, + picked: picked, + item: worktree, + opened: worktree.opened, + hasChanges: options?.status?.hasChanges, + iconPath: iconPath, + }; + + return item; +} - const relativePath = normalizePath(relative(folder.uri.fsPath, uri.fsPath)); - return relativePath.length === 0 ? folder.name : relativePath; +export async function getWorktreeForBranch( + repo: Repository, + branchName: string, + upstreamNames?: string | string[], + worktrees?: GitWorktree[], + branches?: PageableResult | Map, +): Promise { + if (upstreamNames != null && !Array.isArray(upstreamNames)) { + upstreamNames = [upstreamNames]; + } + + worktrees ??= await repo.getWorktrees(); + for (const worktree of worktrees) { + if (worktree.branch?.name === branchName) return worktree; + + if (upstreamNames == null || worktree.branch == null) continue; + + branches ??= new PageableResult(p => repo.getBranches(p != null ? { paging: p } : undefined)); + for await (const branch of branches.values()) { + if (branch.name === worktree.branch.name) { + if ( + branch.upstream?.name != null && + (upstreamNames.includes(branch.upstream.name) || + (branch.upstream.name.startsWith('remotes/') && + upstreamNames.includes(branch.upstream.name.substring(8)))) + ) { + return worktree; + } + + break; + } + } + } + + return undefined; +} + +export function getWorktreeId(repoPath: string, name: string): string { + return `${repoPath}|worktrees/${name}`; +} + +export function isWorktree(worktree: any): worktree is GitWorktree { + return worktree instanceof GitWorktree; +} + +export interface WorktreeSortOptions { + orderBy?: BranchSorting; +} +export function sortWorktrees(worktrees: GitWorktree[], options?: WorktreeSortOptions): GitWorktree[]; +export function sortWorktrees( + worktrees: WorktreeQuickPickItem[], + options?: WorktreeSortOptions, +): WorktreeQuickPickItem[]; +export function sortWorktrees(worktrees: GitWorktree[] | WorktreeQuickPickItem[], options?: WorktreeSortOptions) { + options = { orderBy: configuration.get('sortBranchesBy'), ...options }; + + const getWorktree = (worktree: GitWorktree | WorktreeQuickPickItem): GitWorktree => { + return isWorktree(worktree) ? worktree : worktree.item; + }; + + switch (options.orderBy) { + case 'date:asc': + return worktrees.sort((a, b) => { + a = getWorktree(a); + b = getWorktree(b); + + return ( + (a.opened ? -1 : 1) - (b.opened ? -1 : 1) || + (a.hasChanges === null ? 0 : a.hasChanges ? -1 : 1) - + (b.hasChanges === null ? 0 : b.hasChanges ? -1 : 1) || + (a.date == null ? -1 : a.date.getTime()) - (b.date == null ? -1 : b.date.getTime()) || + sortCompare(a.name, b.name) + ); + }); + case 'name:asc': + return worktrees.sort((a, b) => { + a = getWorktree(a); + b = getWorktree(b); + + return ( + (a.opened ? -1 : 1) - (b.opened ? -1 : 1) || + (a.hasChanges === null ? 0 : a.hasChanges ? -1 : 1) - + (b.hasChanges === null ? 0 : b.hasChanges ? -1 : 1) || + (a.name === 'main' ? -1 : 1) - (b.name === 'main' ? -1 : 1) || + (a.name === 'master' ? -1 : 1) - (b.name === 'master' ? -1 : 1) || + (a.name === 'develop' ? -1 : 1) - (b.name === 'develop' ? -1 : 1) || + sortCompare(a.name, b.name) + ); + }); + case 'name:desc': + return worktrees.sort((a, b) => { + a = getWorktree(a); + b = getWorktree(b); + + return ( + (a.opened ? -1 : 1) - (b.opened ? -1 : 1) || + (a.hasChanges === null ? 0 : a.hasChanges ? -1 : 1) - + (b.hasChanges === null ? 0 : b.hasChanges ? -1 : 1) || + (a.name === 'main' ? -1 : 1) - (b.name === 'main' ? -1 : 1) || + (a.name === 'master' ? -1 : 1) - (b.name === 'master' ? -1 : 1) || + (a.name === 'develop' ? -1 : 1) - (b.name === 'develop' ? -1 : 1) || + sortCompare(b.name, a.name) + ); + }); + case 'date:desc': + default: + return worktrees.sort((a, b) => { + a = getWorktree(a); + b = getWorktree(b); + + return ( + (a.opened ? -1 : 1) - (b.opened ? -1 : 1) || + (b.date == null ? -1 : b.date.getTime()) - (a.date == null ? -1 : a.date.getTime()) || + (a.hasChanges === null ? 0 : a.hasChanges ? -1 : 1) - + (b.hasChanges === null ? 0 : b.hasChanges ? -1 : 1) || + sortCompare(b.name, a.name) + ); + }); + } +} + +export async function getWorktreesByBranch( + repos: Repository | Repository[] | undefined, + options?: { includeDefault?: boolean }, +) { + const worktreesByBranch = new Map(); + if (repos == null) return worktreesByBranch; + + async function addWorktrees(repo: Repository) { + groupWorktreesByBranch(await repo.getWorktrees(), { + includeDefault: options?.includeDefault, + worktreesByBranch: worktreesByBranch, + }); + } + + if (!Array.isArray(repos)) { + await addWorktrees(repos); + } else { + await Promise.allSettled(repos.map(async r => addWorktrees(r))); + } + + return worktreesByBranch; +} + +export function groupWorktreesByBranch( + worktrees: GitWorktree[], + options?: { includeDefault?: boolean; worktreesByBranch?: Map }, +) { + const worktreesByBranch = options?.worktreesByBranch ?? new Map(); + if (worktrees == null) return worktreesByBranch; + + for (const wt of worktrees) { + if (wt.branch == null || (!options?.includeDefault && wt.isDefault)) continue; + + worktreesByBranch.set(wt.branch.id, wt); + } + + return worktreesByBranch; +} + +export function getOpenedWorktreesByBranch( + worktreesByBranch: Map | undefined, +): Set | undefined { + let openedWorktreesByBranch: Set | undefined; + if (worktreesByBranch?.size) { + openedWorktreesByBranch = new Set(filterMap(worktreesByBranch, ([id, wt]) => (wt.opened ? id : undefined))); + if (!openedWorktreesByBranch.size) { + openedWorktreesByBranch = undefined; + } } + return openedWorktreesByBranch; } diff --git a/src/git/parsers/blameParser.ts b/src/git/parsers/blameParser.ts index 4c0969776d1ae..4b1955c9bc5da 100644 --- a/src/git/parsers/blameParser.ts +++ b/src/git/parsers/blameParser.ts @@ -1,11 +1,12 @@ import type { Container } from '../../container'; -import { debug } from '../../system/decorators/log'; +import { maybeStopWatch } from '../../system/stopwatch'; import { getLines } from '../../system/string'; import type { GitBlame, GitBlameAuthor } from '../models/blame'; import type { GitCommitLine } from '../models/commit'; import { GitCommit, GitCommitIdentity } from '../models/commit'; +import { uncommitted } from '../models/constants'; import { GitFileChange, GitFileIndexStatus } from '../models/file'; -import { GitRevision } from '../models/reference'; +import { isUncommitted } from '../models/reference'; import type { GitUser } from '../models/user'; interface BlameEntry { @@ -16,12 +17,12 @@ interface BlameEntry { lineCount: number; author: string; - authorDate?: string; + authorTime: number; authorTimeZone?: string; authorEmail?: string; committer: string; - committerDate?: string; + committerTime: number; committerTimeZone?: string; committerEmail?: string; @@ -33,228 +34,232 @@ interface BlameEntry { summary?: string; } -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitBlameParser { - @debug({ args: false, singleLine: true }) - static parse( - container: Container, - data: string, - repoPath: string, - currentUser: GitUser | undefined, - ): GitBlame | undefined { - if (!data) return undefined; - - const authors = new Map(); - const commits = new Map(); - const lines: GitCommitLine[] = []; - - let entry: BlameEntry | undefined = undefined; - let key: string; - let line: string; - let lineParts: string[]; - - for (line of getLines(data)) { - lineParts = line.split(' '); - if (lineParts.length < 2) continue; - - [key] = lineParts; - if (entry == null) { - entry = { - sha: key, - originalLine: parseInt(lineParts[1], 10), - line: parseInt(lineParts[2], 10), - lineCount: parseInt(lineParts[3], 10), - } as unknown as BlameEntry; - - continue; - } +export function parseGitBlame( + container: Container, + repoPath: string, + data: string | undefined, + currentUser: GitUser | undefined, + modifiedTime?: number, +): GitBlame | undefined { + using sw = maybeStopWatch(`Git.parseBlame(${repoPath})`, { log: false, logLevel: 'debug' }); + if (!data) return undefined; + + const authors = new Map(); + const commits = new Map(); + const lines: GitCommitLine[] = []; + + let entry: BlameEntry | undefined = undefined; + let key: string; + let line: string; + let lineParts: string[]; + + for (line of getLines(data)) { + lineParts = line.split(' '); + if (lineParts.length < 2) continue; + + [key] = lineParts; + if (entry == null) { + entry = { + sha: key, + originalLine: parseInt(lineParts[1], 10), + line: parseInt(lineParts[2], 10), + lineCount: parseInt(lineParts[3], 10), + } as unknown as BlameEntry; + + continue; + } - switch (key) { - case 'author': - if (entry.sha === GitRevision.uncommitted) { - entry.author = 'You'; - } else { - entry.author = line.slice(key.length + 1).trim(); - } - break; + switch (key) { + case 'author': + if (entry.sha === uncommitted) { + entry.author = 'You'; + } else { + entry.author = line.slice(key.length + 1).trim(); + } + break; - case 'author-mail': { - if (entry.sha === GitRevision.uncommitted) { - entry.authorEmail = currentUser?.email; - continue; - } + case 'author-mail': { + if (entry.sha === uncommitted) { + entry.authorEmail = currentUser?.email; + continue; + } - entry.authorEmail = line.slice(key.length + 1).trim(); - const start = entry.authorEmail.indexOf('<'); - if (start >= 0) { - const end = entry.authorEmail.indexOf('>', start); - if (end > start) { - entry.authorEmail = entry.authorEmail.substring(start + 1, end); - } else { - entry.authorEmail = entry.authorEmail.substring(start + 1); - } + entry.authorEmail = line.slice(key.length + 1).trim(); + const start = entry.authorEmail.indexOf('<'); + if (start >= 0) { + const end = entry.authorEmail.indexOf('>', start); + if (end > start) { + entry.authorEmail = entry.authorEmail.substring(start + 1, end); + } else { + entry.authorEmail = entry.authorEmail.substring(start + 1); } + } - break; + break; + } + case 'author-time': + if (entry.sha === uncommitted && modifiedTime != null) { + entry.authorTime = modifiedTime; + } else { + entry.authorTime = parseInt(lineParts[1], 10) * 1000; } - case 'author-time': - entry.authorDate = lineParts[1]; - break; + break; - case 'author-tz': - entry.authorTimeZone = lineParts[1]; - break; + case 'author-tz': + entry.authorTimeZone = lineParts[1]; + break; - case 'committer': - if (GitRevision.isUncommitted(entry.sha)) { - entry.committer = 'You'; - } else { - entry.committer = line.slice(key.length + 1).trim(); - } - break; + case 'committer': + if (isUncommitted(entry.sha)) { + entry.committer = 'You'; + } else { + entry.committer = line.slice(key.length + 1).trim(); + } + break; - case 'committer-mail': { - if (GitRevision.isUncommitted(entry.sha)) { - entry.committerEmail = currentUser?.email; - continue; - } + case 'committer-mail': { + if (isUncommitted(entry.sha)) { + entry.committerEmail = currentUser?.email; + continue; + } - entry.committerEmail = line.slice(key.length + 1).trim(); - const start = entry.committerEmail.indexOf('<'); - if (start >= 0) { - const end = entry.committerEmail.indexOf('>', start); - if (end > start) { - entry.committerEmail = entry.committerEmail.substring(start + 1, end); - } else { - entry.committerEmail = entry.committerEmail.substring(start + 1); - } + entry.committerEmail = line.slice(key.length + 1).trim(); + const start = entry.committerEmail.indexOf('<'); + if (start >= 0) { + const end = entry.committerEmail.indexOf('>', start); + if (end > start) { + entry.committerEmail = entry.committerEmail.substring(start + 1, end); + } else { + entry.committerEmail = entry.committerEmail.substring(start + 1); } + } - break; + break; + } + case 'committer-time': + if (entry.sha === uncommitted && modifiedTime != null) { + entry.committerTime = modifiedTime; + } else { + entry.committerTime = parseInt(lineParts[1], 10) * 1000; } - case 'committer-time': - entry.committerDate = lineParts[1]; - break; + break; - case 'committer-tz': - entry.committerTimeZone = lineParts[1]; - break; + case 'committer-tz': + entry.committerTimeZone = lineParts[1]; + break; - case 'summary': - entry.summary = line.slice(key.length + 1).trim(); - break; + case 'summary': + entry.summary = line.slice(key.length + 1).trim(); + break; - case 'previous': - entry.previousSha = lineParts[1]; - entry.previousPath = lineParts.slice(2).join(' '); - break; + case 'previous': + entry.previousSha = lineParts[1]; + entry.previousPath = lineParts.slice(2).join(' '); + break; - case 'filename': - // Don't trim to allow spaces in the filename - entry.path = line.slice(key.length + 1); + case 'filename': + // Don't trim to allow spaces in the filename + entry.path = line.slice(key.length + 1); - // Since the filename marks the end of a commit, parse the entry and clear it for the next - GitBlameParser.parseEntry(container, entry, repoPath, commits, authors, lines, currentUser); + // Since the filename marks the end of a commit, parse the entry and clear it for the next + parseBlameEntry(container, entry, repoPath, commits, authors, lines, currentUser); - entry = undefined; - break; + entry = undefined; + break; - default: - break; - } + default: + break; } + } - for (const [, c] of commits) { - if (!c.author.name) continue; + for (const [, c] of commits) { + if (!c.author.name) continue; - const author = authors.get(c.author.name); - if (author == undefined) return undefined; + const author = authors.get(c.author.name); + if (author == undefined) return undefined; - author.lineCount += c.lines.length; - } + author.lineCount += c.lines.length; + } - const sortedAuthors = new Map([...authors.entries()].sort((a, b) => b[1].lineCount - a[1].lineCount)); + const sortedAuthors = new Map([...authors.entries()].sort((a, b) => b[1].lineCount - a[1].lineCount)); - const blame: GitBlame = { - repoPath: repoPath, - authors: sortedAuthors, - commits: commits, - lines: lines, - }; - return blame; - } + sw?.stop({ suffix: ` parsed ${lines.length} lines, ${commits.size} commits` }); - private static parseEntry( - container: Container, - entry: BlameEntry, - repoPath: string, - commits: Map, - authors: Map, - lines: GitCommitLine[], - currentUser: { name?: string; email?: string } | undefined, - ) { - let commit = commits.get(entry.sha); - if (commit == null) { - if (entry.author != null) { - if ( - currentUser != null && - // Name or e-mail is configured - (currentUser.name != null || currentUser.email != null) && - // Match on name if configured - (currentUser.name == null || currentUser.name === entry.author) && - // Match on email if configured - (currentUser.email == null || currentUser.email === entry.authorEmail) - ) { - entry.author = 'You'; - } + const blame: GitBlame = { + repoPath: repoPath, + authors: sortedAuthors, + commits: commits, + lines: lines, + }; + return blame; +} - let author = authors.get(entry.author); - if (author == null) { - author = { - name: entry.author, - lineCount: 0, - }; - authors.set(entry.author, author); - } +function parseBlameEntry( + container: Container, + entry: BlameEntry, + repoPath: string, + commits: Map, + authors: Map, + lines: GitCommitLine[], + currentUser: { name?: string; email?: string } | undefined, +) { + let commit = commits.get(entry.sha); + if (commit == null) { + if (entry.author != null) { + if ( + currentUser != null && + // Name or e-mail is configured + (currentUser.name != null || currentUser.email != null) && + // Match on name if configured + (currentUser.name == null || currentUser.name === entry.author) && + // Match on email if configured + (currentUser.email == null || currentUser.email === entry.authorEmail) + ) { + entry.author = 'You'; } - commit = new GitCommit( - container, - repoPath, - entry.sha, - new GitCommitIdentity(entry.author, entry.authorEmail, new Date((entry.authorDate as any) * 1000)), - new GitCommitIdentity( - entry.committer, - entry.committerEmail, - new Date((entry.committerDate as any) * 1000), - ), - entry.summary!, - [], - undefined, - new GitFileChange( - repoPath, - entry.path, - GitFileIndexStatus.Modified, - entry.previousPath && entry.previousPath !== entry.path ? entry.previousPath : undefined, - entry.previousSha, - ), - undefined, - [], - ); - - commits.set(entry.sha, commit); + let author = authors.get(entry.author); + if (author == null) { + author = { + name: entry.author, + lineCount: 0, + }; + authors.set(entry.author, author); + } } - for (let i = 0, len = entry.lineCount; i < len; i++) { - const line: GitCommitLine = { - sha: entry.sha, - previousSha: commit.file!.previousSha, - originalLine: entry.originalLine + i, - line: entry.line + i, - }; + commit = new GitCommit( + container, + repoPath, + entry.sha, + new GitCommitIdentity(entry.author, entry.authorEmail, new Date(entry.authorTime)), + new GitCommitIdentity(entry.committer, entry.committerEmail, new Date(entry.committerTime)), + entry.summary!, + [], + undefined, + new GitFileChange( + repoPath, + entry.path, + GitFileIndexStatus.Modified, + entry.previousPath && entry.previousPath !== entry.path ? entry.previousPath : undefined, + entry.previousSha, + ), + undefined, + [], + ); + + commits.set(entry.sha, commit); + } - commit.lines.push(line); - lines[line.line - 1] = line; - } + for (let i = 0, len = entry.lineCount; i < len; i++) { + const line: GitCommitLine = { + sha: entry.sha, + previousSha: commit.file!.previousSha, + originalLine: entry.originalLine + i, + line: entry.line + i, + }; + + commit.lines.push(line); + lines[line.line - 1] = line; } } diff --git a/src/git/parsers/branchParser.ts b/src/git/parsers/branchParser.ts index 8886255862df4..4e1b14ac9c64e 100644 --- a/src/git/parsers/branchParser.ts +++ b/src/git/parsers/branchParser.ts @@ -1,4 +1,5 @@ -import { debug } from '../../system/decorators/log'; +import type { Container } from '../../container'; +import { maybeStopWatch } from '../../system/stopwatch'; import { GitBranch } from '../models/branch'; const branchWithTrackingRegex = @@ -8,72 +9,72 @@ const branchWithTrackingRegex = const lb = '%3c'; // `%${'<'.charCodeAt(0).toString(16)}`; const rb = '%3e'; // `%${'>'.charCodeAt(0).toString(16)}`; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitBranchParser { - static defaultFormat = [ - `${lb}h${rb}%(HEAD)`, // HEAD indicator - `${lb}n${rb}%(refname)`, // branch name - `${lb}u${rb}%(upstream:short)`, // branch upstream - `${lb}t${rb}%(upstream:track)`, // branch upstream tracking state - `${lb}r${rb}%(objectname)`, // ref - `${lb}d${rb}%(committerdate:iso8601)`, // committer date - ].join(''); +export const parseGitBranchesDefaultFormat = [ + `${lb}h${rb}%(HEAD)`, // HEAD indicator + `${lb}n${rb}%(refname)`, // branch name + `${lb}u${rb}%(upstream:short)`, // branch upstream + `${lb}t${rb}%(upstream:track)`, // branch upstream tracking state + `${lb}r${rb}%(objectname)`, // ref + `${lb}d${rb}%(committerdate:iso8601)`, // committer date +].join(''); - @debug({ args: false, singleLine: true }) - static parse(data: string, repoPath: string): GitBranch[] { - const branches: GitBranch[] = []; +export function parseGitBranches(container: Container, data: string, repoPath: string): GitBranch[] { + using sw = maybeStopWatch(`Git.parseBranches(${repoPath})`, { log: false, logLevel: 'debug' }); - if (!data) return branches; + const branches: GitBranch[] = []; + if (!data) return branches; - let current; - let name; - let upstream; - let ahead; - let behind; - let missing; - let ref; - let date; + let current; + let name; + let upstream; + let ahead; + let behind; + let missing; + let ref; + let date; - let remote; + let remote; - let match; - do { - match = branchWithTrackingRegex.exec(data); - if (match == null) break; + let match; + do { + match = branchWithTrackingRegex.exec(data); + if (match == null) break; - [, current, name, upstream, ahead, behind, missing, ref, date] = match; + [, current, name, upstream, ahead, behind, missing, ref, date] = match; - if (name.startsWith('refs/remotes/')) { - // Strip off refs/remotes/ - name = name.substr(13); - if (name.endsWith('/HEAD')) continue; + if (name.startsWith('refs/remotes/')) { + // Strip off refs/remotes/ + name = name.substring(13); + if (name.endsWith('/HEAD')) continue; - remote = true; - } else { - // Strip off refs/heads/ - name = name.substr(11); - remote = false; - } + remote = true; + } else { + // Strip off refs/heads/ + name = name.substring(11); + remote = false; + } - branches.push( - new GitBranch( - repoPath, - name, - remote, - current.charCodeAt(0) === 42, // '*', - date ? new Date(date) : undefined, - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ref == null || ref.length === 0 ? undefined : ` ${ref}`.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - upstream == null || upstream.length === 0 - ? undefined - : { name: ` ${upstream}`.substr(1), missing: Boolean(missing) }, - Number(ahead) || 0, - Number(behind) || 0, - ), - ); - } while (true); + branches.push( + new GitBranch( + container, + repoPath, + name, + remote, + current.charCodeAt(0) === 42, // '*', + date ? new Date(date) : undefined, + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ref == null || ref.length === 0 ? undefined : ` ${ref}`.substring(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + upstream == null || upstream.length === 0 + ? undefined + : { name: ` ${upstream}`.substring(1), missing: Boolean(missing) }, + Number(ahead) || 0, + Number(behind) || 0, + ), + ); + } while (true); - return branches; - } + sw?.stop({ suffix: ` parsed ${branches.length} branches` }); + + return branches; } diff --git a/src/git/parsers/diffParser.ts b/src/git/parsers/diffParser.ts index f988d251bc7a5..3fd1ff463478e 100644 --- a/src/git/parsers/diffParser.ts +++ b/src/git/parsers/diffParser.ts @@ -1,190 +1,256 @@ -import { debug } from '../../system/decorators/log'; -import { getLines } from '../../system/string'; -import type { GitDiff, GitDiffHunkLine, GitDiffLine, GitDiffShortStat } from '../models/diff'; -import { GitDiffHunk } from '../models/diff'; +import { joinPaths, normalizePath } from '../../system/path'; +import { maybeStopWatch } from '../../system/stopwatch'; +import type { GitDiffFile, GitDiffHunk, GitDiffHunkLine, GitDiffShortStat } from '../models/diff'; import type { GitFile, GitFileStatus } from '../models/file'; +import { GitFileChange } from '../models/file'; const shortStatDiffRegex = /(\d+)\s+files? changed(?:,\s+(\d+)\s+insertions?\(\+\))?(?:,\s+(\d+)\s+deletions?\(-\))?/; -const unifiedDiffRegex = /^@@ -([\d]+)(?:,([\d]+))? \+([\d]+)(?:,([\d]+))? @@(?:.*?)\n([\s\S]*?)(?=^@@)/gm; - -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitDiffParser { - @debug({ args: false, singleLine: true }) - static parse(data: string, debug: boolean = false): GitDiff | undefined { - if (!data) return undefined; - - const hunks: GitDiffHunk[] = []; - - let previousStart; - let previousCount; - let currentStart; - let currentCount; - let hunk; - - let match; - do { - match = unifiedDiffRegex.exec(`${data}\n@@`); - if (match == null) break; - - [, previousStart, previousCount, currentStart, currentCount, hunk] = match; - - previousCount = Number(previousCount) || 0; - previousStart = Number(previousStart) || 0; - currentCount = Number(currentCount) || 0; - currentStart = Number(currentStart) || 0; - - hunks.push( - new GitDiffHunk( - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${hunk}`.substr(1), - { - count: currentCount === 0 ? 1 : currentCount, - position: { - start: currentStart, - end: currentStart + (currentCount > 0 ? currentCount - 1 : 0), - }, - }, - { - count: previousCount === 0 ? 1 : previousCount, - position: { - start: previousStart, - end: previousStart + (previousCount > 0 ? previousCount - 1 : 0), - }, - }, - ), - ); - } while (true); - if (!hunks.length) return undefined; +function parseHunkHeaderPart(headerPart: string) { + const [startS, countS] = headerPart.split(','); + const start = Number(startS); + const count = Number(countS) || 1; + return { count: count, position: { start: start, end: start + count - 1 } }; +} - const diff: GitDiff = { - diff: debug ? data : undefined, - hunks: hunks, - }; - return diff; +export function parseGitFileDiff(data: string, includeContents = false): GitDiffFile | undefined { + using sw = maybeStopWatch('Git.parseFileDiff', { log: false, logLevel: 'debug' }); + if (!data) return undefined; + + const hunks: GitDiffHunk[] = []; + + const lines = data.split('\n'); + + // Skip header + let i = -1; + while (++i < lines.length) { + if (lines[i].startsWith('@@')) { + break; + } } - @debug({ args: false, singleLine: true }) - static parseHunk(hunk: GitDiffHunk): { lines: GitDiffHunkLine[]; state: 'added' | 'changed' | 'removed' } { - const currentStart = hunk.current.position.start; - const previousStart = hunk.previous.position.start; - - const currentLines: (GitDiffLine | undefined)[] = - currentStart > previousStart - ? new Array(currentStart - previousStart).fill(undefined, 0, currentStart - previousStart) - : []; - const previousLines: (GitDiffLine | undefined)[] = - previousStart > currentStart - ? new Array(previousStart - currentStart).fill(undefined, 0, previousStart - currentStart) - : []; - - let hasAddedOrChanged; - let hasRemoved; - - let removed = 0; - for (const l of getLines(hunk.diff)) { - switch (l[0]) { - case '+': - hasAddedOrChanged = true; - currentLines.push({ - line: ` ${l.substring(1)}`, - state: 'added', - }); + // Parse hunks + let line; + while (i < lines.length) { + line = lines[i]; + if (!line.startsWith('@@')) { + i++; + continue; + } - if (removed > 0) { - removed--; - } else { - previousLines.push(undefined); + const header = line.split('@@')[1].trim(); + const [previousHeaderPart, currentHeaderPart] = header.split(' '); + + const current = parseHunkHeaderPart(currentHeaderPart.slice(1)); + const previous = parseHunkHeaderPart(previousHeaderPart.slice(1)); + + const hunkLines = new Map(); + let fileLineNumber = current.position.start; + + line = lines[++i]; + const contentStartLine = i; + + // Parse hunks lines + while (i < lines.length && !line.startsWith('@@')) { + switch (line[0]) { + // deleted + case '-': { + let deletedLineNumber = fileLineNumber; + while (line?.startsWith('-')) { + hunkLines.set(deletedLineNumber++, { + current: undefined, + previous: line.slice(1), + state: 'removed', + }); + line = lines[++i]; } + if (line?.startsWith('+')) { + let addedLineNumber = fileLineNumber; + while (line?.startsWith('+')) { + const hunkLine = hunkLines.get(addedLineNumber); + if (hunkLine != null) { + hunkLine.current = line.slice(1); + hunkLine.state = 'changed'; + } else { + hunkLines.set(addedLineNumber, { + current: line.slice(1), + previous: undefined, + state: 'added', + }); + } + addedLineNumber++; + line = lines[++i]; + } + fileLineNumber = addedLineNumber; + } else { + fileLineNumber = deletedLineNumber; + } break; + } + // added + case '+': + hunkLines.set(fileLineNumber++, { + current: line.slice(1), + previous: undefined, + state: 'added', + }); - case '-': - hasRemoved = true; - removed++; + line = lines[++i]; + break; - previousLines.push({ - line: ` ${l.substring(1)}`, - state: 'removed', + // unchanged (context) + case ' ': + hunkLines.set(fileLineNumber++, { + current: line.slice(1), + previous: line.slice(1), + state: 'unchanged', }); + line = lines[++i]; break; default: - while (removed > 0) { - removed--; - currentLines.push(undefined); - } - - currentLines.push({ line: l, state: 'unchanged' }); - previousLines.push({ line: l, state: 'unchanged' }); - + line = lines[++i]; break; } } - while (removed > 0) { - removed--; - currentLines.push(undefined); - } - - const hunkLines: GitDiffHunkLine[] = []; - - for (let i = 0; i < Math.max(currentLines.length, previousLines.length); i++) { - hunkLines.push({ - hunk: hunk, - current: currentLines[i], - previous: previousLines[i], - }); - } - - return { + const hunk: GitDiffHunk = { + contents: `${lines.slice(contentStartLine, i).join('\n')}\n`, + current: current, + previous: previous, lines: hunkLines, - state: hasAddedOrChanged && hasRemoved ? 'changed' : hasAddedOrChanged ? 'added' : 'removed', }; + + hunks.push(hunk); } - @debug({ args: false, singleLine: true }) - static parseNameStatus(data: string, repoPath: string): GitFile[] | undefined { - if (!data) return undefined; + sw?.stop({ suffix: ` parsed ${hunks.length} hunks` }); - const files: GitFile[] = []; + return { + contents: includeContents ? data : undefined, + hunks: hunks, + }; +} - let status; +export function parseGitDiffNameStatusFiles(data: string, repoPath: string): GitFile[] | undefined { + using sw = maybeStopWatch('Git.parseDiffNameStatusFiles', { log: false, logLevel: 'debug' }); + if (!data) return undefined; - const fields = data.split('\0'); - for (let i = 0; i < fields.length - 1; i++) { - status = fields[i][0]; - if (status === '.') { - status = '?'; - } + const files: GitFile[] = []; + + let status; - files.push({ - status: status as GitFileStatus, - path: fields[++i], - originalPath: status[0] === 'R' || status[0] === 'C' ? fields[++i] : undefined, - repoPath: repoPath, - }); + const fields = data.split('\0'); + for (let i = 0; i < fields.length - 1; i++) { + status = fields[i][0]; + if (status === '.') { + status = '?'; } - return files; + files.push({ + status: status as GitFileStatus, + path: fields[++i], + originalPath: status.startsWith('R') || status.startsWith('C') ? fields[++i] : undefined, + repoPath: repoPath, + }); } - @debug({ args: false, singleLine: true }) - static parseShortStat(data: string): GitDiffShortStat | undefined { - if (!data) return undefined; + sw?.stop({ suffix: ` parsed ${files.length} files` }); - const match = shortStatDiffRegex.exec(data); - if (match == null) return undefined; - - const [, files, insertions, deletions] = match; + return files; +} - const diffShortStat: GitDiffShortStat = { - changedFiles: files == null ? 0 : parseInt(files, 10), - additions: insertions == null ? 0 : parseInt(insertions, 10), - deletions: deletions == null ? 0 : parseInt(deletions, 10), - }; +export function parseGitApplyFiles(data: string, repoPath: string): GitFileChange[] { + using sw = maybeStopWatch('Git.parseApplyFiles', { log: false, logLevel: 'debug' }); + if (!data) return []; + + const files = new Map(); + + const lines = data.split('\0'); + // remove the summary (last) line to parse later + const summary = lines.pop(); + + for (let line of lines) { + line = line.trim(); + if (!line) continue; + + const [insertions, deletions, path] = line.split('\t'); + files.set( + normalizePath(path), + new GitFileChange(repoPath, path, 'M' as GitFileStatus, undefined, undefined, { + changes: 0, + additions: parseInt(insertions, 10), + deletions: parseInt(deletions, 10), + }), + ); + } - return diffShortStat; + for (let line of summary!.split('\n')) { + line = line.trim(); + if (!line) continue; + + const match = /(rename) (.*?)\{(.+?)\s+=>\s+(.+?)\}(?: \(\d+%\))|(create|delete) mode \d+ (.+)/.exec(line); + if (match == null) continue; + + let [, rename, renameRoot, renameOriginalPath, renamePath, createOrDelete, createOrDeletePath] = match; + + if (rename != null) { + renamePath = normalizePath(joinPaths(renameRoot, renamePath)); + renameOriginalPath = normalizePath(joinPaths(renameRoot, renameOriginalPath)); + + const file = files.get(renamePath)!; + files.set( + renamePath, + new GitFileChange( + repoPath, + renamePath, + 'R' as GitFileStatus, + renameOriginalPath, + undefined, + file.stats, + ), + ); + } else { + const file = files.get(normalizePath(createOrDeletePath))!; + files.set( + createOrDeletePath, + new GitFileChange( + repoPath, + file.path, + (createOrDelete === 'create' ? 'A' : 'D') as GitFileStatus, + undefined, + undefined, + file.stats, + ), + ); + } } + + sw?.stop({ suffix: ` parsed ${files.size} files` }); + + return [...files.values()]; +} + +export function parseGitDiffShortStat(data: string): GitDiffShortStat | undefined { + using sw = maybeStopWatch('Git.parseDiffShortStat', { log: false, logLevel: 'debug' }); + if (!data) return undefined; + + const match = shortStatDiffRegex.exec(data); + if (match == null) return undefined; + + const [, files, insertions, deletions] = match; + + const diffShortStat: GitDiffShortStat = { + changedFiles: files == null ? 0 : parseInt(files, 10), + additions: insertions == null ? 0 : parseInt(insertions, 10), + deletions: deletions == null ? 0 : parseInt(deletions, 10), + }; + + sw?.stop({ + suffix: ` parsed ${diffShortStat.changedFiles} files, +${diffShortStat.additions} -${diffShortStat.deletions}`, + }); + + return diffShortStat; } diff --git a/src/git/parsers/logParser.ts b/src/git/parsers/logParser.ts index 4d2a2a4f7775a..fad7c506130b0 100644 --- a/src/git/parsers/logParser.ts +++ b/src/git/parsers/logParser.ts @@ -1,15 +1,17 @@ +/* eslint-disable @typescript-eslint/no-deprecated */ import type { Range } from 'vscode'; import type { Container } from '../../container'; import { filterMap } from '../../system/array'; -import { debug } from '../../system/decorators/log'; -import { normalizePath, relative } from '../../system/path'; +import { normalizePath } from '../../system/path'; +import { maybeStopWatch } from '../../system/stopwatch'; import { getLines } from '../../system/string'; -import type { GitCommitLine } from '../models/commit'; +import { relative } from '../../system/vscode/path'; +import type { GitCommitLine, GitStashCommit } from '../models/commit'; import { GitCommit, GitCommitIdentity } from '../models/commit'; +import { uncommitted } from '../models/constants'; import type { GitFile, GitFileChangeStats } from '../models/file'; import { GitFileChange, GitFileIndexStatus } from '../models/file'; import type { GitLog } from '../models/log'; -import { GitRevision } from '../models/reference'; import type { GitUser } from '../models/user'; import { isUserMatch } from '../models/user'; @@ -19,7 +21,7 @@ const diffRangeRegex = /^@@ -(\d+?),(\d+?) \+(\d+?),(\d+?) @@/; export const fileStatusRegex = /(\S)\S*\t([^\t\n]+)(?:\t(.+))?/; const fileStatusAndSummaryRegex = /^(\d+?|-)\s+?(\d+?|-)\s+?(.*)(?:\n\s(delete|rename|copy|create))?/; const fileStatusAndSummaryRenamedFileRegex = /(.+)\s=>\s(.+)/; -const fileStatusAndSummaryRenamedFilePathRegex = /(.*?){(.+?)\s=>\s(.*?)}(.*)/; +const fileStatusAndSummaryRenamedFilePathRegex = /(.*?){(.+?)?\s=>\s(.*?)?}(.*)/; const logFileSimpleRegex = /^ (.*)\s*(?:(?:diff --git a\/(.*) b\/(.*))|(?:(\S)\S*\t([^\t\n]+)(?:\t(.+))?))/gm; const logFileSimpleRenamedRegex = /^ (\S+)\s*(.*)$/s; @@ -79,10 +81,13 @@ export type ParsedEntryWithFiles = { [K in keyof T]: string } & { files: Pars export type ParserWithFiles = Parser>; export type ParsedStats = { files: number; additions: number; deletions: number }; -export type ParsedEntryWithStats = T & { stats?: ParsedStats }; +export type ParsedEntryWithMaybeStats = T & { stats?: ParsedStats }; +export type ParserWithMaybeStats = Parser>; + +export type ParsedEntryWithStats = T & { stats: ParsedStats }; export type ParserWithStats = Parser>; -type ContributorsParserMaybeWithStats = ParserWithStats<{ +type ContributorsParserMaybeWithStats = ParserWithMaybeStats<{ sha: string; author: string; email: string; @@ -115,7 +120,7 @@ export function getContributorsParser(stats?: boolean): ContributorsParserMaybeW return _contributorsParser; } -type GraphParserMaybeWithStats = ParserWithStats<{ +type GraphParserMaybeWithStats = ParserWithMaybeStats<{ sha: string; author: string; authorEmail: string; @@ -161,6 +166,15 @@ export function getGraphParser(stats?: boolean): GraphParserMaybeWithStats { return _graphParser; } +let _graphStatsParser: ParserWithStats<{ sha: string }> | undefined; + +export function getGraphStatsParser(): ParserWithStats<{ sha: string }> { + if (_graphStatsParser == null) { + _graphStatsParser = createLogParserWithStats({ sha: '%H' }); + } + return _graphStatsParser; +} + type RefParser = Parser; let _refParser: RefParser | undefined; @@ -307,7 +321,7 @@ export function createLogParserWithFiles>( field = fields.next(); file.path = field.value; - if (file.status[0] === 'R' || file.status[0] === 'C') { + if (file.status.startsWith('R') || file.status.startsWith('C')) { field = fields.next(); file.originalPath = field.value; } @@ -327,7 +341,7 @@ export function createLogParserWithFiles>( export function createLogParserWithStats>( fieldMapping: ExtractAll, ): ParserWithStats { - function parseStats(fields: IterableIterator, entry: ParsedEntryWithStats) { + function parseStats(fields: IterableIterator, entry: ParsedEntryWithMaybeStats) { const stats = fields.next().value; const match = shortstatRegex.exec(stats); if (match?.groups != null) { @@ -351,436 +365,438 @@ export function createLogParserWithStats>( }); } -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitLogParser { - // private static _defaultParser: ParserWithFiles<{ - // sha: string; - // author: string; - // authorEmail: string; - // authorDate: string; - // committer: string; - // committerEmail: string; - // committerDate: string; - // message: string; - // parents: string[]; - // }>; - // static get defaultParser() { - // if (this._defaultParser == null) { - // this._defaultParser = GitLogParser.createWithFiles({ - // sha: '%H', - // author: '%aN', - // authorEmail: '%aE', - // authorDate: '%at', - // committer: '%cN', - // committerEmail: '%cE', - // committerDate: '%ct', - // message: '%B', - // parents: '%P', - // }); - // } - // return this._defaultParser; - // } - - static allFormat = [ - `${lb}${sl}f${rb}`, - `${lb}r${rb}${sp}%H`, // ref - `${lb}a${rb}${sp}%aN`, // author - `${lb}e${rb}${sp}%aE`, // author email - `${lb}d${rb}${sp}%at`, // author date - `${lb}n${rb}${sp}%cN`, // committer - `${lb}m${rb}${sp}%cE`, // committer email - `${lb}c${rb}${sp}%ct`, // committer date - `${lb}p${rb}${sp}%P`, // parents - `${lb}t${rb}${sp}%D`, // tips - `${lb}s${rb}`, - '%B', // summary - `${lb}${sl}s${rb}`, - `${lb}f${rb}`, - ].join('%n'); - - static defaultFormat = [ - `${lb}${sl}f${rb}`, - `${lb}r${rb}${sp}%H`, // ref - `${lb}a${rb}${sp}%aN`, // author - `${lb}e${rb}${sp}%aE`, // author email - `${lb}d${rb}${sp}%at`, // author date - `${lb}n${rb}${sp}%cN`, // committer - `${lb}m${rb}${sp}%cE`, // committer email - `${lb}c${rb}${sp}%ct`, // committer date - `${lb}p${rb}${sp}%P`, // parents - `${lb}s${rb}`, - '%B', // summary - `${lb}${sl}s${rb}`, - `${lb}f${rb}`, - ].join('%n'); - - static simpleRefs = `${lb}r${rb}${sp}%H`; - static simpleFormat = `${lb}r${rb}${sp}%H`; - - static shortlog = '%H%x00%aN%x00%aE%x00%at'; - - @debug({ args: false }) - static parse( - container: Container, - data: string, - type: LogType, - repoPath: string | undefined, - fileName: string | undefined, - sha: string | undefined, - currentUser: GitUser | undefined, - limit: number | undefined, - reverse: boolean, - range: Range | undefined, - hasMoreOverride?: boolean, - ): GitLog | undefined { - if (!data) return undefined; - - let relativeFileName: string | undefined; - - let entry: LogEntry = {}; - let line: string | undefined = undefined; - let token: number; - - let i = 0; - let first = true; - - const lines = getLines(`${data}`); - // Skip the first line since it will always be - let next = lines.next(); - if (next.done) return undefined; - - if (repoPath !== undefined) { - repoPath = normalizePath(repoPath); - } +export const parseGitLogAllFormat = [ + `${lb}${sl}f${rb}`, + `${lb}r${rb}${sp}%H`, // ref + `${lb}a${rb}${sp}%aN`, // author + `${lb}e${rb}${sp}%aE`, // author email + `${lb}d${rb}${sp}%at`, // author date + `${lb}n${rb}${sp}%cN`, // committer + `${lb}m${rb}${sp}%cE`, // committer email + `${lb}c${rb}${sp}%ct`, // committer date + `${lb}p${rb}${sp}%P`, // parents + `${lb}t${rb}${sp}%D`, // tips + `${lb}s${rb}`, + '%B', // summary + `${lb}${sl}s${rb}`, + `${lb}f${rb}`, +].join('%n'); +export const parseGitLogDefaultFormat = [ + `${lb}${sl}f${rb}`, + `${lb}r${rb}${sp}%H`, // ref + `${lb}a${rb}${sp}%aN`, // author + `${lb}e${rb}${sp}%aE`, // author email + `${lb}d${rb}${sp}%at`, // author date + `${lb}n${rb}${sp}%cN`, // committer + `${lb}m${rb}${sp}%cE`, // committer email + `${lb}c${rb}${sp}%ct`, // committer date + `${lb}p${rb}${sp}%P`, // parents + `${lb}s${rb}`, + '%B', // summary + `${lb}${sl}s${rb}`, + `${lb}f${rb}`, +].join('%n'); +export const parseGitLogSimpleFormat = `${lb}r${rb}${sp}%H`; + +export function parseGitLog( + container: Container, + data: string, + type: LogType, + repoPath: string | undefined, + fileName: string | undefined, + sha: string | undefined, + currentUser: GitUser | undefined, + limit: number | undefined, + reverse: boolean, + range: Range | undefined, + stashes?: Map, + includeOnlyStashes?: boolean, + hasMoreOverride?: boolean, +): GitLog | undefined { + using sw = maybeStopWatch(`Git.parseLog(${repoPath}, fileName=${fileName}, sha=${sha})`, { + log: false, + logLevel: 'debug', + }); + if (!data) return undefined; - const commits = new Map(); - let truncationCount = limit; + let relativeFileName: string | undefined; - let match; - let renamedFileName; - let renamedMatch; + let entry: LogEntry = {}; + let line: string | undefined = undefined; + let token: number; - loop: while (true) { - next = lines.next(); - if (next.done) break; + let i = 0; + let first = true; - line = next.value; + const lines = getLines(`${data}`); + // Skip the first line since it will always be + let next = lines.next(); + if (next.done) return undefined; - // Since log --reverse doesn't properly honor a max count -- enforce it here - if (reverse && limit && i >= limit) break; + if (repoPath !== undefined) { + repoPath = normalizePath(repoPath); + } - // <1-char token> data - // e.g. bd1452a2dc - token = line.charCodeAt(1); + const commits = new Map(); + let truncationCount = limit; - switch (token) { - case 114: // 'r': // ref - entry = { - sha: line.substring(4), - }; - break; + let match; + let renamedFileName; + let renamedMatch; - case 97: // 'a': // author - if (GitRevision.uncommitted === entry.sha) { - entry.author = 'You'; - } else { - entry.author = line.substring(4); - } - break; + loop: while (true) { + next = lines.next(); + if (next.done) break; - case 101: // 'e': // author-mail - entry.authorEmail = line.substring(4); - break; + line = next.value; - case 100: // 'd': // author-date - entry.authorDate = line.substring(4); - break; + // Since log --reverse doesn't properly honor a max count -- enforce it here + if (reverse && limit && i >= limit) break; - case 110: // 'n': // committer - entry.committer = line.substring(4); - break; + // <1-char token> data + // e.g. bd1452a2dc + token = line.charCodeAt(1); - case 109: // 'm': // committer-mail - entry.committedDate = line.substring(4); - break; + switch (token) { + case 114: // 'r': // ref + entry = { + sha: line.substring(4), + }; + break; - case 99: // 'c': // committer-date - entry.committedDate = line.substring(4); - break; + case 97: // 'a': // author + if (uncommitted === entry.sha) { + entry.author = 'You'; + } else { + entry.author = line.substring(4); + } + break; - case 112: // 'p': // parents - line = line.substring(4); - entry.parentShas = line.length !== 0 ? line.split(' ') : undefined; - break; + case 101: // 'e': // author-mail + entry.authorEmail = line.substring(4); + break; - case 116: // 't': // tips - line = line.substring(4); - entry.tips = line.length !== 0 ? line.split(', ') : undefined; - break; + case 100: // 'd': // author-date + entry.authorDate = line.substring(4); + break; - case 115: // 's': // summary - while (true) { - next = lines.next(); - if (next.done) break; + case 110: // 'n': // committer + entry.committer = line.substring(4); + break; - line = next.value; - if (line === '') break; + case 109: // 'm': // committer-mail + entry.committedDate = line.substring(4); + break; - if (entry.summary === undefined) { - entry.summary = line; - } else { - entry.summary += `\n${line}`; - } - } + case 99: // 'c': // committer-date + entry.committedDate = line.substring(4); + break; - // Remove the trailing newline - if (entry.summary != null && entry.summary.charCodeAt(entry.summary.length - 1) === 10) { - entry.summary = entry.summary.slice(0, -1); - } - break; + case 112: // 'p': // parents + line = line.substring(4); + entry.parentShas = line.length !== 0 ? line.split(' ') : undefined; + break; - case 102: { - // 'f': // files - // Skip the blank line git adds before the files - next = lines.next(); + case 116: // 't': // tips + line = line.substring(4); + entry.tips = line.length !== 0 ? line.split(', ') : undefined; + break; - let hasFiles = true; - if (next.done || next.value === '') { - // If this is a merge commit and there are no files returned, skip the commit and reduce our truncationCount to ensure accurate truncation detection - if ((entry.parentShas?.length ?? 0) > 1) { - if (truncationCount) { - truncationCount--; - } + case 115: // 's': // summary + while (true) { + next = lines.next(); + if (next.done) break; - break; - } + line = next.value; + if (line === '') break; - hasFiles = false; + if (entry.summary === undefined) { + entry.summary = line; + } else { + entry.summary += `\n${line}`; } + } - // eslint-disable-next-line no-unmodified-loop-condition - while (hasFiles) { - next = lines.next(); - if (next.done) break; + // Remove the trailing newline + if (entry.summary != null && entry.summary.charCodeAt(entry.summary.length - 1) === 10) { + entry.summary = entry.summary.slice(0, -1); + } + break; - line = next.value; - if (line === '') break; + case 102: { + // 'f': // files + // Skip the blank line git adds before the files + next = lines.next(); - if (line.startsWith('warning:')) continue; + let hasFiles = true; + if (next.done || next.value === '') { + hasFiles = false; + } - if (type === LogType.Log) { - match = fileStatusRegex.exec(line); - if (match != null) { - if (entry.files === undefined) { - entry.files = []; - } + // eslint-disable-next-line no-unmodified-loop-condition + while (hasFiles) { + next = lines.next(); + if (next.done) break; - renamedFileName = match[3]; - if (renamedFileName !== undefined) { - entry.files.push({ - status: match[1] as GitFileIndexStatus, - path: renamedFileName, - originalPath: match[2], - }); - } else { - entry.files.push({ - status: match[1] as GitFileIndexStatus, - path: match[2], - }); - } - } - } else { - match = diffRegex.exec(line); - if (match != null) { - [, entry.originalPath, entry.path] = match; - if (entry.path === entry.originalPath) { - entry.originalPath = undefined; - entry.status = GitFileIndexStatus.Modified; - } else { - entry.status = GitFileIndexStatus.Renamed; - } + line = next.value; + if (line === '') break; - void lines.next(); - void lines.next(); - next = lines.next(); + if (line.startsWith('warning:')) continue; - match = diffRangeRegex.exec(next.value); - if (match !== null) { - entry.line = { - sha: entry.sha!, - originalLine: parseInt(match[1], 10), - // count: parseInt(match[2], 10), - line: parseInt(match[3], 10), - // count: parseInt(match[4], 10), - }; - } + if (type === LogType.Log) { + match = fileStatusRegex.exec(line); + if (match != null) { + if (entry.files === undefined) { + entry.files = []; + } - while (true) { - next = lines.next(); - if (next.done || next.value === '') break; - } - break; + renamedFileName = match[3]; + if (renamedFileName !== undefined) { + entry.files.push({ + status: match[1] as GitFileIndexStatus, + path: renamedFileName, + originalPath: match[2], + }); } else { + entry.files.push({ + status: match[1] as GitFileIndexStatus, + path: match[2], + }); + } + } + } else { + match = diffRegex.exec(line); + if (match != null) { + [, entry.originalPath, entry.path] = match; + if (entry.path === entry.originalPath) { + entry.originalPath = undefined; + entry.status = GitFileIndexStatus.Modified; + } else { + entry.status = GitFileIndexStatus.Renamed; + } + + void lines.next(); + void lines.next(); + next = lines.next(); + + match = diffRangeRegex.exec(next.value); + if (match !== null) { + entry.line = { + sha: entry.sha!, + originalLine: parseInt(match[1], 10), + // count: parseInt(match[2], 10), + line: parseInt(match[3], 10), + // count: parseInt(match[4], 10), + }; + } + + while (true) { next = lines.next(); - match = fileStatusAndSummaryRegex.exec(`${line}\n${next.value}`); - if (match != null) { - entry.fileStats = { - additions: Number(match[1]) || 0, - deletions: Number(match[2]) || 0, - changes: 0, - }; - - switch (match[4]) { - case undefined: - entry.status = 'M' as GitFileIndexStatus; - entry.path = match[3]; - break; - case 'copy': - case 'rename': - entry.status = (match[4] === 'copy' ? 'C' : 'R') as GitFileIndexStatus; - - renamedFileName = match[3]; - renamedMatch = - fileStatusAndSummaryRenamedFilePathRegex.exec(renamedFileName); - if (renamedMatch != null) { - // If there is no new path, the path part was removed so ensure we don't end up with // - entry.path = - renamedMatch[3] === '' - ? `${renamedMatch[1]}${renamedMatch[4]}`.replace('//', '/') - : `${renamedMatch[1]}${renamedMatch[3]}${renamedMatch[4]}`; - entry.originalPath = `${renamedMatch[1]}${renamedMatch[2]}${renamedMatch[4]}`; + if (next.done || next.value === '') break; + } + break; + } else { + next = lines.next(); + match = fileStatusAndSummaryRegex.exec(`${line}\n${next.value}`); + if (match != null) { + entry.fileStats = { + additions: Number(match[1]) || 0, + deletions: Number(match[2]) || 0, + changes: 0, + }; + + switch (match[4]) { + case undefined: + entry.status = 'M' as GitFileIndexStatus; + entry.path = match[3]; + break; + case 'copy': + case 'rename': + entry.status = (match[4] === 'copy' ? 'C' : 'R') as GitFileIndexStatus; + + renamedFileName = match[3]; + renamedMatch = fileStatusAndSummaryRenamedFilePathRegex.exec(renamedFileName); + if (renamedMatch != null) { + const [, start, from, to, end] = renamedMatch; + // If there is no new path, the path part was removed so ensure we don't end up with // + if (!to) { + entry.path = `${ + start.endsWith('/') && end.startsWith('/') + ? start.slice(0, -1) + : start + }${end}`; } else { - renamedMatch = - fileStatusAndSummaryRenamedFileRegex.exec(renamedFileName); - if (renamedMatch != null) { - entry.path = renamedMatch[2]; - entry.originalPath = renamedMatch[1]; - } else { - entry.path = renamedFileName; - } + entry.path = `${start}${to}${end}`; } - break; - case 'create': - entry.status = 'A' as GitFileIndexStatus; - entry.path = match[3]; - break; - case 'delete': - entry.status = 'D' as GitFileIndexStatus; - entry.path = match[3]; - break; - default: - entry.status = 'M' as GitFileIndexStatus; - entry.path = match[3]; - break; - } + if (!from) { + entry.originalPath = `${ + start.endsWith('/') && end.startsWith('/') + ? start.slice(0, -1) + : start + }${end}`; + } else { + entry.originalPath = `${start}${from}${end}`; + } + } else { + renamedMatch = fileStatusAndSummaryRenamedFileRegex.exec(renamedFileName); + if (renamedMatch != null) { + entry.path = renamedMatch[2]; + entry.originalPath = renamedMatch[1]; + } else { + entry.path = renamedFileName; + } + } + + break; + case 'create': + entry.status = 'A' as GitFileIndexStatus; + entry.path = match[3]; + break; + case 'delete': + entry.status = 'D' as GitFileIndexStatus; + entry.path = match[3]; + break; + default: + entry.status = 'M' as GitFileIndexStatus; + entry.path = match[3]; + break; } - - if (next.done || next.value === '') break; } - } - } - if (entry.files !== undefined) { - entry.path = filterMap(entry.files, f => (f.path ? f.path : undefined)).join(', '); + if (next.done || next.value === '') break; + } } + } - if (first && repoPath === undefined && type === LogType.LogFile && fileName !== undefined) { - // Try to get the repoPath from the most recent commit - repoPath = normalizePath( - fileName.replace(fileName.startsWith('/') ? `/${entry.path}` : entry.path!, ''), - ); - relativeFileName = normalizePath(relative(repoPath, fileName)); - } else { - relativeFileName = - entry.path ?? - (repoPath != null && fileName != null - ? normalizePath(relative(repoPath, fileName)) - : undefined); - } - first = false; - - const commit = commits.get(entry.sha!); - if (commit === undefined) { - i++; - if (limit && i > limit) break loop; - } else if (truncationCount) { - // Since this matches an existing commit it will be skipped, so reduce our truncationCount to ensure accurate truncation detection - truncationCount--; - } + if (entry.files !== undefined) { + entry.path = filterMap(entry.files, f => (f.path ? f.path : undefined)).join(', '); + } - GitLogParser.parseEntry( - container, - entry, - commit, - type, - repoPath, - relativeFileName, - commits, - currentUser, + if (first && repoPath === undefined && type === LogType.LogFile && fileName !== undefined) { + // Try to get the repoPath from the most recent commit + repoPath = normalizePath( + fileName.replace(fileName.startsWith('/') ? `/${entry.path}` : entry.path!, ''), ); + relativeFileName = normalizePath(relative(repoPath, fileName)); + } else { + relativeFileName = + entry.path ?? + (repoPath != null && fileName != null + ? normalizePath(relative(repoPath, fileName)) + : undefined); + } + first = false; - break; + if (includeOnlyStashes && !stashes?.has(entry.sha!)) continue; + + const commit = commits.get(entry.sha!); + if (commit === undefined) { + i++; + if (limit && i > limit) break loop; + } else if (truncationCount) { + // Since this matches an existing commit it will be skipped, so reduce our truncationCount to ensure accurate truncation detection + truncationCount--; } + + parseLogEntry( + container, + entry, + commit, + type, + repoPath, + relativeFileName, + commits, + currentUser, + stashes, + ); + + break; } } - - const log: GitLog = { - repoPath: repoPath!, - commits: commits, - sha: sha, - count: i, - limit: limit, - range: range, - hasMore: hasMoreOverride ?? Boolean(truncationCount && i > truncationCount && truncationCount !== 1), - }; - return log; } - private static parseEntry( - container: Container, - entry: LogEntry, - commit: GitCommit | undefined, - type: LogType, - repoPath: string | undefined, - relativeFileName: string | undefined, - commits: Map, - currentUser: GitUser | undefined, - ): void { - if (commit == null) { - if (entry.author != null) { - if (isUserMatch(currentUser, entry.author, entry.authorEmail)) { - entry.author = 'You'; - } + sw?.stop({ suffix: ` parsed ${commits.size} commits` }); + + const log: GitLog = { + repoPath: repoPath!, + commits: commits, + sha: sha, + count: i, + limit: limit, + range: range, + hasMore: hasMoreOverride ?? Boolean(truncationCount && i > truncationCount && truncationCount !== 1), + }; + return log; +} + +function parseLogEntry( + container: Container, + entry: LogEntry, + commit: GitCommit | undefined, + type: LogType, + repoPath: string | undefined, + relativeFileName: string | undefined, + commits: Map, + currentUser: GitUser | undefined, + stashes: Map | undefined, +): void { + if (commit == null) { + if (entry.author != null) { + if (isUserMatch(currentUser, entry.author, entry.authorEmail)) { + entry.author = 'You'; } + } - if (entry.committer != null) { - if (isUserMatch(currentUser, entry.committer, entry.committerEmail)) { - entry.committer = 'You'; - } + if (entry.committer != null) { + if (isUserMatch(currentUser, entry.committer, entry.committerEmail)) { + entry.committer = 'You'; } + } - const originalFileName = entry.originalPath ?? (relativeFileName !== entry.path ? entry.path : undefined); + const originalFileName = entry.originalPath ?? (relativeFileName !== entry.path ? entry.path : undefined); - const files: { file?: GitFileChange; files?: GitFileChange[] } = { - files: entry.files?.map(f => new GitFileChange(repoPath!, f.path, f.status, f.originalPath)), - }; - if (type === LogType.LogFile && relativeFileName != null) { - files.file = new GitFileChange( - repoPath!, - relativeFileName, - entry.status!, - originalFileName, - undefined, - entry.fileStats, - ); - } + const files: { file?: GitFileChange; files?: GitFileChange[] } = { + files: entry.files?.map(f => new GitFileChange(repoPath!, f.path, f.status, f.originalPath)), + }; + if (type === LogType.LogFile && relativeFileName != null) { + files.file = new GitFileChange( + repoPath!, + relativeFileName, + entry.status!, + originalFileName, + undefined, + entry.fileStats, + ); + } + const stash = stashes?.get(entry.sha!); + if (stash != null) { + commit = new GitCommit( + container, + repoPath!, + stash.sha, + stash.author, + stash.committer, + stash.summary, + stash.parents, + stash.message, + files, + undefined, + entry.line != null ? [entry.line] : [], + entry.tips, + stash.stashName, + stash.stashOnRef, + ); + commits.set(stash.sha, commit); + } else { commit = new GitCommit( container, repoPath!, entry.sha!, + new GitCommitIdentity(entry.author!, entry.authorEmail, new Date((entry.authorDate! as any) * 1000)), + new GitCommitIdentity( entry.committer!, entry.committerEmail, @@ -794,94 +810,95 @@ export class GitLogParser { entry.line != null ? [entry.line] : [], entry.tips, ); - commits.set(entry.sha!, commit); } } +} - @debug({ args: false }) - static parseSimple( - data: string, - skip: number, - skipRef?: string, - ): [string | undefined, string | undefined, GitFileIndexStatus | undefined] { - let ref; - let diffFile; - let diffRenamed; - let status; - let file; - let renamed; - - let match; - do { - match = logFileSimpleRegex.exec(data); - if (match == null) break; - - if (match[1] === skipRef) continue; - if (skip-- > 0) continue; - - [, ref, diffFile, diffRenamed, status, file, renamed] = match; - - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - file = ` ${diffRenamed || diffFile || renamed || file}`.substr(1); - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - status = status == null || status.length === 0 ? undefined : ` ${status}`.substr(1); - } while (skip >= 0); - - // Ensure the regex state is reset - logFileSimpleRegex.lastIndex = 0; +export function parseGitLogSimple( + data: string, + skip: number, + skipRef?: string, +): [string | undefined, string | undefined, GitFileIndexStatus | undefined] { + using _sw = maybeStopWatch('Git.parseLogSimple', { log: false, logLevel: 'debug' }); + + let ref; + let diffFile; + let diffRenamed; + let status; + let file; + let renamed; + + let match; + do { + match = logFileSimpleRegex.exec(data); + if (match == null) break; + + if (match[1] === skipRef) continue; + if (skip-- > 0) continue; + + [, ref, diffFile, diffRenamed, status, file, renamed] = match; // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - return [ - ref == null || ref.length === 0 ? undefined : ` ${ref}`.substr(1), - file, - status as GitFileIndexStatus | undefined, - ]; - } + file = ` ${diffRenamed || diffFile || renamed || file}`.substring(1); + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + status = status == null || status.length === 0 ? undefined : ` ${status}`.substring(1); + } while (skip >= 0); + + // Ensure the regex state is reset + logFileSimpleRegex.lastIndex = 0; + + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + return [ + ref == null || ref.length === 0 ? undefined : ` ${ref}`.substring(1), + file, + status as GitFileIndexStatus | undefined, + ]; +} - @debug({ args: false }) - static parseSimpleRenamed( - data: string, - originalFileName: string, - ): [string | undefined, string | undefined, GitFileIndexStatus | undefined] { - let match = logFileSimpleRenamedRegex.exec(data); - if (match == null) return [undefined, undefined, undefined]; +export function parseGitLogSimpleRenamed( + data: string, + originalFileName: string, +): [string | undefined, string | undefined, GitFileIndexStatus | undefined] { + using _sw = maybeStopWatch('Git.parseLogSimpleRenamed', { log: false, logLevel: 'debug' }); - const [, ref, files] = match; + let match = logFileSimpleRenamedRegex.exec(data); + if (match == null) return [undefined, undefined, undefined]; - let status; - let file; - let renamed; + const [, ref, files] = match; - do { - match = logFileSimpleRenamedFilesRegex.exec(files); - if (match == null) break; + let status; + let file; + let renamed; - [, status, file, renamed] = match; + do { + match = logFileSimpleRenamedFilesRegex.exec(files); + if (match == null) break; - if (originalFileName !== file) { - status = undefined; - file = undefined; - renamed = undefined; - continue; - } + [, status, file, renamed] = match; - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - file = ` ${renamed || file}`.substr(1); - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - status = status == null || status.length === 0 ? undefined : ` ${status}`.substr(1); + if (originalFileName !== file) { + status = undefined; + file = undefined; + renamed = undefined; + continue; + } - break; - } while (true); + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + file = ` ${renamed || file}`.substring(1); + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + status = status == null || status.length === 0 ? undefined : ` ${status}`.substring(1); - // Ensure the regex state is reset - logFileSimpleRenamedFilesRegex.lastIndex = 0; + break; + } while (true); - return [ - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ref == null || ref.length === 0 || file == null ? undefined : ` ${ref}`.substr(1), - file, - status as GitFileIndexStatus | undefined, - ]; - } + // Ensure the regex state is reset + logFileSimpleRenamedFilesRegex.lastIndex = 0; + + return [ + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ref == null || ref.length === 0 || file == null ? undefined : ` ${ref}`.substring(1), + file, + status as GitFileIndexStatus | undefined, + ]; } diff --git a/src/git/parsers/reflogParser.ts b/src/git/parsers/reflogParser.ts index 1bbcf49792983..76ea7076821ca 100644 --- a/src/git/parsers/reflogParser.ts +++ b/src/git/parsers/reflogParser.ts @@ -1,4 +1,4 @@ -import { debug } from '../../system/decorators/log'; +import { maybeStopWatch } from '../../system/stopwatch'; import type { GitReflog } from '../models/reflog'; import { GitReflogRecord } from '../models/reflog'; @@ -10,119 +10,118 @@ const reflogHEADRegex = /.*?\/?HEAD$/; const lb = '%x3c'; // `%x${'<'.charCodeAt(0).toString(16)}`; const rb = '%x3e'; // `%x${'>'.charCodeAt(0).toString(16)}`; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitReflogParser { - static defaultFormat = [ - `${lb}r${rb}%H`, // ref - `${lb}d${rb}%gD`, // reflog selector (with iso8601 timestamp) - `${lb}s${rb}%gs`, // reflog subject - // `${lb}n${rb}%D` // ref names - ].join(''); - - @debug({ args: false }) - static parse( - data: string, - repoPath: string, - commands: string[], - limit: number, - totalLimit: number, - ): GitReflog | undefined { - if (!data) return undefined; - - const records: GitReflogRecord[] = []; - - let sha; - let selector; - let date; - let command; - let commandArgs; - let details; - - let head; - let headDate; - let headSha; - - let count = 0; - let total = 0; - let recordDate; - let record: GitReflogRecord | undefined; - - let match; - do { - match = reflogRegex.exec(data); - if (match == null) break; - - [, sha, selector, date, command, commandArgs, details] = match; - - total++; - - if (record !== undefined) { - // If the next record has the same sha as the previous, use it if it is not pointing to just HEAD and the previous is +export const parseGitRefLogDefaultFormat = [ + `${lb}r${rb}%H`, // ref + `${lb}d${rb}%gD`, // reflog selector (with iso8601 timestamp) + `${lb}s${rb}%gs`, // reflog subject + // `${lb}n${rb}%D` // ref names +].join(''); + +export function parseGitRefLog( + data: string, + repoPath: string, + commands: string[], + limit: number, + totalLimit: number, +): GitReflog | undefined { + using sw = maybeStopWatch(`Git.parseRefLog(${repoPath})`, { log: false, logLevel: 'debug' }); + if (!data) return undefined; + + const records: GitReflogRecord[] = []; + + let sha; + let selector; + let date; + let command; + let commandArgs; + let details; + + let head; + let headDate; + let headSha; + + let count = 0; + let total = 0; + let recordDate; + let record: GitReflogRecord | undefined; + + let match; + do { + match = reflogRegex.exec(data); + if (match == null) break; + + [, sha, selector, date, command, commandArgs, details] = match; + + total++; + + if (record !== undefined) { + // If the next record has the same sha as the previous, use it if it is not pointing to just HEAD and the previous is + if ( + sha === record.sha && + (date !== recordDate || !reflogHEADRegex.test(record.selector) || reflogHEADRegex.test(selector)) + ) { + continue; + } + + if (sha !== record.sha) { if ( - sha === record.sha && - (date !== recordDate || !reflogHEADRegex.test(record.selector) || reflogHEADRegex.test(selector)) + head != null && + headDate === recordDate && + headSha == record.sha && + reflogHEADRegex.test(record.selector) ) { - continue; + record.update(sha, head); + } else { + record.update(sha); } - if (sha !== record.sha) { - if ( - head != null && - headDate === recordDate && - headSha == record.sha && - reflogHEADRegex.test(record.selector) - ) { - record.update(sha, head); - } else { - record.update(sha); - } - - records.push(record); - record = undefined; - recordDate = undefined; - - count++; - if (limit !== 0 && count >= limit) break; - } - } - - if (command === 'HEAD') { - head = selector; - headDate = date; - headSha = sha; - - continue; - } + records.push(record); + record = undefined; + recordDate = undefined; - if (commands.includes(command)) { - record = new GitReflogRecord( - repoPath, - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${sha}`.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${selector}`.substr(1), - new Date(date), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${command}`.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - commandArgs == null || commandArgs.length === 0 ? undefined : commandArgs.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - details == null || details.length === 0 ? undefined : details.substr(1), - ); - recordDate = date; + count++; + if (limit !== 0 && count >= limit) break; } - } while (true); - - // Ensure the regex state is reset - reflogRegex.lastIndex = 0; - - return { - repoPath: repoPath, - records: records, - count: count, - total: total, - limit: limit, - hasMore: (limit !== 0 && count >= limit) || (totalLimit !== 0 && total >= totalLimit), - }; - } + } + + if (command === 'HEAD') { + head = selector; + headDate = date; + headSha = sha; + + continue; + } + + if (commands.includes(command)) { + record = new GitReflogRecord( + repoPath, + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${sha}`.substring(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${selector}`.substring(1), + new Date(date), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${command}`.substring(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + commandArgs == null || commandArgs.length === 0 ? undefined : commandArgs.substring(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + details == null || details.length === 0 ? undefined : details.substring(1), + ); + recordDate = date; + } + } while (true); + + // Ensure the regex state is reset + reflogRegex.lastIndex = 0; + + sw?.stop({ suffix: ` parsed ${records.length} records` }); + + return { + repoPath: repoPath, + records: records, + count: count, + total: total, + limit: limit, + hasMore: (limit !== 0 && count >= limit) || (totalLimit !== 0 && total >= totalLimit), + }; } diff --git a/src/git/parsers/remoteParser.ts b/src/git/parsers/remoteParser.ts index c700784559b02..9377b544320e6 100644 --- a/src/git/parsers/remoteParser.ts +++ b/src/git/parsers/remoteParser.ts @@ -1,75 +1,76 @@ -import { debug } from '../../system/decorators/log'; +import type { Container } from '../../container'; +import { maybeStopWatch } from '../../system/stopwatch'; import type { GitRemoteType } from '../models/remote'; import { GitRemote } from '../models/remote'; import type { getRemoteProviderMatcher } from '../remotes/remoteProviders'; -const emptyStr = ''; - const remoteRegex = /^(.*)\t(.*)\s\((.*)\)$/gm; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitRemoteParser { - @debug({ args: false, singleLine: true }) - static parse( - data: string, - repoPath: string, - remoteProviderMatcher: ReturnType, - ): GitRemote[] | undefined { - if (!data) return undefined; - - const remotes: GitRemote[] = []; - const groups = Object.create(null) as Record; - - let name; - let url; - let type; - - let scheme; - let domain; - let path; - - let uniqueness; - let remote: GitRemote | undefined; - - let match; - do { - match = remoteRegex.exec(data); - if (match == null) break; - - [, name, url, type] = match; - - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - url = ` ${url}`.substr(1); - - [scheme, domain, path] = parseGitRemoteUrl(url); - - uniqueness = `${domain ? `${domain}/` : ''}${path}`; - remote = groups[uniqueness]; - if (remote === undefined) { - const provider = remoteProviderMatcher(url, domain, path); - - remote = new GitRemote( - repoPath, - uniqueness, - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${name}`.substr(1), - scheme, - provider !== undefined ? provider.domain : domain, - provider !== undefined ? provider.path : path, - provider, - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - [{ url: url, type: ` ${type}`.substr(1) as GitRemoteType }], - ); - remotes.push(remote); - groups[uniqueness] = remote; - } else { - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - remote.urls.push({ url: url, type: ` ${type}`.substr(1) as GitRemoteType }); - } - } while (true); - - return remotes; - } +export function parseGitRemotes( + container: Container, + data: string, + repoPath: string, + remoteProviderMatcher: ReturnType, +): GitRemote[] { + using sw = maybeStopWatch(`Git.parseRemotes(${repoPath})`, { log: false, logLevel: 'debug' }); + if (!data) return []; + + const remotes = new Map(); + + let name; + let url; + let type; + + let scheme; + let domain; + let path; + + let remote: GitRemote | undefined; + + let match; + do { + match = remoteRegex.exec(data); + if (match == null) break; + + [, name, url, type] = match; + + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + name = ` ${name}`.substring(1); + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + url = ` ${url}`.substring(1); + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + type = ` ${type}`.substring(1); + + [scheme, domain, path] = parseGitRemoteUrl(url); + + remote = remotes.get(name); + if (remote == null) { + remote = new GitRemote( + container, + repoPath, + name, + scheme, + domain, + path, + remoteProviderMatcher(url, domain, path), + [{ url: url, type: type as GitRemoteType }], + ); + remotes.set(name, remote); + } else { + remote.urls.push({ url: url, type: type as GitRemoteType }); + if (remote.provider != null && type !== 'push') continue; + + const provider = remoteProviderMatcher(url, domain, path); + if (provider == null) continue; + + remote = new GitRemote(container, repoPath, name, scheme, domain, path, provider, remote.urls); + remotes.set(name, remote); + } + } while (true); + + sw?.stop({ suffix: ` parsed ${remotes.size} remotes` }); + + return [...remotes.values()]; } // Test git urls @@ -109,16 +110,16 @@ user:password@host.xz:project.git user:password@host.xz:/path/to/repo.git user:password@host.xz:/path/to/repo.git/ */ -const urlRegex = +export const remoteUrlRegex = /^(?:(git:\/\/)(.*?)\/|(https?:\/\/)(?:.*?@)?(.*?)\/|git@(.*):|(ssh:\/\/)(?:.*@)?(.*?)(?::.*?)?(?:\/|(?=~))|(?:.*?@)(.*?):)(.*)$/; export function parseGitRemoteUrl(url: string): [scheme: string, domain: string, path: string] { - const match = urlRegex.exec(url); - if (match == null) return [emptyStr, emptyStr, url]; + const match = remoteUrlRegex.exec(url); + if (match == null) return ['', '', url]; return [ match[1] || match[3] || match[6], match[2] || match[4] || match[5] || match[7] || match[8], - match[9].replace(/\.git\/?$/, emptyStr), + match[9].replace(/\.git\/?$/, ''), ]; } diff --git a/src/git/parsers/statusParser.ts b/src/git/parsers/statusParser.ts index e295b9f5924d6..081fbe37c8b57 100644 --- a/src/git/parsers/statusParser.ts +++ b/src/git/parsers/statusParser.ts @@ -1,143 +1,163 @@ -import { debug } from '../../system/decorators/log'; import { normalizePath } from '../../system/path'; +import { maybeStopWatch } from '../../system/stopwatch'; import { GitStatus, GitStatusFile } from '../models/status'; -const emptyStr = ''; - const aheadStatusV1Regex = /(?:ahead ([0-9]+))/; const behindStatusV1Regex = /(?:behind ([0-9]+))/; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitStatusParser { - @debug({ args: false, singleLine: true }) - static parse(data: string, repoPath: string, porcelainVersion: number): GitStatus | undefined { - if (!data) return undefined; +export function parseGitStatus(data: string, repoPath: string, porcelainVersion: number): GitStatus | undefined { + using sw = maybeStopWatch(`Git.parseStatus(${repoPath}, v=${porcelainVersion})`, { + log: false, + logLevel: 'debug', + }); + if (!data) return undefined; - const lines = data.split('\n').filter((i?: T): i is T => Boolean(i)); - if (lines.length === 0) return undefined; + const lines = data.split('\n').filter((i?: T): i is T => Boolean(i)); + if (lines.length === 0) return undefined; - if (porcelainVersion < 2) return this.parseV1(lines, repoPath); + const status = porcelainVersion < 2 ? parseStatusV1(lines, repoPath) : parseStatusV2(lines, repoPath); - return this.parseV2(lines, repoPath); - } + sw?.stop({ suffix: ` parsed ${status.files.length} files` }); - @debug({ args: false, singleLine: true }) - private static parseV1(lines: string[], repoPath: string): GitStatus { - let branch: string | undefined; - const files = []; - const state = { - ahead: 0, - behind: 0, - }; - let upstream; - - let position = -1; - while (++position < lines.length) { - const line = lines[position]; - // Header - if (line.startsWith('##')) { - const lineParts = line.split(' '); - [branch, upstream] = lineParts[1].split('...'); - if (lineParts.length > 2) { - const upstreamStatus = lineParts.slice(2).join(' '); + return status; +} +function parseStatusV1(lines: string[], repoPath: string): GitStatus { + let branch: string | undefined; + const files = []; + const state = { + ahead: 0, + behind: 0, + }; + let upstream; + let missing = false; + + let position = -1; + while (++position < lines.length) { + const line = lines[position]; + // Header + if (line.startsWith('##')) { + const lineParts = line.split(' '); + [branch, upstream] = lineParts[1].split('...'); + if (lineParts.length > 2) { + const upstreamStatus = lineParts.slice(2).join(' '); + if (upstreamStatus === '[gone]') { + missing = true; + state.ahead = 0; + state.behind = 0; + } else { const aheadStatus = aheadStatusV1Regex.exec(upstreamStatus); state.ahead = aheadStatus == null ? 0 : Number(aheadStatus[1]) || 0; const behindStatus = behindStatusV1Regex.exec(upstreamStatus); state.behind = behindStatus == null ? 0 : Number(behindStatus[1]) || 0; } + } + } else { + const rawStatus = line.substring(0, 2); + const fileName = line.substring(3); + if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) { + const [file1, file2] = fileName.replace(/"/g, '').split('->'); + files.push(parseStatusFile(repoPath, rawStatus, file2.trim(), file1.trim())); } else { - const rawStatus = line.substring(0, 2); - const fileName = line.substring(3); - if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) { - const [file1, file2] = fileName.replace(/"/g, emptyStr).split('->'); - files.push(this.parseStatusFile(repoPath, rawStatus, file2.trim(), file1.trim())); - } else { - files.push(this.parseStatusFile(repoPath, rawStatus, fileName)); - } + files.push(parseStatusFile(repoPath, rawStatus, fileName)); } } - - return new GitStatus(normalizePath(repoPath), branch ?? emptyStr, emptyStr, files, state, upstream); } - @debug({ args: false, singleLine: true }) - private static parseV2(lines: string[], repoPath: string): GitStatus { - let branch: string | undefined; - const files = []; - let sha: string | undefined; - const state = { - ahead: 0, - behind: 0, - }; - let upstream; - - let position = -1; - while (++position < lines.length) { - const line = lines[position]; - // Headers - if (line.startsWith('#')) { - const lineParts = line.split(' '); - switch (lineParts[1]) { - case 'branch.oid': - sha = lineParts[2]; - break; - case 'branch.head': - branch = lineParts[2]; - break; - case 'branch.upstream': - upstream = lineParts[2]; - break; - case 'branch.ab': - state.ahead = Number(lineParts[2].substring(1)); - state.behind = Number(lineParts[3].substring(1)); - break; - } - } else { - const lineParts = line.split(' '); - switch (lineParts[0][0]) { - case '1': // normal - files.push(this.parseStatusFile(repoPath, lineParts[1], lineParts.slice(8).join(' '))); - break; - case '2': { - // rename - const file = lineParts.slice(9).join(' ').split('\t'); - files.push(this.parseStatusFile(repoPath, lineParts[1], file[0], file[1])); - break; - } - case 'u': // unmerged - files.push(this.parseStatusFile(repoPath, lineParts[1], lineParts.slice(10).join(' '))); - break; - case '?': // untracked - files.push(this.parseStatusFile(repoPath, '??', lineParts.slice(1).join(' '))); - break; + return new GitStatus( + normalizePath(repoPath), + branch ?? '', + '', + files, + state, + upstream ? { name: upstream, missing: missing } : undefined, + ); +} + +function parseStatusV2(lines: string[], repoPath: string): GitStatus { + let branch: string | undefined; + const files = []; + let sha: string | undefined; + const state = { + ahead: 0, + behind: 0, + }; + let missing = true; + let upstream; + + let position = -1; + while (++position < lines.length) { + const line = lines[position]; + // Headers + if (line.startsWith('#')) { + const lineParts = line.split(' '); + switch (lineParts[1]) { + case 'branch.oid': + sha = lineParts[2]; + break; + case 'branch.head': + branch = lineParts[2]; + break; + case 'branch.upstream': + upstream = lineParts[2]; + break; + case 'branch.ab': + missing = false; + state.ahead = Number(lineParts[2].substring(1)); + state.behind = Number(lineParts[3].substring(1)); + break; + } + } else { + const lineParts = line.split(' '); + switch (lineParts[0][0]) { + case '1': // normal + files.push(parseStatusFile(repoPath, lineParts[1], lineParts.slice(8).join(' '))); + break; + case '2': { + // rename + const file = lineParts.slice(9).join(' ').split('\t'); + files.push(parseStatusFile(repoPath, lineParts[1], file[0], file[1])); + break; } + case 'u': // unmerged + files.push(parseStatusFile(repoPath, lineParts[1], lineParts.slice(10).join(' '))); + break; + case '?': // untracked + files.push(parseStatusFile(repoPath, '??', lineParts.slice(1).join(' '))); + break; } } - - return new GitStatus(normalizePath(repoPath), branch ?? emptyStr, sha ?? emptyStr, files, state, upstream); } - static parseStatusFile( - repoPath: string, - rawStatus: string, - fileName: string, - originalFileName?: string, - ): GitStatusFile { - let x = !rawStatus.startsWith('.') ? rawStatus[0].trim() : undefined; - if (x == null || x.length === 0) { - x = undefined; - } + return new GitStatus( + normalizePath(repoPath), + branch ?? '', + sha ?? '', + files, + state, + upstream ? { name: upstream, missing: missing } : undefined, + ); +} - let y = undefined; - if (rawStatus.length > 1) { - y = rawStatus[1] !== '.' ? rawStatus[1].trim() : undefined; - if (y == null || y.length === 0) { - y = undefined; - } - } +function parseStatusFile( + repoPath: string, + rawStatus: string, + fileName: string, + originalFileName?: string, +): GitStatusFile { + let x = !rawStatus.startsWith('.') ? rawStatus[0].trim() : undefined; + if (x == null || x.length === 0) { + x = undefined; + } - return new GitStatusFile(repoPath, x, y, fileName, originalFileName); + let y = undefined; + if (rawStatus.length > 1) { + y = rawStatus[1] !== '.' ? rawStatus[1].trim() : undefined; + if (y == null || y.length === 0) { + y = undefined; + } } + + return new GitStatusFile(repoPath, x, y, fileName, originalFileName); } diff --git a/src/git/parsers/tagParser.ts b/src/git/parsers/tagParser.ts index 26a59fcc825ce..1acd9943ff866 100644 --- a/src/git/parsers/tagParser.ts +++ b/src/git/parsers/tagParser.ts @@ -1,4 +1,4 @@ -import { debug } from '../../system/decorators/log'; +import { maybeStopWatch } from '../../system/stopwatch'; import { GitTag } from '../models/tag'; const tagRegex = /^(.+)<\*r>(.*)(.*)(.*)(.*)(.*)$/gm; @@ -7,54 +7,53 @@ const tagRegex = /^(.+)<\*r>(.*)(.*)(.*)(.*)(.*)$/gm; const lb = '%3c'; // `%${'<'.charCodeAt(0).toString(16)}`; const rb = '%3e'; // `%${'>'.charCodeAt(0).toString(16)}`; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitTagParser { - static defaultFormat = [ - `${lb}n${rb}%(refname)`, // tag name - `${lb}*r${rb}%(*objectname)`, // ref - `${lb}r${rb}%(objectname)`, // ref - `${lb}d${rb}%(creatordate:iso8601)`, // created date - `${lb}ad${rb}%(authordate:iso8601)`, // author date - `${lb}s${rb}%(subject)`, // message - ].join(''); - - @debug({ args: false, singleLine: true }) - static parse(data: string, repoPath: string): GitTag[] | undefined { - if (!data) return undefined; - - const tags: GitTag[] = []; - - let name; - let ref1; - let ref2; - let date; - let commitDate; - let message; - - let match; - do { - match = tagRegex.exec(data); - if (match == null) break; - - [, name, ref1, ref2, date, commitDate, message] = match; - - // Strip off refs/tags/ - name = name.substr(10); - - tags.push( - new GitTag( - repoPath, - name, - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${ref1 || ref2}`.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${message}`.substr(1), - date ? new Date(date) : undefined, - commitDate == null || commitDate.length === 0 ? undefined : new Date(commitDate), - ), - ); - } while (true); - - return tags; - } +export const parseGitTagsDefaultFormat = [ + `${lb}n${rb}%(refname)`, // tag name + `${lb}*r${rb}%(*objectname)`, // ref + `${lb}r${rb}%(objectname)`, // ref + `${lb}d${rb}%(creatordate:iso8601)`, // created date + `${lb}ad${rb}%(authordate:iso8601)`, // author date + `${lb}s${rb}%(subject)`, // message +].join(''); + +export function parseGitTags(data: string, repoPath: string): GitTag[] { + using sw = maybeStopWatch(`Git.parseTags(${repoPath})`, { log: false, logLevel: 'debug' }); + + const tags: GitTag[] = []; + if (!data) return tags; + + let name; + let ref1; + let ref2; + let date; + let commitDate; + let message; + + let match; + do { + match = tagRegex.exec(data); + if (match == null) break; + + [, name, ref1, ref2, date, commitDate, message] = match; + + // Strip off refs/tags/ + name = name.substring(10); + + tags.push( + new GitTag( + repoPath, + name, + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${ref1 || ref2}`.substring(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${message}`.substring(1), + date ? new Date(date) : undefined, + commitDate == null || commitDate.length === 0 ? undefined : new Date(commitDate), + ), + ); + } while (true); + + sw?.stop({ suffix: ` parsed ${tags.length} tags` }); + + return tags; } diff --git a/src/git/parsers/treeParser.ts b/src/git/parsers/treeParser.ts index 25b4baeb60992..96d8b1c094f18 100644 --- a/src/git/parsers/treeParser.ts +++ b/src/git/parsers/treeParser.ts @@ -1,40 +1,74 @@ -import { debug } from '../../system/decorators/log'; -import type { GitTreeEntry } from '../models/tree'; +import { maybeStopWatch } from '../../system/stopwatch'; +import type { GitLsFilesEntry, GitTreeEntry } from '../models/tree'; -const emptyStr = ''; const treeRegex = /(?:.+?)\s+(.+?)\s+(.+?)\s+(.+?)\s+(.+)/gm; +const filesRegex = /^(\S+)\s+(\S+)\s+(\S+)\s+(.*)$/gm; -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitTreeParser { - @debug({ args: false, singleLine: true }) - static parse(data: string | undefined): GitTreeEntry[] | undefined { - if (!data) return undefined; - - const trees: GitTreeEntry[] = []; - - let type; - let sha; - let size; - let filePath; - - let match; - do { - match = treeRegex.exec(data); - if (match == null) break; - - [, type, sha, size, filePath] = match; - - trees.push({ - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - commitSha: sha == null || sha.length === 0 ? emptyStr : ` ${sha}`.substr(1), - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - path: filePath == null || filePath.length === 0 ? emptyStr : ` ${filePath}`.substr(1), - size: Number(size) || 0, - // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - type: (type == null || type.length === 0 ? emptyStr : ` ${type}`.substr(1)) as 'blob' | 'tree', - }); - } while (true); - - return trees; - } +export function parseGitTree(data: string | undefined, ref: string): GitTreeEntry[] { + using sw = maybeStopWatch(`Git.parseTree`, { log: false, logLevel: 'debug' }); + + const trees: GitTreeEntry[] = []; + if (!data) return trees; + + let type; + let oid; + let size; + let filePath; + + let match; + do { + match = treeRegex.exec(data); + if (match == null) break; + + [, type, oid, size, filePath] = match; + + trees.push({ + ref: ref, + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + oid: oid == null || oid.length === 0 ? '' : ` ${oid}`.substring(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + path: filePath == null || filePath.length === 0 ? '' : ` ${filePath}`.substring(1), + size: Number(size) || 0, + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + type: (type == null || type.length === 0 ? '' : ` ${type}`.substring(1)) as 'blob' | 'tree', + }); + } while (true); + + sw?.stop({ suffix: ` parsed ${trees.length} trees` }); + + return trees; +} + +export function parseGitLsFiles(data: string | undefined): GitLsFilesEntry[] { + using sw = maybeStopWatch(`Git.parseLsFiles`, { log: false, logLevel: 'debug' }); + + const files: GitLsFilesEntry[] = []; + if (!data) return files; + + let filePath; + let mode; + let oid; + let stage; + + let match; + do { + match = filesRegex.exec(data); + if (match == null) break; + + [, mode, oid, stage, filePath] = match; + + files.push({ + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + mode: mode == null || mode.length === 0 ? '' : ` ${mode}`.substring(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + oid: oid == null || oid.length === 0 ? '' : ` ${oid}`.substring(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + path: filePath == null || filePath.length === 0 ? '' : ` ${filePath}`.substring(1), + stage: parseInt(stage, 10), + }); + } while (true); + + sw?.stop({ suffix: ` parsed ${files.length} files` }); + + return files; } diff --git a/src/git/parsers/worktreeParser.ts b/src/git/parsers/worktreeParser.ts index 230037b3acd2c..c9b137e2c0a21 100644 --- a/src/git/parsers/worktreeParser.ts +++ b/src/git/parsers/worktreeParser.ts @@ -1,7 +1,9 @@ import { Uri } from 'vscode'; -import { debug } from '../../system/decorators/log'; +import type { Container } from '../../container'; import { normalizePath } from '../../system/path'; +import { maybeStopWatch } from '../../system/stopwatch'; import { getLines } from '../../system/string'; +import type { GitBranch } from '../models/branch'; import { GitWorktree } from '../models/worktree'; interface WorktreeEntry { @@ -14,88 +16,96 @@ interface WorktreeEntry { prunable?: boolean | string; } -// eslint-disable-next-line @typescript-eslint/no-extraneous-class -export class GitWorktreeParser { - @debug({ args: false, singleLine: true }) - static parse(data: string, repoPath: string): GitWorktree[] { - if (!data) return []; +export function parseGitWorktrees( + container: Container, + data: string, + repoPath: string, + branches: GitBranch[], +): GitWorktree[] { + using sw = maybeStopWatch(`Git.parseWorktrees(${repoPath})`, { log: false, logLevel: 'debug' }); - if (repoPath != null) { - repoPath = normalizePath(repoPath); - } + const worktrees: GitWorktree[] = []; + if (!data) return worktrees; - const worktrees: GitWorktree[] = []; + if (repoPath != null) { + repoPath = normalizePath(repoPath); + } - let entry: Partial | undefined = undefined; - let line: string; - let index: number; - let key: string; - let value: string; - let locked: string; - let prunable: string; - let main = true; // the first worktree is the main worktree + let entry: Partial | undefined = undefined; + let line: string; + let index: number; + let key: string; + let value: string; + let locked: string; + let prunable: string; + let main = true; // the first worktree is the main worktree - for (line of getLines(data)) { - index = line.indexOf(' '); - if (index === -1) { - key = line; - value = ''; - } else { - key = line.substring(0, index); - value = line.substring(index + 1); - } + for (line of getLines(data)) { + index = line.indexOf(' '); + if (index === -1) { + key = line; + value = ''; + } else { + key = line.substring(0, index); + value = line.substring(index + 1); + } - if (key.length === 0 && entry != null) { - worktrees.push( - new GitWorktree( - main, - entry.bare ? 'bare' : entry.detached ? 'detached' : 'branch', - repoPath, - Uri.file(entry.path!), - entry.locked ?? false, - entry.prunable ?? false, - entry.sha, - entry.branch, - ), - ); + if (key.length === 0 && entry != null) { + // eslint-disable-next-line no-loop-func + const branch = entry.branch ? branches?.find(b => b.name === entry!.branch) : undefined; - entry = undefined; - main = false; - continue; - } + worktrees.push( + new GitWorktree( + container, + main, + entry.bare ? 'bare' : entry.detached ? 'detached' : 'branch', + repoPath, + Uri.file(entry.path!), + entry.locked ?? false, + entry.prunable ?? false, + entry.sha, + branch, + ), + ); - if (entry == null) { - entry = {}; - } + entry = undefined; + main = false; + continue; + } - switch (key) { - case 'worktree': - entry.path = value; - break; - case 'bare': - entry.bare = true; - break; - case 'HEAD': - entry.sha = value; - break; - case 'branch': - // Strip off refs/heads/ - entry.branch = value.substr(11); - break; - case 'detached': - entry.detached = true; - break; - case 'locked': - [, locked] = value.split(' ', 2); - entry.locked = locked?.trim() || true; - break; - case 'prunable': - [, prunable] = value.split(' ', 2); - entry.prunable = prunable?.trim() || true; - break; - } + if (entry == null) { + entry = {}; } - return worktrees; + switch (key) { + case 'worktree': + entry.path = value; + break; + case 'bare': + entry.bare = true; + break; + case 'HEAD': + entry.sha = value; + break; + case 'branch': + // Strip off refs/heads/ + entry.branch = value.substring(11); + break; + case 'detached': + entry.detached = true; + break; + case 'locked': + [, locked] = value.split(' ', 2); + entry.locked = locked?.trim() || true; + break; + case 'prunable': + [, prunable] = value.split(' ', 2); + entry.prunable = prunable?.trim() || true; + break; + } } + + sw?.stop({ suffix: ` parsed ${worktrees.length} worktrees` }); + + return worktrees; } diff --git a/src/git/queryResults.ts b/src/git/queryResults.ts new file mode 100644 index 0000000000000..ad35c6f02ea96 --- /dev/null +++ b/src/git/queryResults.ts @@ -0,0 +1,135 @@ +import type { Container } from '../container'; +import { getSettledValue } from '../system/promise'; +import { pluralize } from '../system/string'; +import type { FilesQueryFilter } from '../views/nodes/resultsFilesNode'; +import type { GitDiffShortStat } from './models/diff'; +import type { GitFile } from './models/file'; +import type { GitLog } from './models/log'; +import type { GitUser } from './models/user'; + +export interface CommitsQueryResults { + readonly label?: string; + readonly log: GitLog | undefined; + readonly hasMore: boolean; + more?(limit: number | undefined): Promise; +} + +export interface FilesQueryResults { + label: string; + files: GitFile[] | undefined; + stats?: (GitDiffShortStat & { approximated?: boolean }) | undefined; + + filtered?: Map; +} + +export async function getAheadBehindFilesQuery( + container: Container, + repoPath: string, + comparison: string, + compareWithWorkingTree: boolean, +): Promise { + const [filesResult, workingFilesResult, statsResult, workingStatsResult] = await Promise.allSettled([ + container.git.getDiffStatus(repoPath, comparison), + compareWithWorkingTree ? container.git.getDiffStatus(repoPath, 'HEAD') : undefined, + container.git.getChangedFilesCount(repoPath, comparison), + compareWithWorkingTree ? container.git.getChangedFilesCount(repoPath, 'HEAD') : undefined, + ]); + + let files = getSettledValue(filesResult) ?? []; + let stats: FilesQueryResults['stats'] = getSettledValue(statsResult); + + if (compareWithWorkingTree) { + const workingFiles = getSettledValue(workingFilesResult); + if (workingFiles != null) { + if (files.length === 0) { + files = workingFiles ?? []; + } else { + for (const wf of workingFiles) { + const index = files.findIndex(f => f.path === wf.path); + if (index !== -1) { + files.splice(index, 1, wf); + } else { + files.push(wf); + } + } + } + } + + const workingStats = getSettledValue(workingStatsResult); + if (workingStats != null) { + if (stats == null) { + stats = workingStats; + } else { + stats = { + additions: stats.additions + workingStats.additions, + deletions: stats.deletions + workingStats.deletions, + changedFiles: files.length, + approximated: true, + }; + } + } + } + + return { + label: `${pluralize('file', files.length, { zero: 'No' })} changed`, + files: files, + stats: stats, + }; +} + +export function getCommitsQuery( + container: Container, + repoPath: string, + range: string, + filterByAuthors?: GitUser[] | undefined, +): (limit: number | undefined) => Promise { + return async (limit: number | undefined) => { + const log = await container.git.getLog(repoPath, { + limit: limit, + ref: range, + authors: filterByAuthors, + }); + + const results: Mutable = { + log: log, + hasMore: log?.hasMore ?? true, + }; + if (results.hasMore) { + results.more = async (limit: number | undefined) => { + results.log = (await results.log?.more?.(limit)) ?? results.log; + results.hasMore = results.log?.hasMore ?? true; + }; + } + + return results satisfies CommitsQueryResults; + }; +} + +export async function getFilesQuery( + container: Container, + repoPath: string, + ref1: string, + ref2: string, +): Promise { + let comparison; + if (ref2 === '') { + debugger; + throw new Error('Cannot get files for comparisons of a ref with working tree'); + } else if (ref1 === '') { + comparison = ref2; + } else { + comparison = `${ref2}..${ref1}`; + } + + const [filesResult, statsResult] = await Promise.allSettled([ + container.git.getDiffStatus(repoPath, comparison), + container.git.getChangedFilesCount(repoPath, comparison), + ]); + + const files = getSettledValue(filesResult) ?? []; + return { + label: `${pluralize('file', files.length, { zero: 'No' })} changed`, + files: files, + stats: getSettledValue(statsResult), + }; +} diff --git a/src/git/remotes/azure-devops.ts b/src/git/remotes/azure-devops.ts index 48dea74453494..07c654016e6b2 100644 --- a/src/git/remotes/azure-devops.ts +++ b/src/git/remotes/azure-devops.ts @@ -1,8 +1,10 @@ import type { Range, Uri } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; -import { AutolinkType } from '../../config'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { Brand, Unbrand } from '../../system/brand'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; const gitRegex = /\/_git\/?/i; @@ -15,7 +17,9 @@ const fileRegex = /path=([^&]+)/i; const rangeRegex = /line=(\d+)(?:&lineEnd=(\d+))?/; export class AzureDevOpsRemote extends RemoteProvider { + private readonly project: string | undefined; constructor(domain: string, path: string, protocol?: string, name?: string, legacy: boolean = false) { + let repoProject; if (sshDomainRegex.test(domain)) { path = path.replace(sshPathRegex, ''); domain = domain.replace(sshDomainRegex, ''); @@ -25,6 +29,8 @@ export class AzureDevOpsRemote extends RemoteProvider { if (match != null) { const [, org, project, rest] = match; + repoProject = project; + // Handle legacy vsts urls if (legacy) { domain = `${org}.${domain}`; @@ -33,6 +39,13 @@ export class AzureDevOpsRemote extends RemoteProvider { path = `${org}/${project}/_git/${rest}`; } } + } else { + const match = orgAndProjectRegex.exec(path); + if (match != null) { + const [, , project] = match; + + repoProject = project; + } } // Azure DevOps allows projects and repository names with spaces. In that situation, @@ -40,6 +53,7 @@ export class AzureDevOpsRemote extends RemoteProvider { // revert that encoding to avoid double-encoding by gitlens during copy remote and open remote path = decodeURIComponent(path); super(domain, path, protocol, name); + this.project = repoProject; } private _autolinks: (AutolinkReference | DynamicAutolinkReference)[] | undefined; @@ -53,16 +67,16 @@ export class AzureDevOpsRemote extends RemoteProvider { url: `${workUrl}/_workitems/edit/`, title: `Open Work Item # on ${this.name}`, - type: AutolinkType.Issue, + type: 'issue', description: `${this.name} Work Item #`, }, { // Default Pull request message when merging a PR in ADO. Will not catch commits & pushes following a different pattern. - prefix: 'Merged PR ', + prefix: 'PR ', url: `${this.baseUrl}/pullrequest/`, title: `Open Pull Request # on ${this.name}`, - type: AutolinkType.PullRequest, + type: 'pullrequest', description: `${this.name} Pull Request #`, }, ]; @@ -74,14 +88,38 @@ export class AzureDevOpsRemote extends RemoteProvider { return 'azdo'; } - get id() { + get id(): RemoteProviderId { return 'azure-devops'; } + get gkProviderId(): GkProviderId { + return 'azureDevops' satisfies Unbrand as Brand; + } + get name() { return 'Azure DevOps'; } + override get providerDesc(): + | { + id: GkProviderId; + repoDomain: string; + repoName: string; + repoOwnerDomain: string; + } + | undefined { + if (this.gkProviderId == null || this.owner == null || this.repoName == null || this.project == null) { + return undefined; + } + + return { + id: this.gkProviderId, + repoDomain: this.project, + repoName: this.repoName, + repoOwnerDomain: this.owner, + }; + } + private _displayPath: string | undefined; override get displayPath(): string { if (this._displayPath === undefined) { diff --git a/src/git/remotes/bitbucket-server.ts b/src/git/remotes/bitbucket-server.ts index 8a372e3dcbe4d..579db0e837b85 100644 --- a/src/git/remotes/bitbucket-server.ts +++ b/src/git/remotes/bitbucket-server.ts @@ -1,9 +1,11 @@ import type { Range, Uri } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; -import { AutolinkType } from '../../config'; -import { GitRevision } from '../models/reference'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { Brand, Unbrand } from '../../system/brand'; +import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; const fileRegex = /^\/([^/]+)\/([^/]+?)\/src(.+)$/i; @@ -23,7 +25,7 @@ export class BitbucketServerRemote extends RemoteProvider { url: `${this.baseUrl}/issues/`, title: `Open Issue # on ${this.name}`, - type: AutolinkType.Issue, + type: 'issue', description: `${this.name} Issue #`, }, { @@ -32,7 +34,7 @@ export class BitbucketServerRemote extends RemoteProvider { url: `${this.baseUrl}/pull-requests/`, title: `Open Pull Request # on ${this.name}`, - type: AutolinkType.PullRequest, + type: 'pullrequest', description: `${this.name} Pull Request #`, }, ]; @@ -41,20 +43,32 @@ export class BitbucketServerRemote extends RemoteProvider { } protected override get baseUrl(): string { - const [project, repo] = this.path.startsWith('scm/') - ? this.path.replace('scm/', '').split('/') - : this.splitPath(); + const [project, repo] = this.splitPath(); return `${this.protocol}://${this.domain}/projects/${project}/repos/${repo}`; } + protected override splitPath(): [string, string] { + if (this.path.startsWith('scm/')) { + const path = this.path.replace('scm/', ''); + const index = path.indexOf('/'); + return [this.path.substring(0, index), this.path.substring(index + 1)]; + } + + return super.splitPath(); + } + override get icon() { return 'bitbucket'; } - get id() { + get id(): RemoteProviderId { return 'bitbucket-server'; } + get gkProviderId(): GkProviderId { + return 'bitbucketServer' satisfies Unbrand as Brand; + } + get name() { return this.formatName('Bitbucket Server'); } @@ -91,8 +105,8 @@ export class BitbucketServerRemote extends RemoteProvider { let index = path.indexOf('/', 1); if (index !== -1) { const sha = path.substring(1, index); - if (GitRevision.isSha(sha)) { - const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (isSha(sha)) { + const uri = repository.toAbsoluteUri(path.substring(index), { validate: options?.validate }); if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; } } @@ -105,7 +119,7 @@ export class BitbucketServerRemote extends RemoteProvider { index = path.lastIndexOf('/', index - 1); branch = path.substring(1, index); - possibleBranches.set(branch, path.substr(index)); + possibleBranches.set(branch, path.substring(index)); } while (index > 0); if (possibleBranches.size !== 0) { diff --git a/src/git/remotes/bitbucket.ts b/src/git/remotes/bitbucket.ts index 06c2e7dd835f3..8736f7c3e33fa 100644 --- a/src/git/remotes/bitbucket.ts +++ b/src/git/remotes/bitbucket.ts @@ -1,9 +1,11 @@ import type { Range, Uri } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; -import { AutolinkType } from '../../config'; -import { GitRevision } from '../models/reference'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { Brand, Unbrand } from '../../system/brand'; +import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; const fileRegex = /^\/([^/]+)\/([^/]+?)\/src(.+)$/i; @@ -23,7 +25,7 @@ export class BitbucketRemote extends RemoteProvider { url: `${this.baseUrl}/issues/`, title: `Open Issue # on ${this.name}`, - type: AutolinkType.Issue, + type: 'issue', description: `${this.name} Issue #`, }, { @@ -31,7 +33,7 @@ export class BitbucketRemote extends RemoteProvider { url: `${this.baseUrl}/pull-requests/`, title: `Open Pull Request # on ${this.name}`, - type: AutolinkType.PullRequest, + type: 'pullrequest', description: `${this.name} Pull Request #`, }, ]; @@ -43,10 +45,14 @@ export class BitbucketRemote extends RemoteProvider { return 'bitbucket'; } - get id() { + get id(): RemoteProviderId { return 'bitbucket'; } + get gkProviderId(): GkProviderId { + return 'bitbucket' satisfies Unbrand as Brand; + } + get name() { return this.formatName('Bitbucket'); } @@ -83,8 +89,8 @@ export class BitbucketRemote extends RemoteProvider { let index = path.indexOf('/', 1); if (index !== -1) { const sha = path.substring(1, index); - if (GitRevision.isSha(sha)) { - const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (isSha(sha)) { + const uri = repository.toAbsoluteUri(path.substring(index), { validate: options?.validate }); if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; } } @@ -97,7 +103,7 @@ export class BitbucketRemote extends RemoteProvider { index = path.lastIndexOf('/', index - 1); branch = path.substring(1, index); - possibleBranches.set(branch, path.substr(index)); + possibleBranches.set(branch, path.substring(index)); } while (index > 0); if (possibleBranches.size !== 0) { diff --git a/src/git/remotes/custom.ts b/src/git/remotes/custom.ts index 15c20fe871f93..10beef3facece 100644 --- a/src/git/remotes/custom.ts +++ b/src/git/remotes/custom.ts @@ -1,7 +1,9 @@ import type { Range, Uri } from 'vscode'; -import type { RemotesUrlsConfig } from '../../configuration'; -import { interpolate } from '../../system/string'; +import type { RemotesUrlsConfig } from '../../config'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import { getTokensFromTemplate, interpolate } from '../../system/string'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; export class CustomRemote extends RemoteProvider { @@ -12,10 +14,14 @@ export class CustomRemote extends RemoteProvider { this.urls = urls; } - get id() { + get id(): RemoteProviderId { return 'custom'; } + get gkProviderId(): GkProviderId | undefined { + return undefined; + } + get name() { return this.formatName('Custom'); } @@ -28,50 +34,61 @@ export class CustomRemote extends RemoteProvider { } protected override getUrlForRepository(): string { - return this.encodeUrl(interpolate(this.urls.repository, this.getContext())); + return this.getUrl(this.urls.repository, this.getContext()); } protected getUrlForBranches(): string { - return this.encodeUrl(interpolate(this.urls.branches, this.getContext())); + return this.getUrl(this.urls.branches, this.getContext()); } protected getUrlForBranch(branch: string): string { - return this.encodeUrl(interpolate(this.urls.branch, this.getContext({ branch: branch }))); + return this.getUrl(this.urls.branch, this.getContext({ branch: branch })); } protected getUrlForCommit(sha: string): string { - return this.encodeUrl(interpolate(this.urls.commit, this.getContext({ id: sha }))); + return this.getUrl(this.urls.commit, this.getContext({ id: sha })); } protected override getUrlForComparison(base: string, compare: string, notation: '..' | '...'): string | undefined { if (this.urls.comparison == null) return undefined; - return this.encodeUrl( - interpolate(this.urls.comparison, this.getContext({ ref1: base, ref2: compare, notation: notation })), - ); + return this.getUrl(this.urls.comparison, this.getContext({ ref1: base, ref2: compare, notation: notation })); } protected getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string { let line; if (range != null) { if (range.start.line === range.end.line) { - line = interpolate(this.urls.fileLine, { line: range.start.line }); + line = interpolate(this.urls.fileLine, { line: range.start.line, line_encoded: range.start.line }); } else { - line = interpolate(this.urls.fileRange, { start: range.start.line, end: range.end.line }); + line = interpolate(this.urls.fileRange, { + start: range.start.line, + start_encoded: range.start.line, + end: range.end.line, + end_encoded: range.end.line, + }); } } else { line = ''; } - let url; + let template; + let context; if (sha) { - url = interpolate(this.urls.fileInCommit, this.getContext({ id: sha, file: fileName, line: line })); + template = this.urls.fileInCommit; + context = this.getContext({ id: sha, file: fileName, line: line }); } else if (branch) { - url = interpolate(this.urls.fileInBranch, this.getContext({ branch: branch, file: fileName, line: line })); + template = this.urls.fileInBranch; + context = this.getContext({ branch: branch, file: fileName, line: line }); } else { - url = interpolate(this.urls.file, this.getContext({ file: fileName, line: line })); + template = this.urls.file; + context = this.getContext({ file: fileName, line: line }); } + let url = interpolate(template, context); + const encoded = getTokensFromTemplate(template).some(t => t.key.endsWith('_encoded')); + if (encoded) return url; + const decodeHash = url.includes('#'); url = this.encodeUrl(url); if (decodeHash) { @@ -83,13 +100,25 @@ export class CustomRemote extends RemoteProvider { return url; } - private getContext(context?: Record) { + private getUrl(template: string, context: Record): string { + const url = interpolate(template, context); + const encoded = getTokensFromTemplate(template).some(t => t.key.endsWith('_encoded')); + return encoded ? url : this.encodeUrl(url); + } + + private getContext(additionalContext?: Record) { const [repoBase, repoPath] = this.splitPath(); - return { + const context: Record = { repo: this.path, repoBase: repoBase, repoPath: repoPath, - ...(context ?? {}), + ...additionalContext, }; + + for (const [key, value] of Object.entries(context)) { + context[`${key}_encoded`] = encodeURIComponent(value); + } + + return context; } } diff --git a/src/git/remotes/gerrit.ts b/src/git/remotes/gerrit.ts index c0f1d2a9a549c..f8660286236d8 100644 --- a/src/git/remotes/gerrit.ts +++ b/src/git/remotes/gerrit.ts @@ -1,8 +1,10 @@ import type { Range, Uri } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; -import { GitRevision } from '../models/reference'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; const fileRegex = /^\/([^/]+)\/\+(.+)$/i; @@ -53,10 +55,14 @@ export class GerritRemote extends RemoteProvider { return 'gerrit'; } - get id() { + get id(): RemoteProviderId { return 'gerrit'; } + get gkProviderId(): GkProviderId | undefined { + return undefined; // TODO@eamodio DRAFTS add this when supported by backend + } + get name() { return this.formatName('Gerrit'); } @@ -97,15 +103,15 @@ export class GerritRemote extends RemoteProvider { let index = path.indexOf('/', 1); if (index !== -1) { const sha = path.substring(1, index); - if (GitRevision.isSha(sha) || sha == 'HEAD') { - const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (isSha(sha) || sha == 'HEAD') { + const uri = repository.toAbsoluteUri(path.substring(index), { validate: options?.validate }); if (uri != null) return { uri: uri, startLine: startLine }; } } // Check for a link with branch (and deal with branch names with /) if (path.startsWith('/refs/heads/')) { - const branchPath = path.substr('/refs/heads/'.length); + const branchPath = path.substring('/refs/heads/'.length); let branch; const possibleBranches = new Map(); @@ -114,7 +120,7 @@ export class GerritRemote extends RemoteProvider { index = branchPath.lastIndexOf('/', index - 1); branch = branchPath.substring(1, index); - possibleBranches.set(branch, branchPath.substr(index)); + possibleBranches.set(branch, branchPath.substring(index)); } while (index > 0); if (possibleBranches.size !== 0) { @@ -135,7 +141,7 @@ export class GerritRemote extends RemoteProvider { // Check for a link with tag (and deal with tag names with /) if (path.startsWith('/refs/tags/')) { - const tagPath = path.substr('/refs/tags/'.length); + const tagPath = path.substring('/refs/tags/'.length); let tag; const possibleTags = new Map(); @@ -144,7 +150,7 @@ export class GerritRemote extends RemoteProvider { index = tagPath.lastIndexOf('/', index - 1); tag = tagPath.substring(1, index); - possibleTags.set(tag, tagPath.substr(index)); + possibleTags.set(tag, tagPath.substring(index)); } while (index > 0); if (possibleTags.size !== 0) { diff --git a/src/git/remotes/gitea.ts b/src/git/remotes/gitea.ts index 2013fecc79367..48ccadfeb10c9 100644 --- a/src/git/remotes/gitea.ts +++ b/src/git/remotes/gitea.ts @@ -1,9 +1,10 @@ import type { Range, Uri } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; -import { AutolinkType } from '../../config'; -import { GitRevision } from '../models/reference'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; +import type { RemoteProviderId } from './remoteProvider'; import { RemoteProvider } from './remoteProvider'; const fileRegex = /^\/([^/]+)\/([^/]+?)\/src(.+)$/i; @@ -23,7 +24,7 @@ export class GiteaRemote extends RemoteProvider { url: `${this.baseUrl}/issues/`, title: `Open Issue # on ${this.name}`, - type: AutolinkType.Issue, + type: 'issue', description: `${this.name} Issue #`, }, ]; @@ -35,10 +36,14 @@ export class GiteaRemote extends RemoteProvider { return 'gitea'; } - get id() { + get id(): RemoteProviderId { return 'gitea'; } + get gkProviderId(): GkProviderId | undefined { + return undefined; // TODO@eamodio DRAFTS add this when supported by backend + } + get name() { return this.formatName('Gitea'); } @@ -79,8 +84,8 @@ export class GiteaRemote extends RemoteProvider { index = path.indexOf('/', offset); if (index !== -1) { const sha = path.substring(offset, index); - if (GitRevision.isSha(sha)) { - const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (isSha(sha)) { + const uri = repository.toAbsoluteUri(path.substring(index), { validate: options?.validate }); if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; } } @@ -94,7 +99,7 @@ export class GiteaRemote extends RemoteProvider { index = offset; do { branch = path.substring(offset, index); - possibleBranches.set(branch, path.substr(index)); + possibleBranches.set(branch, path.substring(index)); index = path.indexOf('/', index + 1); } while (index < path.length && index !== -1); @@ -144,9 +149,9 @@ export class GiteaRemote extends RemoteProvider { line = ''; } - if (sha) return this.encodeUrl(`${this.baseUrl}/src/commit/${sha}/${fileName}${line}`); - if (branch) return this.encodeUrl(`${this.baseUrl}/src/branch/${branch}/${fileName}${line}`); + if (sha) return `${this.encodeUrl(`${this.baseUrl}/src/commit/${sha}/${fileName}`)}${line}`; + if (branch) return `${this.encodeUrl(`${this.baseUrl}/src/branch/${branch}/${fileName}`)}${line}`; // this route is deprecated but there is no alternative - return this.encodeUrl(`${this.baseUrl}/src/${fileName}${line}`); + return `${this.encodeUrl(`${this.baseUrl}/src/${fileName}`)}${line}`; } } diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index 537ae3f45f886..7b62466125a9b 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -1,50 +1,32 @@ -import type { AuthenticationSession, Disposable, QuickInputButton, Range } from 'vscode'; -import { env, ThemeIcon, Uri, window } from 'vscode'; -import type { Autolink, DynamicAutolinkReference } from '../../annotations/autolinks'; +import type { Range } from 'vscode'; +import { Uri } from 'vscode'; +import type { Autolink, DynamicAutolinkReference, MaybeEnrichedAutolink } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; -import type { Container } from '../../container'; -import type { - IntegrationAuthenticationProvider, - IntegrationAuthenticationSessionDescriptor, -} from '../../plus/integrationAuthentication'; -import { log } from '../../system/decorators/log'; +import { GlyphChars } from '../../constants'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { GitHubRepositoryDescriptor } from '../../plus/integrations/providers/github'; +import type { Brand, Unbrand } from '../../system/brand'; +import { fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; import { encodeUrl } from '../../system/encoding'; -import { equalsIgnoreCase } from '../../system/string'; -import type { Account } from '../models/author'; -import type { DefaultBranch } from '../models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; -import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; -import { GitRevision } from '../models/reference'; +import { equalsIgnoreCase, escapeMarkdown, unescapeMarkdown } from '../../system/string'; +import { getIssueOrPullRequestMarkdownIcon } from '../models/issue'; +import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; -import { ensurePaidPlan, RichRemoteProvider } from './richRemoteProvider'; +import type { RemoteProviderId } from './remoteProvider'; +import { RemoteProvider } from './remoteProvider'; -const autolinkFullIssuesRegex = /\b(?[^/\s]+\/[^/\s]+)#(?[0-9]+)\b(?!]\()/g; +const autolinkFullIssuesRegex = /\b([^/\s]+\/[^/\s]+?)(?:\\)?#([0-9]+)\b(?!]\()/g; const fileRegex = /^\/([^/]+)\/([^/]+?)\/blob(.+)$/i; const rangeRegex = /^L(\d+)(?:-L(\d+))?$/; -const authProvider = Object.freeze({ id: 'github', scopes: ['repo', 'read:user', 'user:email'] }); -const enterpriseAuthProvider = Object.freeze({ id: 'github-enterprise', scopes: ['repo', 'read:user', 'user:email'] }); - function isGitHubDotCom(domain: string): boolean { return equalsIgnoreCase(domain, 'github.com'); } -export class GitHubRemote extends RichRemoteProvider { - @memoize() - protected get authProvider() { - return isGitHubDotCom(this.domain) ? authProvider : enterpriseAuthProvider; - } - - constructor( - container: Container, - domain: string, - path: string, - protocol?: string, - name?: string, - custom: boolean = false, - ) { - super(container, domain, path, protocol, name, custom); +export class GitHubRemote extends RemoteProvider { + constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { + super(domain, path, protocol, name, custom); } get apiBaseUrl() { @@ -75,11 +57,16 @@ export class GitHubRemote extends RichRemoteProvider { text: string, outputFormat: 'html' | 'markdown' | 'plaintext', tokenMapping: Map, + enrichedAutolinks?: Map, + prs?: Set, + footnotes?: Map, ) => { return outputFormat === 'plaintext' ? text : text.replace(autolinkFullIssuesRegex, (linkText: string, repo: string, num: string) => { - const url = encodeUrl(`${this.protocol}://${this.domain}/${repo}/issues/${num}`); + const url = encodeUrl( + `${this.protocol}://${this.domain}/${unescapeMarkdown(repo)}/issues/${num}`, + ); const title = ` "Open Issue or Pull Request #${num} from ${repo} on ${this.name}"`; const token = `\x00${tokenMapping.size}\x00`; @@ -89,28 +76,76 @@ export class GitHubRemote extends RichRemoteProvider { tokenMapping.set(token, `${linkText}`); } + let footnoteIndex: number; + + const issueResult = enrichedAutolinks?.get(num)?.[0]; + if (issueResult?.value != null) { + if (issueResult.paused) { + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} ${ + this.name + } Issue or Pull Request ${repo}#${num} $(loading~spin)](${url}${title}")`, + ); + } + } else { + const issue = issueResult.value; + const issueTitle = escapeMarkdown(issue.title.trim()); + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon( + issue, + )} **${issueTitle}**](${url}${title})\\\n${GlyphChars.Space.repeat( + 5, + )}${linkText} ${issue.state} ${fromNow( + issue.closedDate ?? issue.createdDate, + )}`, + ); + } + } + } else if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} ${ + this.name + } Issue or Pull Request ${repo}#${num}](${url}${title})`, + ); + } + return token; }); }, parse: (text: string, autolinks: Map) => { - let repo: string; + let ownerAndRepo: string; let num: string; let match; do { match = autolinkFullIssuesRegex.exec(text); - if (match?.groups == null) break; + if (match == null) break; - ({ repo, num } = match.groups); + [, ownerAndRepo, num] = match; + const [owner, repo] = ownerAndRepo.split('/', 2); autolinks.set(num, { provider: this, id: num, - prefix: `${repo}#`, - url: `${this.protocol}://${this.domain}/${repo}/issues/${num}`, - title: `Open Issue or Pull Request # from ${repo} on ${this.name}`, + prefix: `${ownerAndRepo}#`, + url: `${this.protocol}://${this.domain}/${ownerAndRepo}/issues/${num}`, + title: `Open Issue or Pull Request # from ${ownerAndRepo} on ${this.name}`, + + description: `${this.name} Issue or Pull Request ${ownerAndRepo}#${num}`, - description: `${this.name} Issue or Pull Request ${repo}#${num}`, + descriptor: { + key: this.remoteKey, + owner: owner, + name: repo, + } satisfies GitHubRepositoryDescriptor, }); } while (true); }, @@ -129,23 +164,24 @@ export class GitHubRemote extends RichRemoteProvider { return 'github'; } - get id() { + get id(): RemoteProviderId { return 'github'; } + get gkProviderId(): GkProviderId { + return (!isGitHubDotCom(this.domain) + ? 'githubEnterprise' + : 'github') satisfies Unbrand as Brand; + } + get name() { return this.formatName('GitHub'); } - @log() - override async connect(): Promise { - if (!isGitHubDotCom(this.domain)) { - if (!(await ensurePaidPlan('GitHub Enterprise instance', this.container))) { - return false; - } - } - - return super.connect(); + @memoize() + override get repoDesc(): GitHubRepositoryDescriptor { + const [owner, repo] = this.splitPath(); + return { key: this.remoteKey, owner: owner, name: repo }; } async getLocalInfoFromRemoteUri( @@ -180,8 +216,8 @@ export class GitHubRemote extends RichRemoteProvider { let index = path.indexOf('/', 1); if (index !== -1) { const sha = path.substring(1, index); - if (GitRevision.isSha(sha)) { - const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (isSha(sha)) { + const uri = repository.toAbsoluteUri(path.substring(index), { validate: options?.validate }); if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; } } @@ -194,7 +230,7 @@ export class GitHubRemote extends RichRemoteProvider { index = path.lastIndexOf('/', index - 1); branch = path.substring(1, index); - possibleBranches.set(branch, path.substr(index)); + possibleBranches.set(branch, path.substring(index)); } while (index > 0); if (possibleBranches.size !== 0) { @@ -257,99 +293,6 @@ export class GitHubRemote extends RichRemoteProvider { if (branch) return `${this.encodeUrl(`${this.baseUrl}/blob/${branch}/${fileName}`)}${line}`; return `${this.encodeUrl(`${this.baseUrl}?path=${fileName}`)}${line}`; } - - protected async getProviderAccountForCommit( - { accessToken }: AuthenticationSession, - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getAccountForCommit(this, accessToken, owner, repo, ref, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected async getProviderAccountForEmail( - { accessToken }: AuthenticationSession, - email: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getAccountForEmail(this, accessToken, owner, repo, email, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected async getProviderDefaultBranch({ - accessToken, - }: AuthenticationSession): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getDefaultBranch(this, accessToken, owner, repo, { - baseUrl: this.apiBaseUrl, - }); - } - - protected async getProviderIssueOrPullRequest( - { accessToken }: AuthenticationSession, - id: string, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getIssueOrPullRequest(this, accessToken, owner, repo, Number(id), { - baseUrl: this.apiBaseUrl, - }); - } - - protected async getProviderPullRequestForBranch( - { accessToken }: AuthenticationSession, - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - const { include, ...opts } = options ?? {}; - - const GitHubPullRequest = (await import(/* webpackChunkName: "github" */ '../../plus/github/models')) - .GitHubPullRequest; - return (await this.container.github)?.getPullRequestForBranch(this, accessToken, owner, repo, branch, { - ...opts, - include: include?.map(s => GitHubPullRequest.toState(s)), - baseUrl: this.apiBaseUrl, - }); - } - - protected async getProviderPullRequestForCommit( - { accessToken }: AuthenticationSession, - ref: string, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getPullRequestForCommit(this, accessToken, owner, repo, ref, { - baseUrl: this.apiBaseUrl, - }); - } - - protected async searchProviderMyPullRequests({ - accessToken, - }: AuthenticationSession): Promise { - return (await this.container.github)?.searchMyPullRequests(this, accessToken, { - repos: [this.path], - }); - } - - protected async searchProviderMyIssues({ - accessToken, - }: AuthenticationSession): Promise { - return (await this.container.github)?.searchMyIssues(this, accessToken, { - repos: [this.path], - }); - } } const gitHubNoReplyAddressRegex = /^(?:(\d+)\+)?([a-zA-Z\d-]{1,39})@users\.noreply\.(.*)$/i; @@ -363,82 +306,3 @@ export function getGitHubNoReplyAddressParts( const [, userId, login, authority] = match; return { userId: userId, login: login, authority: authority }; } - -export class GitHubAuthenticationProvider implements Disposable, IntegrationAuthenticationProvider { - private readonly _disposable: Disposable; - - constructor(container: Container) { - this._disposable = container.integrationAuthentication.registerProvider('github-enterprise', this); - } - - dispose() { - this._disposable.dispose(); - } - - getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { - return descriptor?.domain ?? ''; - } - - async createSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, - ): Promise { - const input = window.createInputBox(); - input.ignoreFocusOut = true; - - const disposables: Disposable[] = []; - - let token; - try { - const infoButton: QuickInputButton = { - iconPath: new ThemeIcon(`link-external`), - tooltip: 'Open Access Tokens page on GitHub', - }; - - token = await new Promise(resolve => { - disposables.push( - input.onDidHide(() => resolve(undefined)), - input.onDidChangeValue(() => (input.validationMessage = undefined)), - input.onDidAccept(() => { - const value = input.value.trim(); - if (!value) { - input.validationMessage = 'A personal access token is required'; - return; - } - - resolve(value); - }), - input.onDidTriggerButton(e => { - if (e === infoButton) { - void env.openExternal( - Uri.parse(`https://${descriptor?.domain ?? 'github.com'}/settings/tokens`), - ); - } - }), - ); - - input.password = true; - input.title = `GitHub Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; - input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; - input.prompt = 'Paste your GitHub Personal Access Token'; - input.buttons = [infoButton]; - - input.show(); - }); - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); - } - - if (!token) return undefined; - - return { - id: this.getSessionId(descriptor), - accessToken: token, - scopes: [], - account: { - id: '', - label: '', - }, - }; - } -} diff --git a/src/git/remotes/gitlab.ts b/src/git/remotes/gitlab.ts index a3fe17b5ac25a..826c882ed19d6 100644 --- a/src/git/remotes/gitlab.ts +++ b/src/git/remotes/gitlab.ts @@ -1,45 +1,32 @@ -import type { AuthenticationSession, Disposable, QuickInputButton, Range } from 'vscode'; -import { env, ThemeIcon, Uri, window } from 'vscode'; -import type { Autolink, DynamicAutolinkReference } from '../../annotations/autolinks'; +import type { Range, Uri } from 'vscode'; +import type { Autolink, DynamicAutolinkReference, MaybeEnrichedAutolink } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; -import { AutolinkType } from '../../config'; -import type { Container } from '../../container'; -import type { - IntegrationAuthenticationProvider, - IntegrationAuthenticationSessionDescriptor, -} from '../../plus/integrationAuthentication'; -import { log } from '../../system/decorators/log'; +import { GlyphChars } from '../../constants'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { GitLabRepositoryDescriptor } from '../../plus/integrations/providers/gitlab'; +import type { Brand, Unbrand } from '../../system/brand'; +import { fromNow } from '../../system/date'; +import { memoize } from '../../system/decorators/memoize'; import { encodeUrl } from '../../system/encoding'; -import { equalsIgnoreCase } from '../../system/string'; -import type { Account } from '../models/author'; -import type { DefaultBranch } from '../models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; -import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; -import { GitRevision } from '../models/reference'; +import { equalsIgnoreCase, escapeMarkdown, unescapeMarkdown } from '../../system/string'; +import { getIssueOrPullRequestMarkdownIcon } from '../models/issue'; +import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; -import { ensurePaidPlan, RichRemoteProvider } from './richRemoteProvider'; +import type { RemoteProviderId } from './remoteProvider'; +import { RemoteProvider } from './remoteProvider'; -const autolinkFullIssuesRegex = /\b(?[^/\s]+\/[^/\s]+)#(?[0-9]+)\b(?!]\()/g; -const autolinkFullMergeRequestsRegex = /\b(?[^/\s]+\/[^/\s]+)!(?[0-9]+)\b(?!]\()/g; +const autolinkFullIssuesRegex = /\b([^/\s]+\/[^/\s]+?)(?:\\)?#([0-9]+)\b(?!]\()/g; +const autolinkFullMergeRequestsRegex = /\b([^/\s]+\/[^/\s]+?)(?:\\)?!([0-9]+)\b(?!]\()/g; const fileRegex = /^\/([^/]+)\/([^/]+?)\/-\/blob(.+)$/i; const rangeRegex = /^L(\d+)(?:-(\d+))?$/; -const authProvider = Object.freeze({ id: 'gitlab', scopes: ['read_api', 'read_user', 'read_repository'] }); - -export class GitLabRemote extends RichRemoteProvider { - protected get authProvider() { - return authProvider; - } +function isGitLabDotCom(domain: string): boolean { + return equalsIgnoreCase(domain, 'gitlab.com'); +} - constructor( - container: Container, - domain: string, - path: string, - protocol?: string, - name?: string, - custom: boolean = false, - ) { - super(container, domain, path, protocol, name, custom); +export class GitLabRemote extends RemoteProvider { + constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { + super(domain, path, protocol, name, custom); } get apiBaseUrl() { @@ -55,7 +42,7 @@ export class GitLabRemote extends RichRemoteProvider { url: `${this.baseUrl}/-/issues/`, title: `Open Issue # on ${this.name}`, - type: AutolinkType.Issue, + type: 'issue', description: `${this.name} Issue #`, }, { @@ -63,7 +50,7 @@ export class GitLabRemote extends RichRemoteProvider { url: `${this.baseUrl}/-/merge_requests/`, title: `Open Merge Request ! on ${this.name}`, - type: AutolinkType.PullRequest, + type: 'pullrequest', description: `${this.name} Merge Request !`, }, { @@ -71,11 +58,16 @@ export class GitLabRemote extends RichRemoteProvider { text: string, outputFormat: 'html' | 'markdown' | 'plaintext', tokenMapping: Map, + enrichedAutolinks?: Map, + prs?: Set, + footnotes?: Map, ) => { return outputFormat === 'plaintext' ? text : text.replace(autolinkFullIssuesRegex, (linkText: string, repo: string, num: string) => { - const url = encodeUrl(`${this.protocol}://${this.domain}/${repo}/-/issues/${num}`); + const url = encodeUrl( + `${this.protocol}://${this.domain}/${unescapeMarkdown(repo)}/-/issues/${num}`, + ); const title = ` "Open Issue #${num} from ${repo} on ${this.name}"`; const token = `\x00${tokenMapping.size}\x00`; @@ -85,29 +77,72 @@ export class GitLabRemote extends RichRemoteProvider { tokenMapping.set(token, `${linkText}`); } + let footnoteIndex: number; + + const issueResult = enrichedAutolinks?.get(num)?.[0]; + if (issueResult?.value != null) { + if (issueResult.paused) { + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} GitLab Issue ${repo}#${num} $(loading~spin)](${url}${title}")`, + ); + } + } else { + const issue = issueResult.value; + const issueTitle = escapeMarkdown(issue.title.trim()); + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon( + issue, + )} **${issueTitle}**](${url}${title})\\\n${GlyphChars.Space.repeat( + 5, + )}${linkText} ${issue.state} ${fromNow( + issue.closedDate ?? issue.createdDate, + )}`, + ); + } + } + } else if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} GitLab Issue ${repo}#${num}](${url}${title})`, + ); + } + return token; }); }, parse: (text: string, autolinks: Map) => { - let repo: string; + let ownerAndRepo: string; let num: string; let match; do { match = autolinkFullIssuesRegex.exec(text); - if (match?.groups == null) break; + if (match == null) break; - ({ repo, num } = match.groups); + [, ownerAndRepo, num] = match; + const [owner, repo] = ownerAndRepo.split('/', 2); autolinks.set(num, { provider: this, id: num, - prefix: `${repo}#`, - url: `${this.protocol}://${this.domain}/${repo}/-/issues/${num}`, - title: `Open Issue # from ${repo} on ${this.name}`, - - type: AutolinkType.Issue, - description: `${this.name} Issue ${repo}#${num}`, + prefix: `${ownerAndRepo}#`, + url: `${this.protocol}://${this.domain}/${ownerAndRepo}/-/issues/${num}`, + title: `Open Issue # from ${ownerAndRepo} on ${this.name}`, + + type: 'issue', + description: `${this.name} Issue ${ownerAndRepo}#${num}`, + descriptor: { + key: this.remoteKey, + owner: owner, + name: repo, + } satisfies GitLabRepositoryDescriptor, }); } while (true); }, @@ -117,6 +152,9 @@ export class GitLabRemote extends RichRemoteProvider { text: string, outputFormat: 'html' | 'markdown' | 'plaintext', tokenMapping: Map, + enrichedAutolinks?: Map, + prs?: Set, + footnotes?: Map, ) => { return outputFormat === 'plaintext' ? text @@ -135,30 +173,77 @@ export class GitLabRemote extends RichRemoteProvider { tokenMapping.set(token, `${linkText}`); } + let footnoteIndex: number; + + const issueResult = enrichedAutolinks?.get(num)?.[0]; + if (issueResult?.value != null) { + if (issueResult.paused) { + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} ${ + this.name + } Merge Request ${repo}!${num} $(loading~spin)](${url}${title}")`, + ); + } + } else { + const issue = issueResult.value; + const issueTitle = escapeMarkdown(issue.title.trim()); + if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon( + issue, + )} **${issueTitle}**](${url}${title})\\\n${GlyphChars.Space.repeat( + 5, + )}${linkText} ${issue.state} ${fromNow( + issue.closedDate ?? issue.createdDate, + )}`, + ); + } + } + } else if (footnotes != null && !prs?.has(num)) { + footnoteIndex = footnotes.size + 1; + footnotes.set( + footnoteIndex, + `[${getIssueOrPullRequestMarkdownIcon()} ${ + this.name + } Merge Request ${repo}!${num}](${url}${title})`, + ); + } return token; }, ); }, parse: (text: string, autolinks: Map) => { - let repo: string; + let ownerAndRepo: string; let num: string; let match; do { match = autolinkFullMergeRequestsRegex.exec(text); - if (match?.groups == null) break; + if (match == null) break; - ({ repo, num } = match.groups); + [, ownerAndRepo, num] = match; + const [owner, repo] = ownerAndRepo.split('/', 2); autolinks.set(num, { provider: this, id: num, - prefix: `${repo}!`, - url: `${this.protocol}://${this.domain}/${repo}/-/merge_requests/${num}`, - title: `Open Merge Request ! from ${repo} on ${this.name}`, - - type: AutolinkType.PullRequest, - description: `Merge Request !${num} from ${repo} on ${this.name}`, + prefix: `${ownerAndRepo}!`, + url: `${this.protocol}://${this.domain}/${ownerAndRepo}/-/merge_requests/${num}`, + title: `Open Merge Request ! from ${ownerAndRepo} on ${this.name}`, + + type: 'pullrequest', + description: `${this.name} Merge Request !${num} from ${ownerAndRepo}`, + + descriptor: { + key: this.remoteKey, + owner: owner, + name: repo, + } satisfies GitLabRepositoryDescriptor, }); } while (true); }, @@ -172,23 +257,24 @@ export class GitLabRemote extends RichRemoteProvider { return 'gitlab'; } - get id() { + get id(): RemoteProviderId { return 'gitlab'; } + get gkProviderId(): GkProviderId { + return (!isGitLabDotCom(this.domain) + ? 'gitlabSelfHosted' + : 'gitlab') satisfies Unbrand as Brand; + } + get name() { return this.formatName('GitLab'); } - @log() - override async connect(): Promise { - if (!equalsIgnoreCase(this.domain, 'gitlab.com')) { - if (!(await ensurePaidPlan('GitLab self-managed instance', this.container))) { - return false; - } - } - - return super.connect(); + @memoize() + override get repoDesc(): GitLabRepositoryDescriptor { + const [owner, repo] = this.splitPath(); + return { key: this.remoteKey, owner: owner, name: repo }; } async getLocalInfoFromRemoteUri( @@ -223,8 +309,8 @@ export class GitLabRemote extends RichRemoteProvider { let index = path.indexOf('/', 1); if (index !== -1) { const sha = path.substring(1, index); - if (GitRevision.isSha(sha)) { - const uri = repository.toAbsoluteUri(path.substr(index), { validate: options?.validate }); + if (isSha(sha)) { + const uri = repository.toAbsoluteUri(path.substring(index), { validate: options?.validate }); if (uri != null) return { uri: uri, startLine: startLine, endLine: endLine }; } } @@ -237,7 +323,7 @@ export class GitLabRemote extends RichRemoteProvider { index = path.lastIndexOf('/', index - 1); branch = path.substring(1, index); - possibleBranches.set(branch, path.substr(index)); + possibleBranches.set(branch, path.substring(index)); } while (index > 0); if (possibleBranches.size !== 0) { @@ -288,172 +374,4 @@ export class GitLabRemote extends RichRemoteProvider { if (branch) return `${this.encodeUrl(`${this.baseUrl}/-/blob/${branch}/${fileName}`)}${line}`; return `${this.encodeUrl(`${this.baseUrl}?path=${fileName}`)}${line}`; } - - protected async getProviderAccountForCommit( - { accessToken }: AuthenticationSession, - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getAccountForCommit(this, accessToken, owner, repo, ref, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected async getProviderAccountForEmail( - { accessToken }: AuthenticationSession, - email: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getAccountForEmail(this, accessToken, owner, repo, email, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected async getProviderDefaultBranch({ - accessToken, - }: AuthenticationSession): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getDefaultBranch(this, accessToken, owner, repo, { - baseUrl: this.apiBaseUrl, - }); - } - - protected async getProviderIssueOrPullRequest( - { accessToken }: AuthenticationSession, - id: string, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getIssueOrPullRequest(this, accessToken, owner, repo, Number(id), { - baseUrl: this.apiBaseUrl, - }); - } - - protected async getProviderPullRequestForBranch( - { accessToken }: AuthenticationSession, - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - const { include, ...opts } = options ?? {}; - - const GitLabMergeRequest = (await import(/* webpackChunkName: "gitlab" */ '../../plus/gitlab/models')) - .GitLabMergeRequest; - return (await this.container.gitlab)?.getPullRequestForBranch(this, accessToken, owner, repo, branch, { - ...opts, - include: include?.map(s => GitLabMergeRequest.toState(s)), - baseUrl: this.apiBaseUrl, - }); - } - - protected async getProviderPullRequestForCommit( - { accessToken }: AuthenticationSession, - ref: string, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getPullRequestForCommit(this, accessToken, owner, repo, ref, { - baseUrl: this.apiBaseUrl, - }); - } - - protected async searchProviderMyPullRequests( - _session: AuthenticationSession, - ): Promise { - return Promise.resolve(undefined); - } - - protected async searchProviderMyIssues(_session: AuthenticationSession): Promise { - return Promise.resolve(undefined); - } -} - -export class GitLabAuthenticationProvider implements Disposable, IntegrationAuthenticationProvider { - private readonly _disposable: Disposable; - - constructor(container: Container) { - this._disposable = container.integrationAuthentication.registerProvider('gitlab', this); - } - - dispose() { - this._disposable.dispose(); - } - - getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { - return descriptor?.domain ?? ''; - } - - async createSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, - ): Promise { - const input = window.createInputBox(); - input.ignoreFocusOut = true; - - const disposables: Disposable[] = []; - - let token; - try { - const infoButton: QuickInputButton = { - iconPath: new ThemeIcon(`link-external`), - tooltip: 'Open Access Tokens page on GitLab', - }; - - token = await new Promise(resolve => { - disposables.push( - input.onDidHide(() => resolve(undefined)), - input.onDidChangeValue(() => (input.validationMessage = undefined)), - input.onDidAccept(() => { - const value = input.value.trim(); - if (!value) { - input.validationMessage = 'A personal access token is required'; - return; - } - - resolve(value); - }), - input.onDidTriggerButton(e => { - if (e === infoButton) { - void env.openExternal( - Uri.parse( - `https://${descriptor?.domain ?? 'gitlab.com'}/-/profile/personal_access_tokens`, - ), - ); - } - }), - ); - - input.password = true; - input.title = `GitLab Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; - input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; - input.prompt = 'Paste your GitLab Personal Access Token'; - input.buttons = [infoButton]; - - input.show(); - }); - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); - } - - if (!token) return undefined; - - return { - id: this.getSessionId(descriptor), - accessToken: token, - scopes: [], - account: { - id: '', - label: '', - }, - }; - } } diff --git a/src/git/remotes/google-source.ts b/src/git/remotes/google-source.ts index 87523e2467535..7d0995cb7a9e9 100644 --- a/src/git/remotes/google-source.ts +++ b/src/git/remotes/google-source.ts @@ -1,14 +1,20 @@ +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; import { GerritRemote } from './gerrit'; +import type { RemoteProviderId } from './remoteProvider'; export class GoogleSourceRemote extends GerritRemote { constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { super(domain, path, protocol, name, custom, false); } - override get id() { + override get id(): RemoteProviderId { return 'google-source'; } + override get gkProviderId(): GkProviderId | undefined { + return undefined; // TODO@eamodio DRAFTS add this when supported by backend + } + override get name() { return this.formatName('Google Source'); } diff --git a/src/git/remotes/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index 92d112465088a..1461fdc0c9460 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -1,16 +1,30 @@ -import type { Range } from 'vscode'; -import { env, Uri } from 'vscode'; +import type { Range, Uri } from 'vscode'; +import { env } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; +import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { ResourceDescriptor } from '../../plus/integrations/integration'; +import { memoize } from '../../system/decorators/memoize'; import { encodeUrl } from '../../system/encoding'; -import type { RemoteProviderReference } from '../models/remoteProvider'; +import { getSettledValue } from '../../system/promise'; +import { openUrl } from '../../system/vscode/utils'; +import type { ProviderReference } from '../models/remoteProvider'; import type { RemoteResource } from '../models/remoteResource'; import { RemoteResourceType } from '../models/remoteResource'; import type { Repository } from '../models/repository'; -import type { RichRemoteProvider } from './richRemoteProvider'; -export abstract class RemoteProvider implements RemoteProviderReference { - readonly type: 'simple' | 'rich' = 'simple'; +export type RemoteProviderId = + | 'azure-devops' + | 'bitbucket' + | 'bitbucket-server' + | 'custom' + | 'gerrit' + | 'gitea' + | 'github' + | 'gitlab' + | 'google-source'; + +export abstract class RemoteProvider implements ProviderReference { protected readonly _name: string | undefined; constructor( @@ -39,20 +53,45 @@ export abstract class RemoteProvider implements RemoteProviderReference { return 'remote'; } - abstract get id(): string; - abstract get name(): string; + get owner(): string | undefined { + return this.splitPath()[0]; + } - async copy(resource: RemoteResource): Promise { - const url = this.url(resource); - if (url == null) { - return; - } + @memoize() + get remoteKey() { + return this.domain ? `${this.domain}/${this.path}` : this.path; + } - await env.clipboard.writeText(url); + get repoDesc(): T { + return { owner: this.owner, name: this.repoName } as unknown as T; } - hasRichIntegration(): this is RichRemoteProvider { - return this.type === 'rich'; + get providerDesc(): + | { + id: GkProviderId; + repoDomain: string; + repoName: string; + repoOwnerDomain?: string; + } + | undefined { + if (this.gkProviderId == null || this.owner == null || this.repoName == null) return undefined; + + return { id: this.gkProviderId, repoDomain: this.owner, repoName: this.repoName }; + } + + get repoName(): string | undefined { + return this.splitPath()[1]; + } + + abstract get id(): RemoteProviderId; + abstract get gkProviderId(): GkProviderId | undefined; + abstract get name(): string; + + async copy(resource: RemoteResource | RemoteResource[]): Promise { + const urls = this.getUrlsFromResources(resource); + if (!urls.length) return; + + await env.clipboard.writeText(urls.join('\n')); } abstract getLocalInfoFromRemoteUri( @@ -61,8 +100,12 @@ export abstract class RemoteProvider implements RemoteProviderReference { options?: { validate?: boolean }, ): Promise<{ uri: Uri; startLine?: number; endLine?: number } | undefined>; - open(resource: RemoteResource): Promise { - return this.openUrl(this.url(resource)); + async open(resource: RemoteResource | RemoteResource[]): Promise { + const urls = this.getUrlsFromResources(resource); + if (!urls.length) return false; + + const results = await Promise.allSettled(urls.map(openUrl)); + return results.every(r => getSettledValue(r) === true); } url(resource: RemoteResource): string | undefined { @@ -138,23 +181,32 @@ export abstract class RemoteProvider implements RemoteProviderReference { return this.baseUrl; } - private async openUrl(url?: string): Promise { - if (url == null) { - return undefined; - } - - const uri = Uri.parse(url); - // Pass a string to openExternal to avoid double encoding issues: https://github.com/microsoft/vscode/issues/85930 - if (uri.path.includes('#')) { - // .d.ts currently says it only supports a Uri, but it actually accepts a string too - return (env.openExternal as unknown as (target: string) => Thenable)(uri.toString()); - } - return env.openExternal(uri); - } - protected encodeUrl(url: string): string; protected encodeUrl(url: string | undefined): string | undefined; protected encodeUrl(url: string | undefined): string | undefined { return encodeUrl(url)?.replace(/#/g, '%23'); } + + private getUrlsFromResources(resource: RemoteResource | RemoteResource[]): string[] { + const urls: string[] = []; + + if (Array.isArray(resource)) { + for (const r of resource) { + const url = this.url(r); + if (url == null) continue; + + urls.push(url); + } + } else { + const url = this.url(resource); + if (url != null) { + urls.push(url); + } + } + return urls; + } +} + +export function getRemoteProviderThemeIconString(provider: RemoteProvider | undefined): string { + return provider != null ? `gitlens-provider-${provider.icon}` : 'cloud'; } diff --git a/src/git/remotes/remoteProviderConnections.ts b/src/git/remotes/remoteProviderConnections.ts deleted file mode 100644 index c51e30a732063..0000000000000 --- a/src/git/remotes/remoteProviderConnections.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Event } from 'vscode'; -import { EventEmitter } from 'vscode'; -import { Container } from '../../container'; - -export interface ConnectionStateChangeEvent { - key: string; - reason: 'connected' | 'disconnected'; -} - -export namespace RichRemoteProviders { - const _connectedCache = new Set(); - export const _onDidChangeConnectionState = new EventEmitter(); - export const onDidChangeConnectionState: Event = _onDidChangeConnectionState.event; - - export function connected(key: string): void { - // Only fire events if the key is being connected for the first time - if (_connectedCache.has(key)) return; - - _connectedCache.add(key); - Container.instance.telemetry.sendEvent('remoteProviders/connected', { 'remoteProviders.key': key }); - - _onDidChangeConnectionState.fire({ key: key, reason: 'connected' }); - } - - export function disconnected(key: string): void { - // Probably shouldn't bother to fire the event if we don't already think we are connected, but better to be safe - // if (!_connectedCache.has(key)) return; - _connectedCache.delete(key); - Container.instance.telemetry.sendEvent('remoteProviders/disconnected', { 'remoteProviders.key': key }); - - _onDidChangeConnectionState.fire({ key: key, reason: 'disconnected' }); - } -} diff --git a/src/git/remotes/remoteProviders.ts b/src/git/remotes/remoteProviders.ts index 10565ab69d46f..8604802ff9f73 100644 --- a/src/git/remotes/remoteProviders.ts +++ b/src/git/remotes/remoteProviders.ts @@ -1,7 +1,7 @@ -import type { RemotesConfig } from '../../configuration'; -import { CustomRemoteType } from '../../configuration'; +import type { RemotesConfig } from '../../config'; import type { Container } from '../../container'; -import { Logger } from '../../logger'; +import { Logger } from '../../system/logger'; +import { configuration } from '../../system/vscode/configuration'; import { AzureDevOpsRemote } from './azure-devops'; import { BitbucketRemote } from './bitbucket'; import { BitbucketServerRemote } from './bitbucket-server'; @@ -28,12 +28,12 @@ const builtInProviders: RemoteProviders = [ { custom: false, matcher: 'github.com', - creator: (container: Container, domain: string, path: string) => new GitHubRemote(container, domain, path), + creator: (_container: Container, domain: string, path: string) => new GitHubRemote(domain, path), }, { custom: false, matcher: 'gitlab.com', - creator: (container: Container, domain: string, path: string) => new GitLabRemote(container, domain, path), + creator: (_container: Container, domain: string, path: string) => new GitLabRemote(domain, path), }, { custom: false, @@ -48,7 +48,7 @@ const builtInProviders: RemoteProviders = [ { custom: false, matcher: /\bgitlab\b/i, - creator: (container: Container, domain: string, path: string) => new GitLabRemote(container, domain, path), + creator: (_container: Container, domain: string, path: string) => new GitLabRemote(domain, path), }, { custom: false, @@ -104,33 +104,33 @@ export function loadRemoteProviders(cfg: RemotesConfig[] | null | undefined): Re function getCustomProviderCreator(cfg: RemotesConfig) { switch (cfg.type) { - case CustomRemoteType.AzureDevOps: + case 'AzureDevOps': return (_container: Container, domain: string, path: string) => new AzureDevOpsRemote(domain, path, cfg.protocol, cfg.name, true); - case CustomRemoteType.Bitbucket: + case 'Bitbucket': return (_container: Container, domain: string, path: string) => new BitbucketRemote(domain, path, cfg.protocol, cfg.name, true); - case CustomRemoteType.BitbucketServer: + case 'BitbucketServer': return (_container: Container, domain: string, path: string) => new BitbucketServerRemote(domain, path, cfg.protocol, cfg.name, true); - case CustomRemoteType.Custom: + case 'Custom': return (_container: Container, domain: string, path: string) => new CustomRemote(domain, path, cfg.urls!, cfg.protocol, cfg.name); - case CustomRemoteType.Gerrit: + case 'Gerrit': return (_container: Container, domain: string, path: string) => new GerritRemote(domain, path, cfg.protocol, cfg.name, true); - case CustomRemoteType.GoogleSource: + case 'GoogleSource': return (_container: Container, domain: string, path: string) => new GoogleSourceRemote(domain, path, cfg.protocol, cfg.name, true); - case CustomRemoteType.Gitea: + case 'Gitea': return (_container: Container, domain: string, path: string) => new GiteaRemote(domain, path, cfg.protocol, cfg.name, true); - case CustomRemoteType.GitHub: - return (container: Container, domain: string, path: string) => - new GitHubRemote(container, domain, path, cfg.protocol, cfg.name, true); - case CustomRemoteType.GitLab: - return (container: Container, domain: string, path: string) => - new GitLabRemote(container, domain, path, cfg.protocol, cfg.name, true); + case 'GitHub': + return (_container: Container, domain: string, path: string) => + new GitHubRemote(domain, path, cfg.protocol, cfg.name, true); + case 'GitLab': + return (_container: Container, domain: string, path: string) => + new GitLabRemote(domain, path, cfg.protocol, cfg.name, true); default: return undefined; } @@ -138,8 +138,12 @@ function getCustomProviderCreator(cfg: RemotesConfig) { export function getRemoteProviderMatcher( container: Container, - providers: RemoteProviders, + providers?: RemoteProviders, ): (url: string, domain: string, path: string) => RemoteProvider | undefined { + if (providers == null) { + providers = loadRemoteProviders(configuration.get('remotes', null)); + } + return (url: string, domain: string, path: string) => createBestRemoteProvider(container, providers, url, domain, path); } @@ -171,6 +175,7 @@ function createBestRemoteProvider( return undefined; } catch (ex) { + debugger; Logger.error(ex, 'createRemoteProvider'); return undefined; } diff --git a/src/git/remotes/richRemoteProvider.ts b/src/git/remotes/richRemoteProvider.ts deleted file mode 100644 index 2ae8d3a7ee9dc..0000000000000 --- a/src/git/remotes/richRemoteProvider.ts +++ /dev/null @@ -1,589 +0,0 @@ -import type { AuthenticationSession, AuthenticationSessionsChangeEvent, Event, MessageItem } from 'vscode'; -import { authentication, EventEmitter, window } from 'vscode'; -import { wrapForForcedInsecureSSL } from '@env/fetch'; -import { isWeb } from '@env/platform'; -import { configuration } from '../../configuration'; -import type { Container } from '../../container'; -import { AuthenticationError, ProviderRequestClientError } from '../../errors'; -import { Logger } from '../../logger'; -import { getLogScope } from '../../logScope'; -import { showIntegrationDisconnectedTooManyFailedRequestsWarningMessage } from '../../messages'; -import type { IntegrationAuthenticationSessionDescriptor } from '../../plus/integrationAuthentication'; -import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../../subscription'; -import { gate } from '../../system/decorators/gate'; -import { debug, log } from '../../system/decorators/log'; -import { isPromise } from '../../system/promise'; -import type { Account } from '../models/author'; -import type { DefaultBranch } from '../models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; -import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; -import { RemoteProvider } from './remoteProvider'; -import { RichRemoteProviders } from './remoteProviderConnections'; - -// TODO@eamodio revisit how once authenticated, all remotes are always connected, even after a restart - -export abstract class RichRemoteProvider extends RemoteProvider { - override readonly type: 'simple' | 'rich' = 'rich'; - - private readonly _onDidChange = new EventEmitter(); - get onDidChange(): Event { - return this._onDidChange.event; - } - - constructor( - protected readonly container: Container, - domain: string, - path: string, - protocol?: string, - name?: string, - custom?: boolean, - ) { - super(domain, path, protocol, name, custom); - - container.context.subscriptions.push( - configuration.onDidChange(e => { - if (configuration.changed(e, 'remotes')) { - this._ignoreSSLErrors.clear(); - } - }), - // TODO@eamodio revisit how connections are linked or not - RichRemoteProviders.onDidChangeConnectionState(e => { - if (e.key !== this.key) return; - - if (e.reason === 'disconnected') { - void this.disconnect({ silent: true }); - } else if (e.reason === 'connected') { - void this.ensureSession(false); - } - }), - authentication.onDidChangeSessions(this.onAuthenticationSessionsChanged, this), - ); - } - - abstract get apiBaseUrl(): string; - protected abstract get authProvider(): { id: string; scopes: string[] }; - protected get authProviderDescriptor(): IntegrationAuthenticationSessionDescriptor { - return { domain: this.domain, scopes: this.authProvider.scopes }; - } - - private get key() { - return this.custom ? `${this.name}:${this.domain}` : this.name; - } - - private get connectedKey(): `connected:${string}` { - return `connected:${this.key}`; - } - - get maybeConnected(): boolean | undefined { - return this._session === undefined ? undefined : this._session !== null; - } - - protected _session: AuthenticationSession | null | undefined; - protected session() { - if (this._session === undefined) { - return this.ensureSession(false); - } - return this._session ?? undefined; - } - - private onAuthenticationSessionsChanged(e: AuthenticationSessionsChangeEvent) { - if (e.provider.id === this.authProvider.id) { - void this.ensureSession(false); - } - } - - @log() - async connect(): Promise { - try { - const session = await this.ensureSession(true); - return Boolean(session); - } catch (ex) { - return false; - } - } - - @gate() - @log() - async disconnect(options?: { silent?: boolean; currentSessionOnly?: boolean }): Promise { - if (options?.currentSessionOnly && this._session === null) return; - - const connected = this._session != null; - - if (connected && !options?.silent) { - if (options?.currentSessionOnly) { - void showIntegrationDisconnectedTooManyFailedRequestsWarningMessage(this.name); - } else { - const disable = { title: 'Disable' }; - const signout = { title: 'Disable & Sign Out' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - - let result: MessageItem | undefined; - if (this.container.integrationAuthentication.hasProvider(this.authProvider.id)) { - result = await window.showWarningMessage( - `Are you sure you want to disable the rich integration with ${this.name}?\n\nNote: signing out clears the saved authentication.`, - { modal: true }, - disable, - signout, - cancel, - ); - } else { - result = await window.showWarningMessage( - `Are you sure you want to disable the rich integration with ${this.name}?`, - { modal: true }, - disable, - cancel, - ); - } - - if (result == null || result === cancel) return; - if (result === signout) { - void this.container.integrationAuthentication.deleteSession(this.id, this.authProviderDescriptor); - } - } - } - - this.resetRequestExceptionCount(); - this._prsByCommit.clear(); - this._session = null; - - if (connected) { - // Don't store the disconnected flag if this only for this current VS Code session (will be re-connected on next restart) - if (!options?.currentSessionOnly) { - void this.container.storage.storeWorkspace(this.connectedKey, false); - } - - this._onDidChange.fire(); - if (!options?.silent && !options?.currentSessionOnly) { - RichRemoteProviders.disconnected(this.key); - } - } - } - - @log() - async reauthenticate(): Promise { - if (this._session === undefined) return; - - this._session = undefined; - void (await this.ensureSession(true, true)); - } - - private requestExceptionCount = 0; - - resetRequestExceptionCount() { - this.requestExceptionCount = 0; - } - - @debug() - trackRequestException() { - this.requestExceptionCount++; - - if (this.requestExceptionCount >= 5 && this._session !== null) { - void this.disconnect({ currentSessionOnly: true }); - } - } - - @gate() - @debug({ - exit: connected => `returned ${connected}`, - }) - async isConnected(): Promise { - return (await this.session()) != null; - } - - @gate() - @debug() - async getAccountForCommit( - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - try { - const author = await this.getProviderAccountForCommit(this._session!, ref, options); - this.resetRequestExceptionCount(); - return author; - } catch (ex) { - Logger.error(ex, scope); - - if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { - this.trackRequestException(); - } - return undefined; - } - } - - protected abstract getProviderAccountForCommit( - session: AuthenticationSession, - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise; - - @gate() - @debug() - async getAccountForEmail( - email: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - try { - const author = await this.getProviderAccountForEmail(this._session!, email, options); - this.resetRequestExceptionCount(); - return author; - } catch (ex) { - Logger.error(ex, scope); - - if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { - this.trackRequestException(); - } - return undefined; - } - } - - protected abstract getProviderAccountForEmail( - session: AuthenticationSession, - email: string, - options?: { - avatarSize?: number; - }, - ): Promise; - - @gate() - @debug() - async getDefaultBranch(): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - try { - const defaultBranch = await this.getProviderDefaultBranch(this._session!); - this.resetRequestExceptionCount(); - return defaultBranch; - } catch (ex) { - Logger.error(ex, scope); - - if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { - this.trackRequestException(); - } - return undefined; - } - } - - protected abstract getProviderDefaultBranch({ - accessToken, - }: AuthenticationSession): Promise; - - @gate() - @debug() - async searchMyPullRequests(): Promise { - const scope = getLogScope(); - - try { - const pullRequests = await this.searchProviderMyPullRequests(this._session!); - this.resetRequestExceptionCount(); - return pullRequests; - } catch (ex) { - Logger.error(ex, scope); - - if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { - this.trackRequestException(); - } - return undefined; - } - } - protected abstract searchProviderMyPullRequests( - session: AuthenticationSession, - ): Promise; - - @gate() - @debug() - async searchMyIssues(): Promise { - const scope = getLogScope(); - - try { - const issues = await this.searchProviderMyIssues(this._session!); - this.resetRequestExceptionCount(); - return issues; - } catch (ex) { - Logger.error(ex, scope); - - if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { - this.trackRequestException(); - } - return undefined; - } - } - protected abstract searchProviderMyIssues(session: AuthenticationSession): Promise; - - @gate() - @debug() - async getIssueOrPullRequest(id: string): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - try { - const issueOrPullRequest = await this.getProviderIssueOrPullRequest(this._session!, id); - this.resetRequestExceptionCount(); - return issueOrPullRequest; - } catch (ex) { - Logger.error(ex, scope); - - if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { - this.trackRequestException(); - } - return undefined; - } - } - - private _ignoreSSLErrors = new Map(); - getIgnoreSSLErrors(): boolean | 'force' { - if (isWeb) return false; - - let ignoreSSLErrors = this._ignoreSSLErrors.get(this.id); - if (ignoreSSLErrors === undefined) { - const cfg = configuration - .get('remotes') - ?.find(remote => remote.type.toLowerCase() === this.id && remote.domain === this.domain); - ignoreSSLErrors = cfg?.ignoreSSLErrors ?? false; - this._ignoreSSLErrors.set(this.id, ignoreSSLErrors); - } - - return ignoreSSLErrors; - } - - protected abstract getProviderIssueOrPullRequest( - session: AuthenticationSession, - id: string, - ): Promise; - - @gate() - @debug() - async getPullRequestForBranch( - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - try { - const pr = await this.getProviderPullRequestForBranch(this._session!, branch, options); - this.resetRequestExceptionCount(); - return pr; - } catch (ex) { - Logger.error(ex, scope); - - if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { - this.trackRequestException(); - } - return undefined; - } - } - protected abstract getProviderPullRequestForBranch( - session: AuthenticationSession, - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise; - - private _prsByCommit = new Map | PullRequest | null>(); - - @debug() - getPullRequestForCommit(ref: string): Promise | PullRequest | undefined { - let pr = this._prsByCommit.get(ref); - if (pr === undefined) { - pr = this.getPullRequestForCommitCore(ref); - this._prsByCommit.set(ref, pr); - } - if (pr == null || !isPromise(pr)) return pr ?? undefined; - - return pr.then(pr => pr ?? undefined); - } - - @debug() - private async getPullRequestForCommitCore(ref: string) { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return null; - - try { - const pr = (await this.getProviderPullRequestForCommit(this._session!, ref)) ?? null; - this._prsByCommit.set(ref, pr); - this.resetRequestExceptionCount(); - return pr; - } catch (ex) { - Logger.error(ex, scope); - - this._prsByCommit.delete(ref); - - if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { - this.trackRequestException(); - } - return null; - } - } - - protected abstract getProviderPullRequestForCommit( - session: AuthenticationSession, - ref: string, - ): Promise; - - @gate() - private async ensureSession( - createIfNeeded: boolean, - forceNewSession: boolean = false, - ): Promise { - if (this._session != null) return this._session; - if (!configuration.get('integrations.enabled')) return undefined; - - if (createIfNeeded) { - await this.container.storage.deleteWorkspace(this.connectedKey); - } else if (this.container.storage.getWorkspace(this.connectedKey) === false) { - return undefined; - } - - let session: AuthenticationSession | undefined | null; - try { - if (this.container.integrationAuthentication.hasProvider(this.authProvider.id)) { - session = await this.container.integrationAuthentication.getSession( - this.authProvider.id, - this.authProviderDescriptor, - { createIfNeeded: createIfNeeded, forceNewSession: forceNewSession }, - ); - } else { - session = await wrapForForcedInsecureSSL(this.getIgnoreSSLErrors(), () => - authentication.getSession(this.authProvider.id, this.authProvider.scopes, { - createIfNone: forceNewSession ? undefined : createIfNeeded, - silent: !createIfNeeded && !forceNewSession ? true : undefined, - forceNewSession: forceNewSession ? true : undefined, - }), - ); - } - } catch (ex) { - await this.container.storage.deleteWorkspace(this.connectedKey); - - if (ex instanceof Error && ex.message.includes('User did not consent')) { - return undefined; - } - - session = null; - } - - if (session === undefined && !createIfNeeded) { - await this.container.storage.deleteWorkspace(this.connectedKey); - } - - this._session = session ?? null; - this.resetRequestExceptionCount(); - - if (session != null) { - await this.container.storage.storeWorkspace(this.connectedKey, true); - - queueMicrotask(() => { - this._onDidChange.fire(); - RichRemoteProviders.connected(this.key); - }); - } - - return session ?? undefined; - } -} - -export async function ensurePaidPlan(providerName: string, container: Container): Promise { - const title = `Connecting to a ${providerName} instance for rich integration features requires GitLens Pro.`; - - while (true) { - const subscription = await container.subscription.getSubscription(); - if (subscription.account?.verified === false) { - const resend = { title: 'Resend Verification' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nYou must verify your email address before you can continue.`, - { modal: true }, - resend, - cancel, - ); - - if (result === resend) { - if (await container.subscription.resendVerification()) { - continue; - } - } - - return false; - } - - const plan = subscription.plan.effective.id; - if (isSubscriptionPaidPlan(plan)) break; - - if (subscription.account == null && !isSubscriptionPreviewTrialExpired(subscription)) { - const startTrial = { title: 'Start a GitLens Pro Trial' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nDo you want to also try GitLens+ features on private repos, free for 3 days?`, - { modal: true }, - startTrial, - cancel, - ); - - if (result !== startTrial) return false; - - void container.subscription.startPreviewTrial(); - break; - } else if (subscription.account == null) { - const signIn = { title: 'Extend Your GitLens Pro Trial' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nDo you want to continue to use GitLens+ features on private repos, free for an additional 7-days?`, - { modal: true }, - signIn, - cancel, - ); - - if (result === signIn) { - if (await container.subscription.loginOrSignUp()) { - continue; - } - } - } else { - const upgrade = { title: 'Upgrade to Pro' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nDo you want to continue to use GitLens+ features on private repos?`, - { modal: true }, - upgrade, - cancel, - ); - - if (result === upgrade) { - void container.subscription.purchase(); - } - } - - return false; - } - - return true; -} diff --git a/src/git/search.ts b/src/git/search.ts index 55d6494a2577f..c0fcd25fb57bb 100644 --- a/src/git/search.ts +++ b/src/git/search.ts @@ -1,49 +1,10 @@ +import type { SearchOperators, SearchOperatorsLongForm, SearchQuery } from '../constants.search'; +import { searchOperationRegex, searchOperatorsToLongFormMap } from '../constants.search'; +import type { StoredSearchQuery } from '../constants.storage'; import type { GitRevisionReference } from './models/reference'; -import { GitRevision } from './models/reference'; +import { isSha, shortenRevision } from './models/reference'; import type { GitUser } from './models/user'; -export type SearchOperators = - | '' - | '=:' - | 'message:' - | '@:' - | 'author:' - | '#:' - | 'commit:' - | '?:' - | 'file:' - | '~:' - | 'change:'; - -export const searchOperators = new Set([ - '', - '=:', - 'message:', - '@:', - 'author:', - '#:', - 'commit:', - '?:', - 'file:', - '~:', - 'change:', -]); - -export interface SearchQuery { - query: string; - matchAll?: boolean; - matchCase?: boolean; - matchRegex?: boolean; -} - -// Don't change this shape as it is persisted in storage -export interface StoredSearchQuery { - pattern: string; - matchAll?: boolean; - matchCase?: boolean; - matchRegex?: boolean; -} - export interface GitSearchResultData { date: number; i: number; @@ -91,34 +52,17 @@ export function getSearchQueryComparisonKey(search: SearchQuery | StoredSearchQu export function createSearchQueryForCommit(ref: string): string; export function createSearchQueryForCommit(commit: GitRevisionReference): string; export function createSearchQueryForCommit(refOrCommit: string | GitRevisionReference) { - return `#:${typeof refOrCommit === 'string' ? GitRevision.shorten(refOrCommit) : refOrCommit.name}`; + return `#:${typeof refOrCommit === 'string' ? shortenRevision(refOrCommit) : refOrCommit.name}`; } export function createSearchQueryForCommits(refs: string[]): string; export function createSearchQueryForCommits(commits: GitRevisionReference[]): string; export function createSearchQueryForCommits(refsOrCommits: (string | GitRevisionReference)[]) { - return refsOrCommits.map(r => `#:${typeof r === 'string' ? GitRevision.shorten(r) : r.name}`).join(' '); + return refsOrCommits.map(r => `#:${typeof r === 'string' ? shortenRevision(r) : r.name}`).join(' '); } -const normalizeSearchOperatorsMap = new Map([ - ['', 'message:'], - ['=:', 'message:'], - ['message:', 'message:'], - ['@:', 'author:'], - ['author:', 'author:'], - ['#:', 'commit:'], - ['commit:', 'commit:'], - ['?:', 'file:'], - ['file:', 'file:'], - ['~:', 'change:'], - ['change:', 'change:'], -]); - -const searchOperationRegex = - /(?:(?=:|message:|@:|author:|#:|commit:|\?:|file:|~:|change:)\s?(?".+?"|\S+}?))|(?\S+)(?!(?:=|message|@|author|#|commit|\?|file|~|change):)/gi; - -export function parseSearchQuery(search: SearchQuery): Map { - const operations = new Map(); +export function parseSearchQuery(search: SearchQuery): Map> { + const operations = new Map>(); let op: SearchOperators | undefined; let value: string | undefined; @@ -129,21 +73,23 @@ export function parseSearchQuery(search: SearchQuery): Map { match = searchOperationRegex.exec(search.query); if (match?.groups == null) break; - op = normalizeSearchOperatorsMap.get(match.groups.op as SearchOperators); + op = searchOperatorsToLongFormMap.get(match.groups.op as SearchOperators); ({ value, text } = match.groups); if (text) { - op = text === '@me' ? 'author:' : GitRevision.isSha(text) ? 'commit:' : 'message:'; - value = text; + if (!searchOperatorsToLongFormMap.has(text.trim() as SearchOperators)) { + op = text === '@me' ? 'author:' : isSha(text) ? 'commit:' : 'message:'; + value = text; + } } if (op && value) { - const values = operations.get(op); + let values = operations.get(op); if (values == null) { - operations.set(op, [value]); - } else { - values.push(value); + values = new Set(); + operations.set(op, values); } + values.add(value); } } while (match != null); @@ -242,7 +188,28 @@ export function getGitArgsFromSearchQuery( files.push(value); } else { - files.push(`${search.matchCase ? '' : ':(icase)'}${value}`); + const prefix = search.matchCase ? '' : ':(icase)'; + if (/[/\\*?|![\]{}]/.test(value)) { + files.push(`${prefix}${value}`); + } else { + const index = value.indexOf('.'); + if (index > 0) { + // maybe a file extension + files.push(`${prefix}**/${value}`); + } else { + files.push(`${prefix}*${value}*`); + } + } + } + } + + break; + case 'type:': + for (const value of values) { + if (value === 'stash') { + if (!searchArgs.has('--no-walk')) { + searchArgs.add('--no-walk'); + } } } diff --git a/src/git/utils/branch-utils.ts b/src/git/utils/branch-utils.ts new file mode 100644 index 0000000000000..74c283bcdbb8a --- /dev/null +++ b/src/git/utils/branch-utils.ts @@ -0,0 +1,19 @@ +import { ThemeIcon } from 'vscode'; +import type { IconPath } from '../../@types/vscode.iconpath'; +import type { Container } from '../../container'; +import { getIconPathUris } from '../../system/vscode/vscode'; +import type { GitBranch } from '../models/branch'; + +export function getBranchIconPath(container: Container, branch: GitBranch | undefined): IconPath { + const status = branch?.status; + switch (status) { + case 'ahead': + case 'behind': + case 'diverged': + return getIconPathUris(container, `icon-branch-${status}.svg`); + case 'upToDate': + return getIconPathUris(container, `icon-branch-synced.svg`); + default: + return new ThemeIcon('git-branch'); + } +} diff --git a/src/git/utils/commit-utils.ts b/src/git/utils/commit-utils.ts new file mode 100644 index 0000000000000..b1f717f5e81d3 --- /dev/null +++ b/src/git/utils/commit-utils.ts @@ -0,0 +1,23 @@ +/** not sure about file location. I thought about git/formatters, or git/utils */ + +/** + * use `\n` symbol is presented to split commit message to description and title + */ +export function splitGitCommitMessage(commitMessage?: string) { + if (!commitMessage) { + return { + title: '', + }; + } + const message = commitMessage.trim(); + const index = message.indexOf('\n'); + if (index < 0) { + return { + title: message, + }; + } + return { + title: message.substring(0, index), + description: message.substring(index + 1).trim(), + }; +} diff --git a/src/git/utils/repository-utils.ts b/src/git/utils/repository-utils.ts new file mode 100644 index 0000000000000..2adde65c00912 --- /dev/null +++ b/src/git/utils/repository-utils.ts @@ -0,0 +1,29 @@ +import type { IconPath } from '../../@types/vscode.iconpath'; +import type { Container } from '../../container'; +import { getIconPathUris } from '../../system/vscode/vscode'; +import type { Repository } from '../models/repository'; +import type { GitStatus } from '../models/status'; + +export function getRepositoryStatusIconPath( + container: Container, + repository: Repository, + status: GitStatus | undefined, +): IconPath { + const type = repository.virtual ? '-cloud' : ''; + + if (status?.hasWorkingTreeChanges) { + return getIconPathUris(container, `icon-repo-changes${type}.svg`); + } + + const branchStatus = status?.branchStatus; + switch (branchStatus) { + case 'ahead': + case 'behind': + case 'diverged': + return getIconPathUris(container, `icon-repo-${branchStatus}${type}.svg`); + case 'upToDate': + return getIconPathUris(container, `icon-repo-synced${type}.svg`); + default: + return getIconPathUris(container, `icon-repo${type}.svg`); + } +} diff --git a/src/git/utils/worktree-utils.ts b/src/git/utils/worktree-utils.ts new file mode 100644 index 0000000000000..72cf5caebdf16 --- /dev/null +++ b/src/git/utils/worktree-utils.ts @@ -0,0 +1,18 @@ +import type { IconPath } from '../../@types/vscode.iconpath'; +import type { Container } from '../../container'; +import { getIconPathUris } from '../../system/vscode/vscode'; +import type { GitBranch } from '../models/branch'; + +export function getWorktreeBranchIconPath(container: Container, branch: GitBranch | undefined): IconPath { + const status = branch?.status; + switch (status) { + case 'ahead': + case 'behind': + case 'diverged': + return getIconPathUris(container, `icon-repo-${status}.svg`); + case 'upToDate': + return getIconPathUris(container, `icon-repo-synced.svg`); + default: + return getIconPathUris(container, `icon-repo.svg`); + } +} diff --git a/src/gk/models/drafts.ts b/src/gk/models/drafts.ts new file mode 100644 index 0000000000000..0cdf719187ffd --- /dev/null +++ b/src/gk/models/drafts.ts @@ -0,0 +1,264 @@ +import type { Uri } from 'vscode'; +import type { GitCommit } from '../../git/models/commit'; +import type { GitFileChangeShape } from '../../git/models/file'; +import type { GitPatch, PatchRevisionRange } from '../../git/models/patch'; +import type { Repository } from '../../git/models/repository'; +import type { GitUser } from '../../git/models/user'; +import type { GkRepositoryId, RepositoryIdentity, RepositoryIdentityRequest } from './repositoryIdentities'; + +export interface LocalDraft { + readonly draftType: 'local'; + + patch: GitPatch; +} + +export type DraftRole = 'owner' | 'admin' | 'editor' | 'viewer'; + +export type DraftArchiveReason = 'committed' | 'rejected' | 'accepted'; + +export interface Draft { + readonly draftType: 'cloud'; + readonly type: DraftType; + readonly id: string; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly author: { + id: string; + name: string; + email: string | undefined; + avatarUri?: Uri; + }; + readonly isMine: boolean; + readonly organizationId?: string; + readonly role: DraftRole; + readonly isPublished: boolean; + + readonly title: string; + readonly description?: string; + + readonly deepLinkUrl: string; + readonly visibility: DraftVisibility; + + readonly isArchived: boolean; + readonly archivedBy?: string; + readonly archivedReason?: DraftArchiveReason; + readonly archivedAt?: Date; + + readonly prEntityId?: string; + + readonly latestChangesetId: string; + changesets?: DraftChangeset[]; + + // readonly user?: { + // readonly id: string; + // readonly name: string; + // readonly email: string; + // }; +} + +export interface DraftChangeset { + readonly id: string; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly draftId: string; + readonly parentChangesetId: string | undefined; + + readonly userId: string; + readonly gitUserName: string; + readonly gitUserEmail: string; + + readonly deepLinkUrl?: string; + + readonly patches: DraftPatch[]; +} + +export interface DraftPatch { + readonly type: 'cloud'; + readonly id: string; + readonly createdAt: Date; + readonly updatedAt: Date; + readonly draftId: string; + readonly changesetId: string; + readonly userId: string; + readonly prEntityId?: string; + + readonly baseBranchName: string; + /*readonly*/ baseRef: string; + + readonly gkRepositoryId: GkRepositoryId; + // repoData?: GitRepositoryData; + readonly secureLink: DraftPatchResponse['secureDownloadData']; + + commit?: GitCommit; + contents?: string; + files?: DraftPatchFileChange[]; + repository?: Repository | RepositoryIdentity; +} + +export interface DraftPatchDetails { + id: string; + contents: string; + files: DraftPatchFileChange[]; + repository: Repository | RepositoryIdentity; +} + +export interface DraftPatchFileChange extends GitFileChangeShape { + readonly gkRepositoryId: GkRepositoryId; +} + +export interface CreateDraftChange { + revision: PatchRevisionRange; + contents?: string; + repository: Repository; + prEntityId?: string; +} + +export interface CreateDraftPatchRequestFromChange { + contents: string; + patch: DraftPatchCreateRequest; + repository: Repository; + user: GitUser | undefined; +} + +export type DraftVisibility = 'public' | 'private' | 'invite_only' | 'provider_access'; + +export type DraftType = 'patch' | 'stash' | 'suggested_pr_change'; + +export interface CreateDraftRequest { + type: DraftType; + title: string; + description?: string; + visibility: DraftVisibility; +} + +export interface CreateDraftResponse { + id: string; + deepLink: string; +} + +export interface DraftResponse { + readonly type: DraftType; + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly createdBy: string; + readonly organizationId?: string; + readonly role: DraftRole; + + readonly deepLink: string; + readonly isPublished: boolean; + readonly latestChangesetId: string; + readonly visibility: DraftVisibility; + + readonly title: string; + readonly description?: string; + + readonly isArchived: boolean; + readonly archivedBy?: string; + readonly archivedReason?: DraftArchiveReason; + readonly archivedAt?: string; +} + +export interface DraftUser { + readonly id: string; + readonly userId: string; + readonly draftId: string; + readonly role: DraftRole; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface DraftPendingUser { + userId: string; + role: Exclude; +} + +export interface DraftChangesetCreateRequest { + parentChangesetId?: string | null; + gitUserName?: string; + gitUserEmail?: string; + patches: DraftPatchCreateRequest[]; +} + +export interface DraftChangesetCreateResponse { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly draftId: string; + readonly parentChangesetId: string | undefined; + readonly userId: string; + readonly gitUserName: string; + readonly gitUserEmail: string; + + readonly deepLink?: string; + readonly patches: DraftPatchCreateResponse[]; +} + +export interface DraftChangesetResponse { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly draftId: string; + readonly parentChangesetId: string | undefined; + readonly userId: string; + readonly gitUserName: string; + readonly gitUserEmail: string; + + readonly deepLink?: string; + readonly patches: DraftPatchResponse[]; +} + +export interface DraftPatchCreateRequest { + baseCommitSha: string; + baseBranchName: string; + gitRepoData: RepositoryIdentityRequest; + prEntityId?: string; +} + +export interface DraftPatchCreateResponse { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly draftId: string; + readonly changesetId: string; + readonly userId: string; + + readonly baseCommitSha: string; + readonly baseBranchName: string; + readonly gitRepositoryId: GkRepositoryId; + + readonly secureUploadData: { + readonly headers: Record; + readonly method: string; + readonly url: string; + }; +} + +export interface DraftPatchResponse { + readonly id: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly draftId: string; + readonly changesetId: string; + readonly userId: string; + + readonly baseCommitSha: string; + readonly baseBranchName: string; + readonly gitRepositoryId: GkRepositoryId; + + readonly secureDownloadData: { + readonly headers: Record; + readonly method: string; + readonly url: string; + }; +} + +export type CodeSuggestionCountsResponse = { + counts: CodeSuggestionCounts; +}; + +export type CodeSuggestionCounts = { + [entityId: string]: { + count: number; + }; +}; diff --git a/src/gk/models/repositoryIdentities.ts b/src/gk/models/repositoryIdentities.ts new file mode 100644 index 0000000000000..7f60da011ede9 --- /dev/null +++ b/src/gk/models/repositoryIdentities.ts @@ -0,0 +1,99 @@ +import type { Branded } from '../../system/brand'; + +export const missingRepositoryId = '-'; + +export type GkProviderId = Branded< + 'github' | 'githubEnterprise' | 'gitlab' | 'gitlabSelfHosted' | 'bitbucket' | 'bitbucketServer' | 'azureDevops', + 'GkProviderId' +>; +export type GkRepositoryId = Branded; + +export interface RepositoryIdentityRemoteDescriptor { + readonly url?: string; + readonly domain?: string; + readonly path?: string; +} + +export interface RepositoryIdentityProviderDescriptor { + readonly id?: ID; + readonly domain?: string; + readonly repoDomain?: string; + readonly repoName?: string; + readonly repoOwnerDomain?: string; +} + +// TODO: replace this string with GkProviderId eventually once we wrangle our backend provider ids +export interface RepositoryIdentityDescriptor { + readonly name: string; + + readonly initialCommitSha?: string; + readonly remote?: RepositoryIdentityRemoteDescriptor; + readonly provider?: RepositoryIdentityProviderDescriptor; +} + +export interface RepositoryIdentity extends RepositoryIdentityDescriptor { + readonly id: GkRepositoryId; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +type BaseRepositoryIdentityRequest = { + // name: string; + initialCommitSha?: string; +}; + +type BaseRepositoryIdentityRequestWithCommitSha = BaseRepositoryIdentityRequest & { + initialCommitSha: string; +}; + +type BaseRepositoryIdentityRequestWithRemote = BaseRepositoryIdentityRequest & { + remote: { url: string; domain: string; path: string }; +}; + +type BaseRepositoryIdentityRequestWithRemoteProvider = BaseRepositoryIdentityRequestWithRemote & { + provider: { + id: GkProviderId; + repoDomain: string; + repoName: string; + repoOwnerDomain?: string; + }; +}; + +type BaseRepositoryIdentityRequestWithoutRemoteProvider = BaseRepositoryIdentityRequestWithRemote & { + provider?: never; +}; + +export type RepositoryIdentityRequest = + | BaseRepositoryIdentityRequestWithCommitSha + | BaseRepositoryIdentityRequestWithRemote + | BaseRepositoryIdentityRequestWithRemoteProvider + | BaseRepositoryIdentityRequestWithoutRemoteProvider; + +export interface RepositoryIdentityResponse { + readonly id: GkRepositoryId; + readonly createdAt: string; + readonly updatedAt: string; + + // readonly name: string; + + readonly initialCommitSha?: string; + readonly remote?: { + readonly url?: string; + readonly domain?: string; + readonly path?: string; + }; + readonly provider?: { + readonly id?: GkProviderId; + readonly repoDomain?: string; + readonly repoName?: string; + readonly repoOwnerDomain?: string; + }; +} + +export function getPathFromProviderIdentity( + provider: RepositoryIdentityProviderDescriptor, +): string { + return provider.repoOwnerDomain + ? `${provider.repoOwnerDomain}/${provider.repoDomain}/${provider.repoName}` + : `${provider.repoDomain}/${provider.repoName}`; +} diff --git a/src/hovers/hovers.ts b/src/hovers/hovers.ts index 347ae4cad7ac6..cb5de161757b6 100644 --- a/src/hovers/hovers.ts +++ b/src/hovers/hovers.ts @@ -1,24 +1,24 @@ import type { CancellationToken, TextDocument } from 'vscode'; import { MarkdownString } from 'vscode'; -import { hrtime } from '@env/hrtime'; -import { DiffWithCommand, ShowQuickCommitCommand } from '../commands'; -import { configuration } from '../configuration'; -import { GlyphChars, LogLevel } from '../constants'; -import { Container } from '../container'; +import type { EnrichedAutolink } from '../annotations/autolinks'; +import { DiffWithCommand } from '../commands/diffWith'; +import { ShowQuickCommitCommand } from '../commands/showQuickCommit'; +import { GlyphChars } from '../constants'; +import type { Container } from '../container'; import { CommitFormatter } from '../git/formatters/commitFormatter'; import { GitUri } from '../git/gitUri'; import type { GitCommit } from '../git/models/commit'; -import type { GitDiffHunk, GitDiffHunkLine } from '../git/models/diff'; +import { uncommittedStaged } from '../git/models/constants'; +import type { GitDiffHunk, GitDiffLine } from '../git/models/diff'; import type { PullRequest } from '../git/models/pullRequest'; -import { GitRevision } from '../git/models/reference'; +import { isUncommittedStaged, shortenRevision } from '../git/models/reference'; import type { GitRemote } from '../git/models/remote'; -import { Logger } from '../logger'; -import { getNewLogScope } from '../logScope'; -import { count } from '../system/iterable'; -import { getSettledValue, PromiseCancelledError } from '../system/promise'; -import { getDurationMilliseconds } from '../system/string'; +import type { RemoteProvider } from '../git/remotes/remoteProvider'; +import { getSettledValue, pauseOnCancelOrTimeout, pauseOnCancelOrTimeoutMapTuplePromise } from '../system/promise'; +import { configuration } from '../system/vscode/configuration'; export async function changesMessage( + container: Container, commit: GitCommit, uri: GitUri, editorLine: number, // 0-based, Git is 1-based @@ -31,23 +31,23 @@ export async function changesMessage( async function getDiff() { if (commit.file == null) return undefined; + const line = editorLine + 1; + const commitLine = commit.lines.find(l => l.line === line) ?? commit.lines[0]; + // TODO: Figure out how to optimize this let ref; if (commit.isUncommitted) { - if (GitRevision.isUncommittedStaged(documentRef)) { + if (isUncommittedStaged(documentRef)) { ref = documentRef; } } else { - previousSha = await commit.getPreviousSha(); + previousSha = commitLine.previousSha; ref = previousSha; if (ref == null) { return `\`\`\`diff\n+ ${document.lineAt(editorLine).text}\n\`\`\``; } } - const line = editorLine + 1; - const commitLine = commit.lines.find(l => l.line === line) ?? commit.lines[0]; - let originalPath = commit.file.originalPath; if (originalPath == null) { if (uri.fsPath !== commit.file.uri.fsPath) { @@ -57,19 +57,14 @@ export async function changesMessage( editorLine = commitLine.line - 1; // TODO: Doesn't work with dirty files -- pass in editor? or contents? - let hunkLine = await Container.instance.git.getDiffForLine(uri, editorLine, ref, documentRef); + let lineDiff = await container.git.getDiffForLine(uri, editorLine, ref, documentRef); // If we didn't find a diff & ref is undefined (meaning uncommitted), check for a staged diff - if (hunkLine == null && ref == null && documentRef !== GitRevision.uncommittedStaged) { - hunkLine = await Container.instance.git.getDiffForLine( - uri, - editorLine, - undefined, - GitRevision.uncommittedStaged, - ); + if (lineDiff == null && ref == null && documentRef !== uncommittedStaged) { + lineDiff = await container.git.getDiffForLine(uri, editorLine, undefined, uncommittedStaged); } - return hunkLine != null ? getDiffFromHunkLine(hunkLine) : undefined; + return lineDiff != null ? getDiffFromLine(lineDiff) : undefined; } const diff = await getDiff(); @@ -97,10 +92,10 @@ export async function changesMessage( previous = compareUris.previous.sha == null || compareUris.previous.isUncommitted - ? `  _${GitRevision.shorten(compareUris.previous.sha, { + ? `  _${shortenRevision(compareUris.previous.sha, { strings: { working: 'Working Tree' }, })}_  ${GlyphChars.ArrowLeftRightLong}  ` - : `  [$(git-commit) ${GitRevision.shorten( + : `  [$(git-commit) ${shortenRevision( compareUris.previous.sha || '', )}](${ShowQuickCommitCommand.getMarkdownCommandArgs( compareUris.previous.sha || '', @@ -108,12 +103,12 @@ export async function changesMessage( current = compareUris.current.sha == null || compareUris.current.isUncommitted - ? `_${GitRevision.shorten(compareUris.current.sha, { + ? `_${shortenRevision(compareUris.current.sha, { strings: { working: 'Working Tree', }, })}_` - : `[$(git-commit) ${GitRevision.shorten( + : `[$(git-commit) ${shortenRevision( compareUris.current.sha || '', )}](${ShowQuickCommitCommand.getMarkdownCommandArgs(compareUris.current.sha || '')} "Show Commit")`; } else { @@ -123,7 +118,7 @@ export async function changesMessage( previousSha = await commit.getPreviousSha(); } if (previousSha) { - previous = `  [$(git-commit) ${GitRevision.shorten( + previous = `  [$(git-commit) ${shortenRevision( previousSha, )}](${ShowQuickCommitCommand.getMarkdownCommandArgs(previousSha)} "Show Commit")  ${ GlyphChars.ArrowLeftRightLong @@ -191,83 +186,105 @@ export async function localChangesMessage( } export async function detailsMessage( + container: Container, commit: GitCommit, uri: GitUri, editorLine: number, // 0-based, Git is 1-based - format: string, - dateFormat: string | null, - options?: { + options: Readonly<{ autolinks?: boolean; - cancellationToken?: CancellationToken; - pullRequests?: { - enabled: boolean; - pr?: PullRequest | PromiseCancelledError>; - }; + cancellation?: CancellationToken; + dateFormat: string | null; + enrichedAutolinks?: Promise | undefined> | undefined; + format: string; getBranchAndTagTips?: ( sha: string, options?: { compact?: boolean | undefined; icons?: boolean | undefined }, ) => string | undefined; - }, -): Promise { - if (dateFormat === null) { - dateFormat = 'MMMM Do, YYYY h:mma'; - } - - let message = commit.message ?? commit.summary; - if (commit.message == null && !commit.isUncommitted) { - await commit.ensureFullDetails(); - message = commit.message ?? commit.summary; - - if (options?.cancellationToken?.isCancellationRequested) return new MarkdownString(); + pullRequest?: Promise | PullRequest | undefined; + pullRequests?: boolean; + remotes?: GitRemote[]; + timeout?: number; + }>, +): Promise { + const remotesResult = await pauseOnCancelOrTimeout( + options?.remotes ?? container.git.getBestRemotesWithProviders(commit.repoPath), + options?.cancellation, + options?.timeout, + ); + + let remotes: GitRemote[] | undefined; + let remote: GitRemote | undefined; + if (remotesResult.paused) { + if (remotesResult.reason === 'cancelled') return undefined; + // If we timed out, just continue without the remotes + } else { + remotes = remotesResult.value; + [remote] = remotes; } - const remotes = await Container.instance.git.getRemotesWithProviders(commit.repoPath, { sort: true }); - - if (options?.cancellationToken?.isCancellationRequested) return new MarkdownString(); - - const [previousLineComparisonUrisResult, autolinkedIssuesOrPullRequestsResult, prResult, presenceResult] = + const cfg = configuration.get('hovers'); + const autolinks = + remote?.provider != null && + (options?.autolinks || (options?.autolinks !== false && cfg.autolinks.enabled && cfg.autolinks.enhanced)) && + CommitFormatter.has(cfg.detailsMarkdownFormat, 'message'); + const prs = + remote?.hasIntegration() && + remote.maybeIntegrationConnected !== false && + (options?.pullRequests || (options?.pullRequests !== false && cfg.pullRequests.enabled)) && + CommitFormatter.has( + options.format, + 'pullRequest', + 'pullRequestAgo', + 'pullRequestAgoOrDate', + 'pullRequestDate', + 'pullRequestState', + ); + + const [enrichedAutolinksResult, prResult, presenceResult, previousLineComparisonUrisResult] = await Promise.allSettled([ + autolinks + ? pauseOnCancelOrTimeoutMapTuplePromise( + options?.enrichedAutolinks ?? commit.getEnrichedAutolinks(remote), + options?.cancellation, + options?.timeout, + ) + : undefined, + prs + ? pauseOnCancelOrTimeout( + options?.pullRequest ?? commit.getAssociatedPullRequest(remote), + options?.cancellation, + options?.timeout, + ) + : undefined, + container.vsls.active + ? pauseOnCancelOrTimeout( + container.vsls.getContactPresence(commit.author.email), + options?.cancellation, + Math.min(options?.timeout ?? 250, 250), + ) + : undefined, commit.isUncommitted ? commit.getPreviousComparisonUrisForLine(editorLine, uri.sha) : undefined, - getAutoLinkedIssuesOrPullRequests(message, remotes), - options?.pullRequests?.pr ?? - getPullRequestForCommit(commit.ref, remotes, { - pullRequests: - options?.pullRequests?.enabled !== false && - CommitFormatter.has( - format, - 'pullRequest', - 'pullRequestAgo', - 'pullRequestAgoOrDate', - 'pullRequestDate', - 'pullRequestState', - ), - }), - Container.instance.vsls.maybeGetPresence(commit.author.email), + commit.message == null ? commit.ensureFullDetails() : undefined, ]); - if (options?.cancellationToken?.isCancellationRequested) return new MarkdownString(); + if (options?.cancellation?.isCancellationRequested) return undefined; - const previousLineComparisonUris = getSettledValue(previousLineComparisonUrisResult); - const autolinkedIssuesOrPullRequests = getSettledValue(autolinkedIssuesOrPullRequestsResult); + const enrichedResult = getSettledValue(enrichedAutolinksResult); const pr = getSettledValue(prResult); const presence = getSettledValue(presenceResult); + const previousLineComparisonUris = getSettledValue(previousLineComparisonUrisResult); - // Remove possible duplicate pull request - if (pr != null && !(pr instanceof PromiseCancelledError)) { - autolinkedIssuesOrPullRequests?.delete(pr.id); - } - - const details = await CommitFormatter.fromTemplateAsync(format, commit, { - autolinkedIssuesOrPullRequests: autolinkedIssuesOrPullRequests, - dateFormat: dateFormat, + const details = await CommitFormatter.fromTemplateAsync(options.format, commit, { + enrichedAutolinks: enrichedResult?.value != null && !enrichedResult.paused ? enrichedResult.value : undefined, + dateFormat: options.dateFormat === null ? 'MMMM Do, YYYY h:mma' : options.dateFormat, editor: { line: editorLine, uri: uri, }, getBranchAndTagTips: options?.getBranchAndTagTips, - messageAutolinks: options?.autolinks, - pullRequestOrRemote: pr, - presence: presence, + messageAutolinks: options?.autolinks || (options?.autolinks !== false && cfg.autolinks.enabled), + pullRequest: pr?.value, + presence: presence?.value, previousLineComparisonUris: previousLineComparisonUris, outputFormat: 'markdown', remotes: remotes, @@ -280,143 +297,15 @@ export async function detailsMessage( } function getDiffFromHunk(hunk: GitDiffHunk): string { - return `\`\`\`diff\n${hunk.diff.trim()}\n\`\`\``; + return `\`\`\`diff\n${hunk.contents.trim()}\n\`\`\``; } -function getDiffFromHunkLine(hunkLine: GitDiffHunkLine, diffStyle?: 'line' | 'hunk'): string { +function getDiffFromLine(lineDiff: GitDiffLine, diffStyle?: 'line' | 'hunk'): string { if (diffStyle === 'hunk' || (diffStyle == null && configuration.get('hovers.changesDiff') === 'hunk')) { - return getDiffFromHunk(hunkLine.hunk); + return getDiffFromHunk(lineDiff.hunk); } - return `\`\`\`diff${hunkLine.previous == null ? '' : `\n- ${hunkLine.previous.line.trim()}`}${ - hunkLine.current == null ? '' : `\n+ ${hunkLine.current.line.trim()}` + return `\`\`\`diff${lineDiff.line.previous == null ? '' : `\n- ${lineDiff.line.previous.trim()}`}${ + lineDiff.line.current == null ? '' : `\n+ ${lineDiff.line.current.trim()}` }\n\`\`\``; } - -async function getAutoLinkedIssuesOrPullRequests(message: string, remotes: GitRemote[]) { - const scope = getNewLogScope('Hovers.getAutoLinkedIssuesOrPullRequests'); - Logger.debug(scope, `${GlyphChars.Dash} message=`); - - const start = hrtime(); - - const cfg = configuration.get('hovers'); - if ( - !cfg.autolinks.enabled || - !cfg.autolinks.enhanced || - !CommitFormatter.has(cfg.detailsMarkdownFormat, 'message') - ) { - Logger.debug(scope, `completed ${GlyphChars.Dot} ${getDurationMilliseconds(start)} ms`); - - return undefined; - } - - const remote = await Container.instance.git.getBestRemoteWithRichProvider(remotes); - if (remote?.provider == null) { - Logger.debug(scope, `completed ${GlyphChars.Dot} ${getDurationMilliseconds(start)} ms`); - - return undefined; - } - - // TODO: Make this configurable? - const timeout = 250; - - try { - const autolinks = await Container.instance.autolinks.getLinkedIssuesAndPullRequests(message, remote, { - timeout: timeout, - }); - - if (autolinks != null && Logger.enabled(LogLevel.Debug)) { - // If there are any issues/PRs that timed out, log it - const prCount = count(autolinks.values(), pr => pr instanceof PromiseCancelledError); - if (prCount !== 0) { - Logger.debug( - scope, - `timed out ${ - GlyphChars.Dash - } ${prCount} issue/pull request queries took too long (over ${timeout} ms) ${ - GlyphChars.Dot - } ${getDurationMilliseconds(start)} ms`, - ); - - // const pending = [ - // ...Iterables.map(autolinks.values(), issueOrPullRequest => - // issueOrPullRequest instanceof CancelledPromiseError - // ? issueOrPullRequest.promise - // : undefined, - // ), - // ]; - // void Promise.all(pending).then(() => { - // Logger.debug( - // scope, - // `${GlyphChars.Dot} ${count} issue/pull request queries completed; refreshing...`, - // ); - // void executeCoreCommand(CoreCommands.EditorShowHover); - // }); - - return autolinks; - } - } - - Logger.debug(scope, `completed ${GlyphChars.Dot} ${getDurationMilliseconds(start)} ms`); - - return autolinks; - } catch (ex) { - Logger.error(ex, scope, `failed ${GlyphChars.Dot} ${getDurationMilliseconds(start)} ms`); - - return undefined; - } -} - -async function getPullRequestForCommit( - ref: string, - remotes: GitRemote[], - options?: { - pullRequests?: boolean; - }, -) { - const scope = getNewLogScope('Hovers.getPullRequestForCommit'); - Logger.debug(scope, `${GlyphChars.Dash} ref=${ref}`); - - const start = hrtime(); - - if (!options?.pullRequests) { - Logger.debug(scope, `completed ${GlyphChars.Dot} ${getDurationMilliseconds(start)} ms`); - - return undefined; - } - - const remote = await Container.instance.git.getBestRemoteWithRichProvider(remotes, { - includeDisconnected: true, - }); - if (remote?.provider == null) { - Logger.debug(scope, `completed ${GlyphChars.Dot} ${getDurationMilliseconds(start)} ms`); - - return undefined; - } - - const { provider } = remote; - const connected = provider.maybeConnected ?? (await provider.isConnected()); - if (!connected) { - Logger.debug(scope, `completed ${GlyphChars.Dot} ${getDurationMilliseconds(start)} ms`); - - return remote; - } - - try { - const pr = await Container.instance.git.getPullRequestForCommit(ref, provider, { timeout: 250 }); - - Logger.debug(scope, `completed ${GlyphChars.Dot} ${getDurationMilliseconds(start)} ms`); - - return pr; - } catch (ex) { - if (ex instanceof PromiseCancelledError) { - Logger.debug(scope, `timed out ${GlyphChars.Dot} ${getDurationMilliseconds(start)} ms`); - - return ex; - } - - Logger.error(ex, scope, `failed ${GlyphChars.Dot} ${getDurationMilliseconds(start)} ms`); - - return undefined; - } -} diff --git a/src/hovers/lineHoverController.ts b/src/hovers/lineHoverController.ts index 0b5d1a8edcca3..753263bf68522 100644 --- a/src/hovers/lineHoverController.ts +++ b/src/hovers/lineHoverController.ts @@ -1,15 +1,15 @@ import type { CancellationToken, ConfigurationChangeEvent, Position, TextDocument, TextEditor, Uri } from 'vscode'; import { Disposable, Hover, languages, Range, window } from 'vscode'; -import { UriComparer } from '../comparers'; -import { configuration, FileAnnotationType } from '../configuration'; import type { Container } from '../container'; -import { Logger } from '../logger'; +import { UriComparer } from '../system/comparers'; import { debug } from '../system/decorators/log'; import { once } from '../system/event'; -import type { LinesChangeEvent } from '../trackers/gitLineTracker'; +import { Logger } from '../system/logger'; +import { configuration } from '../system/vscode/configuration'; +import type { LinesChangeEvent } from '../trackers/lineTracker'; import { changesMessage, detailsMessage } from './hovers'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) export class LineHoverController implements Disposable { private readonly _disposable: Disposable; @@ -85,7 +85,7 @@ export class LineHoverController implements Disposable { async provideDetailsHover( document: TextDocument, position: Position, - _token: CancellationToken, + token: CancellationToken, ): Promise { if (!this.container.lineTracker.includes(position.line)) return undefined; @@ -98,7 +98,7 @@ export class LineHoverController implements Disposable { // Avoid double annotations if we are showing the whole-file hover blame annotations if (cfg.annotations.details) { const fileAnnotations = await this.container.fileAnnotations.getAnnotationType(window.activeTextEditor); - if (fileAnnotations === FileAnnotationType.Blame) return undefined; + if (fileAnnotations === 'blame') return undefined; } const wholeLine = cfg.currentLine.over === 'line'; @@ -120,22 +120,18 @@ export class LineHoverController implements Disposable { const commitLine = commit.lines.find(l => l.line === line) ?? commit.lines[0]; editorLine = commitLine.originalLine - 1; - const trackedDocument = await this.container.tracker.get(document); - if (trackedDocument == null) return undefined; + const trackedDocument = await this.container.documentTracker.get(document); + if (trackedDocument == null || token.isCancellationRequested) return undefined; - const message = await detailsMessage( - commit, - trackedDocument.uri, - editorLine, - cfg.detailsMarkdownFormat, - configuration.get('defaultDateFormat'), - { + const message = + (await detailsMessage(this.container, commit, trackedDocument.uri, editorLine, { autolinks: cfg.autolinks.enabled, - pullRequests: { - enabled: cfg.pullRequests.enabled, - }, - }, - ); + cancellation: token, + dateFormat: configuration.get('defaultDateFormat'), + format: cfg.detailsMarkdownFormat, + pullRequests: cfg.pullRequests.enabled, + timeout: 250, + })) ?? 'Cancelled'; return new Hover(message, range); } @@ -162,7 +158,7 @@ export class LineHoverController implements Disposable { // Avoid double annotations if we are showing the whole-file hover blame annotations if (cfg.annotations.changes) { const fileAnnotations = await this.container.fileAnnotations.getAnnotationType(window.activeTextEditor); - if (fileAnnotations === FileAnnotationType.Blame) return undefined; + if (fileAnnotations === 'blame') return undefined; } const wholeLine = cfg.currentLine.over === 'line'; @@ -179,10 +175,16 @@ export class LineHoverController implements Disposable { ); if (!wholeLine && range.start.character !== position.character) return undefined; - const trackedDocument = await this.container.tracker.get(document); + const trackedDocument = await this.container.documentTracker.get(document); if (trackedDocument == null) return undefined; - const message = await changesMessage(commit, trackedDocument.uri, position.line, trackedDocument.document); + const message = await changesMessage( + this.container, + commit, + trackedDocument.uri, + position.line, + trackedDocument.document, + ); if (message == null) return undefined; return new Hover(message, range); diff --git a/src/logScope.ts b/src/logScope.ts deleted file mode 100644 index 345ee94a2c454..0000000000000 --- a/src/logScope.ts +++ /dev/null @@ -1,41 +0,0 @@ -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) - -const scopes = new Map(); -let scopeCounter = 0; - -export interface LogScope { - readonly scopeId?: number; - readonly prefix: string; - exitDetails?: string; -} - -export function clearLogScope(scopeId: number) { - scopes.delete(scopeId); -} - -export function getLogScope(): LogScope | undefined { - return scopes.get(scopeCounter); -} - -export function getNewLogScope(prefix: string): LogScope { - const scopeId = getNextLogScopeId(); - return { - scopeId: scopeId, - prefix: `[${String(scopeId).padStart(5)}] ${prefix}`, - }; -} - -export function getLogScopeId(): number { - return scopeCounter; -} - -export function getNextLogScopeId(): number { - if (scopeCounter === maxSmallIntegerV8) { - scopeCounter = 0; - } - return ++scopeCounter; -} - -export function setLogScope(scopeId: number, scope: LogScope) { - scopes.set(scopeId, scope); -} diff --git a/src/messages.ts b/src/messages.ts index d632eab4bf4aa..ccbaa60041652 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -1,24 +1,47 @@ import type { MessageItem } from 'vscode'; -import { ConfigurationTarget, env, Uri, window } from 'vscode'; -import { configuration, SuppressedMessages } from './configuration'; -import { Commands, LogLevel } from './constants'; +import { ConfigurationTarget, window } from 'vscode'; +import type { SuppressedMessages } from './config'; +import { urls } from './constants'; +import { Commands } from './constants.commands'; +import type { BlameIgnoreRevsFileError } from './git/errors'; +import { BlameIgnoreRevsFileBadRevisionError } from './git/errors'; import type { GitCommit } from './git/models/commit'; -import { Logger } from './logger'; -import { executeCommand } from './system/command'; +import { Logger } from './system/logger'; +import { executeCommand } from './system/vscode/command'; +import { configuration } from './system/vscode/configuration'; +import { openUrl } from './system/vscode/utils'; + +export function showBlameInvalidIgnoreRevsFileWarningMessage( + ex: BlameIgnoreRevsFileError | BlameIgnoreRevsFileBadRevisionError, +): Promise { + if (ex instanceof BlameIgnoreRevsFileBadRevisionError) { + return showMessage( + 'error', + `Unable to show blame. Invalid revision (${ex.revision}) specified in the blame.ignoreRevsFile in your Git config.`, + 'suppressBlameInvalidIgnoreRevsFileBadRevisionWarning', + ); + } + + return showMessage( + 'error', + `Unable to show blame. Invalid or missing blame.ignoreRevsFile (${ex.fileName}) specified in your Git config.`, + 'suppressBlameInvalidIgnoreRevsFileWarning', + ); +} export function showCommitHasNoPreviousCommitWarningMessage(commit?: GitCommit): Promise { if (commit == null) { - return showMessage('info', 'There is no previous commit.', SuppressedMessages.CommitHasNoPreviousCommitWarning); + return showMessage('info', 'There is no previous commit.', 'suppressCommitHasNoPreviousCommitWarning'); } return showMessage( 'info', `Commit ${commit.shortSha} (${commit.author.name}, ${commit.formattedDate}) has no previous commit.`, - SuppressedMessages.CommitHasNoPreviousCommitWarning, + 'suppressCommitHasNoPreviousCommitWarning', ); } export function showCommitNotFoundWarningMessage(message: string): Promise { - return showMessage('warn', `${message}. The commit could not be found.`, SuppressedMessages.CommitNotFoundWarning); + return showMessage('warn', `${message}. The commit could not be found.`, 'suppressCommitNotFoundWarning'); } export async function showCreatePullRequestPrompt(branch: string): Promise { @@ -26,7 +49,7 @@ export async function showCreatePullRequestPrompt(branch: string): Promise { const result = await showMessage( 'warn', 'GitLens debug logging is currently enabled. Unless you are reporting an issue, it is recommended to be disabled. Would you like to disable it?', - SuppressedMessages.SuppressDebugLoggingWarning, + 'suppressDebugLoggingWarning', { title: "Don't Show Again" }, disable, ); @@ -47,7 +70,7 @@ export async function showDebugLoggingWarningMessage(): Promise { } export async function showGenericErrorMessage(message: string): Promise { - if (Logger.enabled(LogLevel.Error)) { + if (Logger.enabled('error')) { const result = await showMessage('error', `${message}. See output channel for more details.`, undefined, null, { title: 'Open Output Channel', }); @@ -76,7 +99,7 @@ export function showFileNotUnderSourceControlWarningMessage(message: string): Pr return showMessage( 'warn', `${message}. The file is probably not under source control.`, - SuppressedMessages.FileNotUnderSourceControlWarning, + 'suppressFileNotUnderSourceControlWarning', ); } @@ -84,7 +107,7 @@ export function showGitDisabledErrorMessage() { return showMessage( 'error', 'GitLens requires Git to be enabled. Please re-enable Git \u2014 set `git.enabled` to true and reload.', - SuppressedMessages.GitDisabledWarning, + 'suppressGitDisabledWarning', ); } @@ -99,7 +122,7 @@ export function showGitMissingErrorMessage() { return showMessage( 'error', "GitLens was unable to find Git. Please make sure Git is installed. Also ensure that Git is either in the PATH, or that 'git.path' is pointed to its installed location.", - SuppressedMessages.GitMissingWarning, + 'suppressGitMissingWarning', ); } @@ -110,53 +133,64 @@ export function showGitVersionUnsupportedErrorMessage( return showMessage( 'error', `GitLens requires a newer version of Git (>= ${required}) than is currently installed (${version}). Please install a more recent version of Git.`, - SuppressedMessages.GitVersionWarning, + 'suppressGitVersionWarning', ); } -export function showInsidersErrorMessage() { +export function showPreReleaseExpiredErrorMessage(version: string) { return showMessage( 'error', - 'GitLens (Insiders) cannot be used while GitLens is also enabled. Please ensure that only one version is enabled.', - ); -} - -export function showPreReleaseExpiredErrorMessage(version: string, insiders: boolean) { - return showMessage( - 'error', - `This GitLens ${ - insiders ? '(Insiders)' : 'pre-release' - } version (${version}) has expired. Please upgrade to a more recent version.`, + `This GitLens pre-release version (${version}) has expired. Please upgrade to a more recent version.`, ); } export function showLineUncommittedWarningMessage(message: string): Promise { - return showMessage( - 'warn', - `${message}. The line has uncommitted changes.`, - SuppressedMessages.LineUncommittedWarning, - ); + return showMessage('warn', `${message}. The line has uncommitted changes.`, 'suppressLineUncommittedWarning'); } export function showNoRepositoryWarningMessage(message: string): Promise { - return showMessage('warn', `${message}. No repository could be found.`, SuppressedMessages.NoRepositoryWarning); + return showMessage('warn', `${message}. No repository could be found.`, 'suppressNoRepositoryWarning'); } export function showRebaseSwitchToTextWarningMessage(): Promise { return showMessage( 'warn', 'Closing either the git-rebase-todo file or the Rebase Editor will start the rebase.', - SuppressedMessages.RebaseSwitchToTextWarning, + 'suppressRebaseSwitchToTextWarning', ); } +export function showGkDisconnectedTooManyFailedRequestsWarningMessage(): Promise { + return showMessage( + 'error', + `Requests to GitKraken have stopped being sent for this session, because of too many failed requests.`, + 'suppressGkDisconnectedTooManyFailedRequestsWarningMessage', + undefined, + { + title: 'OK', + }, + ); +} + +export function showGkRequestFailed500WarningMessage(message: string): Promise { + return showMessage('error', message, 'suppressGkRequestFailed500Warning', undefined, { + title: 'OK', + }); +} + +export function showGkRequestTimedOutWarningMessage(): Promise { + return showMessage('error', `GitKraken request timed out.`, 'suppressGkRequestTimedOutWarning', undefined, { + title: 'OK', + }); +} + export function showIntegrationDisconnectedTooManyFailedRequestsWarningMessage( providerName: string, ): Promise { return showMessage( 'error', `Rich integration with ${providerName} has been disconnected for this session, because of too many failed requests.`, - SuppressedMessages.IntegrationDisconnectedTooManyFailedRequestsWarning, + 'suppressIntegrationDisconnectedTooManyFailedRequestsWarning', undefined, { title: 'OK', @@ -165,7 +199,7 @@ export function showIntegrationDisconnectedTooManyFailedRequestsWarningMessage( } export function showIntegrationRequestFailed500WarningMessage(message: string): Promise { - return showMessage('error', message, SuppressedMessages.IntegrationRequestFailed500Warning, undefined, { + return showMessage('error', message, 'suppressIntegrationRequestFailed500Warning', undefined, { title: 'OK', }); } @@ -174,7 +208,7 @@ export function showIntegrationRequestTimedOutWarningMessage(providerName: strin return showMessage( 'error', `${providerName} request timed out.`, - SuppressedMessages.IntegrationRequestTimedOutWarning, + 'suppressIntegrationRequestTimedOutWarning', undefined, { title: 'OK', @@ -183,17 +217,23 @@ export function showIntegrationRequestTimedOutWarningMessage(providerName: strin } export async function showWhatsNewMessage(version: string) { - const whatsnew = { title: "See What's New" }; + const confirm = { title: 'OK', isCloseAffordance: true }; + const announcement = { title: 'Read Announcement', isCloseAffordance: true }; const result = await showMessage( 'info', - `GitLens ${version} is here — check out what's new!`, + `Upgraded to GitLens ${version}${ + version === '15' + ? `, with a host of new [Pro features](${urls.proFeatures}) including [Launchpad](${urls.codeSuggest}), [Code Suggest](${urls.codeSuggest}), and more` + : '' + } — [see what's new](${urls.releaseNotes} "See what's new in GitLens ${version}").`, undefined, null, - whatsnew, + confirm, + announcement, ); - if (result === whatsnew) { - void (await env.openExternal(Uri.parse('https://help.gitkraken.com/gitlens/gitlens-release-notes-current/'))); + if (result === announcement) { + void openUrl(urls.releaseAnnouncement); } } @@ -256,6 +296,7 @@ function suppressedMessage(suppressionKey: SuppressedMessages) { for (const [key, value] of Object.entries(messages)) { if (value !== true) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete messages[key as keyof typeof messages]; } } diff --git a/src/partners.ts b/src/partners.ts index cdfc57233e824..69d2699d9a059 100644 --- a/src/partners.ts +++ b/src/partners.ts @@ -1,10 +1,10 @@ import type { CancellationTokenSource, Extension, ExtensionContext, Uri } from 'vscode'; import { extensions } from 'vscode'; import type { ActionContext, HoverCommandsActionContext } from './api/gitlens'; -import type { InviteToLiveShareCommandArgs } from './commands'; -import { Commands, CoreCommands } from './constants'; +import type { InviteToLiveShareCommandArgs } from './commands/inviteToLiveShare'; +import { Commands } from './constants.commands'; import { Container } from './container'; -import { executeCommand, executeCoreCommand } from './system/command'; +import { executeCommand, executeCoreCommand } from './system/vscode/command'; import type { ContactPresence } from './vsls/vsls'; export async function installExtension( @@ -36,14 +36,14 @@ export async function installExtension( }); }); - await executeCoreCommand(CoreCommands.InstallExtension, vsix ?? extensionId); + await executeCoreCommand('workbench.extensions.installExtension', vsix ?? extensionId); // Wait for extension activation until timeout expires timer = setTimeout(() => { timer = undefined; tokenSource.cancel(); }, timeout); - return extension; + return await extension; } catch { tokenSource.cancel(); return undefined; diff --git a/src/pathMapping/models.ts b/src/pathMapping/models.ts new file mode 100644 index 0000000000000..a0c541fdf0586 --- /dev/null +++ b/src/pathMapping/models.ts @@ -0,0 +1,12 @@ +export type LocalRepoDataMap = Record< + string /* key can be remote url, provider/owner/name, or first commit SHA*/, + RepoLocalData +>; + +export interface RepoLocalData { + paths: string[]; + name?: string; + hostName?: string; + owner?: string; + hostingServiceType?: string; +} diff --git a/src/pathMapping/repositoryPathMappingProvider.ts b/src/pathMapping/repositoryPathMappingProvider.ts new file mode 100644 index 0000000000000..05f5e37dac61a --- /dev/null +++ b/src/pathMapping/repositoryPathMappingProvider.ts @@ -0,0 +1,13 @@ +import type { Disposable } from 'vscode'; + +export interface RepositoryPathMappingProvider extends Disposable { + getLocalRepoPaths(options: { + remoteUrl?: string; + repoInfo?: { provider?: string; owner?: string; repoName?: string }; + }): Promise; + + writeLocalRepoPath( + options: { remoteUrl?: string; repoInfo?: { provider?: string; owner?: string; repoName?: string } }, + localPath: string, + ): Promise; +} diff --git a/src/plus/LICENSE.plus b/src/plus/LICENSE.plus index 814b362324cca..7d3d6faa994dd 100644 --- a/src/plus/LICENSE.plus +++ b/src/plus/LICENSE.plus @@ -1,6 +1,6 @@ GitLens+ License -Copyright (c) 2021-2023 Axosoft, LLC dba GitKraken ("GitKraken") +Copyright (c) 2021-2024 Axosoft, LLC dba GitKraken ("GitKraken") With regard to the software set forth in or under any directory named "plus". diff --git a/src/plus/drafts/actions.ts b/src/plus/drafts/actions.ts new file mode 100644 index 0000000000000..7d2df4c02f80e --- /dev/null +++ b/src/plus/drafts/actions.ts @@ -0,0 +1,29 @@ +import type { MessageItem } from 'vscode'; +import { window } from 'vscode'; +import { Container } from '../../container'; +import { configuration } from '../../system/vscode/configuration'; +import type { WebviewViewShowOptions } from '../../webviews/webviewsController'; +import type { ShowCreateDraft, ShowViewDraft } from '../webviews/patchDetails/registration'; + +type ShowCreateOrOpen = ShowCreateDraft | ShowViewDraft; + +export async function showPatchesView(createOrOpen: ShowCreateOrOpen, options?: WebviewViewShowOptions): Promise { + if (!configuration.get('cloudPatches.enabled')) { + const confirm: MessageItem = { title: 'Enable' }; + const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showInformationMessage( + 'Cloud Patches are currently disabled. Would you like to enable them?', + { modal: true }, + confirm, + cancel, + ); + + if (result !== confirm) return; + await configuration.updateEffective('cloudPatches.enabled', true); + } + + if (createOrOpen.mode === 'create') { + options = { ...options, preserveFocus: false, preserveVisibility: false }; + } + return Container.instance.patchDetailsView.show(options, createOrOpen); +} diff --git a/src/plus/drafts/draftsService.ts b/src/plus/drafts/draftsService.ts new file mode 100644 index 0000000000000..b1b8340c887c0 --- /dev/null +++ b/src/plus/drafts/draftsService.ts @@ -0,0 +1,1034 @@ +import type { HeadersInit } from '@env/fetch'; +import type { EntityIdentifier } from '@gitkraken/provider-apis'; +import { EntityIdentifierUtils } from '@gitkraken/provider-apis'; +import type { Disposable } from 'vscode'; +import { getAvatarUri } from '../../avatars'; +import type { Container } from '../../container'; +import type { GitCommit } from '../../git/models/commit'; +import type { PullRequest } from '../../git/models/pullRequest'; +import { isSha, isUncommitted, shortenRevision } from '../../git/models/reference'; +import { isRepository, Repository } from '../../git/models/repository'; +import type { GitUser } from '../../git/models/user'; +import { getRemoteProviderMatcher } from '../../git/remotes/remoteProviders'; +import type { + CodeSuggestionCounts, + CodeSuggestionCountsResponse, + CreateDraftChange, + CreateDraftPatchRequestFromChange, + CreateDraftRequest, + CreateDraftResponse, + Draft, + DraftChangeset, + DraftChangesetCreateRequest, + DraftChangesetCreateResponse, + DraftChangesetResponse, + DraftPatch, + DraftPatchDetails, + DraftPatchFileChange, + DraftPatchResponse, + DraftPendingUser, + DraftResponse, + DraftType, + DraftUser, + DraftVisibility, +} from '../../gk/models/drafts'; +import type { + GkRepositoryId, + RepositoryIdentity, + RepositoryIdentityRequest, + RepositoryIdentityResponse, +} from '../../gk/models/repositoryIdentities'; +import { log } from '../../system/decorators/log'; +import { Logger } from '../../system/logger'; +import type { LogScope } from '../../system/logger.scope'; +import { getLogScope } from '../../system/logger.scope'; +import { getSettledValue } from '../../system/promise'; +import type { OrganizationMember } from '../gk/account/organization'; +import type { SubscriptionAccount } from '../gk/account/subscription'; +import type { ServerConnection } from '../gk/serverConnection'; +import type { IntegrationId } from '../integrations/providers/models'; +import { providersMetadata } from '../integrations/providers/models'; +import { getEntityIdentifierInput } from '../integrations/providers/utils'; +import type { LaunchpadItem } from '../launchpad/launchpadProvider'; + +export interface ProviderAuth { + provider: IntegrationId; + token: string; +} + +export class DraftService implements Disposable { + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + dispose(): void {} + + @log({ args: { 2: false } }) + async createDraft( + type: DraftType, + title: string, + changes: CreateDraftChange[], + options?: { description?: string; visibility?: DraftVisibility; prEntityId?: string }, + ): Promise { + const scope = getLogScope(); + + try { + const results = await Promise.allSettled(changes.map(c => this.getCreateDraftPatchRequestFromChange(c))); + if (!results.length) throw new Error('No changes found'); + + const patchRequests: CreateDraftPatchRequestFromChange[] = []; + const failed: Error[] = []; + let user: GitUser | undefined; + + for (const r of results) { + if (r.status === 'fulfilled') { + // Don't include empty patches -- happens when there are changes in a range that undo each other + if (r.value.contents) { + patchRequests.push(r.value); + if (user == null) { + user = r.value.user; + } + } + } else { + failed.push(r.reason); + } + } + + if (failed.length) { + debugger; + throw new AggregateError(failed, 'Unable to create draft'); + } + + type DraftResult = { data: CreateDraftResponse }; + + let providerAuthHeader: HeadersInit | undefined; + let prEntityIdBody: { prEntityId: string } | undefined; + if (type === 'suggested_pr_change') { + if (options?.prEntityId == null) { + throw new Error('No pull request info provided'); + } + prEntityIdBody = { + prEntityId: options.prEntityId, + }; + + const repo = patchRequests[0].repository; + const providerAuth = await this.getProviderAuthFromRepoOrIntegrationId(repo); + if (providerAuth == null) { + throw new Error('No provider integration found'); + } + providerAuthHeader = { + 'Provider-Auth': Buffer.from(JSON.stringify(providerAuth)).toString('base64'), + }; + } + + // POST v1/drafts + const createDraftRsp = await this.connection.fetchGkDevApi('v1/drafts', { + method: 'POST', + body: JSON.stringify({ + type: type, + title: title, + description: options?.description, + visibility: options?.visibility ?? 'public', + } satisfies CreateDraftRequest), + }); + + if (!createDraftRsp.ok) { + await handleBadDraftResponse('Unable to create draft', createDraftRsp, scope); + } + + const createDraft = ((await createDraftRsp.json()) as DraftResult).data; + const draftId = createDraft.id; + + type ChangesetResult = { data: DraftChangesetCreateResponse }; + + // POST /v1/drafts/:draftId/changesets + const createChangesetRsp = await this.connection.fetchGkDevApi(`v1/drafts/${draftId}/changesets`, { + method: 'POST', + body: JSON.stringify({ + // parentChangesetId: null, + gitUserName: user?.name, + gitUserEmail: user?.email, + patches: patchRequests.map(p => p.patch), + } satisfies DraftChangesetCreateRequest), + headers: providerAuthHeader, + }); + + if (!createChangesetRsp.ok) { + await handleBadDraftResponse( + `Unable to create changeset for draft '${draftId}'`, + createChangesetRsp, + scope, + ); + } + + const createChangeset = ((await createChangesetRsp.json()) as ChangesetResult).data; + + const patches: DraftPatch[] = []; + + let i = 0; + for (const patch of createChangeset.patches) { + const { url, method, headers } = patch.secureUploadData; + + const { contents, repository } = patchRequests[i++]; + if (contents == null) { + debugger; + throw new Error(`No contents found for ${patch.baseCommitSha}`); + } + + const diffFiles = await this.container.git.getDiffFiles(repository.path, contents); + const files = diffFiles?.files.map(f => ({ ...f, gkRepositoryId: patch.gitRepositoryId })) ?? []; + + // Upload patch to returned S3 url + await this.connection.fetch(url, { + method: method, + headers: { + 'Content-Type': 'text/plain', + ...headers, + }, + body: contents, + }); + + const newPatch = formatPatch( + { + ...patch, + secureDownloadData: undefined!, + }, + { + contents: contents, + files: files, + repository: repository, + }, + ); + + patches.push(newPatch); + } + + // POST /v1/drafts/:draftId/publish + const publishRsp = await this.connection.fetchGkDevApi(`v1/drafts/${draftId}/publish`, { + method: 'POST', + headers: providerAuthHeader, + body: prEntityIdBody != null ? JSON.stringify(prEntityIdBody) : undefined, + }); + if (!publishRsp.ok) { + await handleBadDraftResponse(`Failed to publish draft '${draftId}'`, publishRsp, scope); + } + + type Result = { data: DraftResponse }; + + const draftRsp = await this.connection.fetchGkDevApi(`v1/drafts/${draftId}`, { + method: 'GET', + headers: providerAuthHeader, + }); + + if (!draftRsp.ok) { + await handleBadDraftResponse(`Unable to open draft '${draftId}'`, draftRsp, scope); + } + + const draft = ((await draftRsp.json()) as Result).data; + + const { account } = await this.container.subscription.getSubscription(); + + const newDraft = formatDraft(draft, { account: account }); + newDraft.changesets = [ + { + ...formatChangeset({ ...createChangeset, patches: [] }), + patches: patches, + }, + ]; + + return newDraft; + } catch (ex) { + debugger; + Logger.error(ex, scope); + + throw ex; + } + } + + private async getCreateDraftPatchRequestFromChange( + change: CreateDraftChange, + ): Promise { + const isWIP = isUncommitted(change.revision.to); + + const [branchNamesResult, diffResult, firstShaResult, remoteResult, userResult] = await Promise.allSettled([ + isWIP + ? this.container.git.getBranch(change.repository.uri).then(b => (b != null ? [b.name] : undefined)) + : this.container.git.getCommitBranches(change.repository.uri, [ + change.revision.to, + change.revision.from, + ]), + change.contents == null + ? this.container.git.getDiff(change.repository.path, change.revision.to, change.revision.from) + : undefined, + this.container.git.getFirstCommitSha(change.repository.uri), + this.container.git.getBestRemoteWithProvider(change.repository.uri), + this.container.git.getCurrentUser(change.repository.uri), + ]); + + const firstSha = getSettledValue(firstShaResult); + // TODO: what happens if there are multiple remotes -- which one should we use? Do we need to ask? See more notes below + const remote = getSettledValue(remoteResult); + + let repoData: RepositoryIdentityRequest; + if (remote == null) { + if (firstSha == null) throw new Error('No remote or initial commit found'); + + repoData = { + initialCommitSha: firstSha, + }; + } else { + repoData = { + initialCommitSha: firstSha, + remote: { + url: remote.url, + domain: remote.domain, + path: remote.path, + }, + provider: remote.provider.providerDesc, + }; + } + + const diff = getSettledValue(diffResult); + const contents = change.contents ?? diff?.contents; + if (contents == null) throw new Error(`Unable to diff ${change.revision.from} and ${change.revision.to}`); + + const user = getSettledValue(userResult); + + // We need to get the branch name if possible, otherwise default to HEAD + const branchNames = getSettledValue(branchNamesResult); + const branchName = branchNames?.[0] ?? 'HEAD'; + + let baseSha = change.revision.from; + if (!isSha(baseSha)) { + const commit = await this.container.git.getCommit(change.repository.uri, baseSha); + if (commit != null) { + baseSha = commit.sha; + } else { + debugger; + } + } + + return { + patch: { + baseCommitSha: baseSha, + baseBranchName: branchName, + gitRepoData: repoData, + prEntityId: change.prEntityId, + }, + contents: contents, + repository: change.repository, + user: user, + }; + } + + @log() + async deleteDraft(id: string): Promise { + await this.connection.fetchGkDevApi(`v1/drafts/${id}`, { method: 'DELETE' }); + } + + @log({ args: { 1: opts => JSON.stringify({ ...opts, providerAuth: undefined }) } }) + async archiveDraft(draft: Draft, options?: { providerAuth?: ProviderAuth; archiveReason?: string }): Promise { + const scope = getLogScope(); + + try { + let providerAuth = options?.providerAuth; + if (draft.visibility === 'provider_access' && providerAuth == null) { + providerAuth = await this.getProviderAuthForDraft(draft); + if (providerAuth == null) { + throw new Error('No provider integration found'); + } + } + + let providerAuthHeader; + if (providerAuth != null) { + providerAuthHeader = { + 'Provider-Auth': Buffer.from(JSON.stringify(providerAuth)).toString('base64'), + }; + } + + const rsp = await this.connection.fetchGkDevApi(`v1/drafts/${draft.id}/archive`, { + method: 'POST', + body: + options?.archiveReason != null + ? JSON.stringify({ archiveReason: options.archiveReason }) + : undefined, + headers: providerAuthHeader, + }); + + if (!rsp.ok) { + await handleBadDraftResponse(`Unable to archive draft '${draft.id}'`, rsp, scope); + } + } catch (ex) { + debugger; + Logger.error(ex, scope); + + throw ex; + } + } + + @log({ args: { 1: opts => JSON.stringify({ ...opts, providerAuth: undefined }) } }) + async getDraft(id: string, options?: { providerAuth?: ProviderAuth }): Promise { + const scope = getLogScope(); + + type Result = { data: DraftResponse }; + + let headers; + if (options?.providerAuth) { + headers = { + 'Provider-Auth': Buffer.from(JSON.stringify(options.providerAuth)).toString('base64'), + }; + } + + const [rspResult, changesetsResult] = await Promise.allSettled([ + this.connection.fetchGkDevApi(`v1/drafts/${id}`, { method: 'GET', headers: headers }), + this.getChangesets(id), + ]); + + if (rspResult.status === 'rejected') { + Logger.error(rspResult.reason, scope, `Unable to open draft '${id}': ${rspResult.reason}`); + throw new Error(`Unable to open draft '${id}': ${rspResult.reason}`); + } + + if (changesetsResult.status === 'rejected') { + Logger.error( + changesetsResult.reason, + scope, + `Unable to open changeset for draft '${id}': ${changesetsResult.reason}`, + ); + throw new Error(`Unable to open changesets for draft '${id}': ${changesetsResult.reason}`); + } + + const rsp = getSettledValue(rspResult)!; + if (!rsp?.ok) { + await handleBadDraftResponse(`Unable to open draft '${id}'`, rsp, scope); + } + + const draft = ((await rsp.json()) as Result).data; + const changesets = getSettledValue(changesetsResult)!; + + const [subscriptionResult, membersResult] = await Promise.allSettled([ + this.container.subscription.getSubscription(), + this.container.organizations.getMembers(draft.organizationId), + ]); + + const account = getSettledValue(subscriptionResult)?.account; + const members = getSettledValue(membersResult); + + const newDraft = formatDraft(draft, { + account: account, + members: members, + }); + newDraft.changesets = changesets; + + return newDraft; + } + + @log() + async getDrafts(isArchived?: boolean): Promise { + return this.getDraftsCore(isArchived ? { isArchived: isArchived } : undefined); + } + + async getDraftsCore(options?: { + prEntityId?: string; + providerAuth?: ProviderAuth; + isArchived?: boolean; + }): Promise { + const scope = getLogScope(); + type Result = { data: DraftResponse[] }; + + const queryStrings = []; + let fromPrEntityId = false; + if (options?.prEntityId != null) { + if (options.providerAuth == null) { + throw new Error('No provider integration found'); + } + fromPrEntityId = true; + queryStrings.push(`prEntityId=${encodeURIComponent(options.prEntityId)}`); + } + + if (options?.isArchived) { + queryStrings.push('archived=true'); + } + + let headers; + if (options?.providerAuth) { + headers = { + 'Provider-Auth': Buffer.from(JSON.stringify(options.providerAuth)).toString('base64'), + }; + } + + const rsp = await this.connection.fetchGkDevApi( + '/v1/drafts', + { + method: 'GET', + headers: headers, + }, + { + query: queryStrings.length ? queryStrings.join('&') : undefined, + }, + ); + + if (!rsp.ok) { + await handleBadDraftResponse('Unable to open drafts', rsp, scope); + } + + const drafts = ((await rsp.json()) as Result).data; + + const [subscriptionResult, membersResult] = await Promise.allSettled([ + this.container.subscription.getSubscription(), + this.container.organizations.getMembers(), + ]); + + const account = getSettledValue(subscriptionResult)?.account; + const members = getSettledValue(membersResult); + + return drafts.map( + (d): Draft => + formatDraft(d, { + account: account, + members: members, + fromPrEntityId: fromPrEntityId, + }), + ); + } + + @log() + async getChangesets(id: string): Promise { + const scope = getLogScope(); + + type Result = { data: DraftChangesetResponse[] }; + + try { + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}/changesets`, { method: 'GET' }); + if (!rsp.ok) { + await handleBadDraftResponse(`Unable to open changesets for draft '${id}'`, rsp, scope); + } + + const changeset = ((await rsp.json()) as Result).data; + + const changesets: DraftChangeset[] = []; + for (const c of changeset) { + const newChangeset = formatChangeset(c); + changesets.push(newChangeset); + } + + return changesets; + } catch (ex) { + Logger.error(ex, scope); + + throw ex; + } + } + + @log() + async getPatch(id: string): Promise { + const patch = await this.getPatchCore(id); + + const details = await this.getPatchDetails(patch); + patch.contents = details.contents; + patch.files = details.files; + patch.repository = details.repository; + + return patch; + } + + private async getPatchCore(id: string): Promise { + const scope = getLogScope(); + type Result = { data: DraftPatchResponse }; + + // GET /v1/patches/:patchId + const rsp = await this.connection.fetchGkDevApi(`/v1/patches/${id}`, { method: 'GET' }); + + if (!rsp.ok) { + await handleBadDraftResponse(`Unable to open patch '${id}'`, rsp, scope); + } + + const data = ((await rsp.json()) as Result).data; + + const newPatch = formatPatch(data); + + return newPatch; + } + + async getPatchDetails(id: string): Promise; + async getPatchDetails(patch: DraftPatch): Promise; + @log({ + args: { 0: idOrPatch => (typeof idOrPatch === 'string' ? idOrPatch : idOrPatch.id) }, + }) + async getPatchDetails(idOrPatch: string | DraftPatch): Promise { + const patch = typeof idOrPatch === 'string' ? await this.getPatchCore(idOrPatch) : idOrPatch; + + const [contentsResult, repositoryResult] = await Promise.allSettled([ + this.getPatchContentsCore(patch.secureLink), + this.getRepositoryOrIdentity(patch.draftId, patch.gkRepositoryId, { + openIfNeeded: true, + skipRefValidation: true, + }), + ]); + + const contents = getSettledValue(contentsResult)!; + const repositoryOrIdentity = getSettledValue(repositoryResult)!; + + let repoPath = ''; + if (isRepository(repositoryOrIdentity)) { + repoPath = repositoryOrIdentity.path; + } + + const diffFiles = await this.container.git.getDiffFiles(repoPath, contents); + const files = diffFiles?.files.map(f => ({ ...f, gkRepositoryId: patch.gkRepositoryId })) ?? []; + + return { + id: patch.id, + contents: contents, + files: files, + repository: repositoryOrIdentity, + }; + } + + private async getPatchContentsCore( + secureLink: DraftPatchResponse['secureDownloadData'], + ): Promise { + const { url, method, headers } = secureLink; + + // Download patch from returned S3 url + const contentsRsp = await this.connection.fetch(url, { + method: method, + headers: { + Accept: 'text/plain', + ...headers, + }, + }); + + return contentsRsp.text(); + } + + @log() + async updateDraftVisibility(id: string, visibility: DraftVisibility): Promise { + const scope = getLogScope(); + + type Result = { data: Draft }; + + try { + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}`, { + method: 'PATCH', + body: JSON.stringify({ visibility: visibility }), + }); + + if (rsp?.ok === false) { + await handleBadDraftResponse(`Unable to update draft '${id}'`, rsp, scope); + } + + const draft = ((await rsp.json()) as Result).data; + + return draft; + } catch (ex) { + Logger.error(ex, scope); + + throw ex; + } + } + + @log() + async getDraftUsers(id: string): Promise { + const scope = getLogScope(); + + type Result = { data: DraftUser[] }; + + try { + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}/users`, { method: 'GET' }); + + if (rsp?.ok === false) { + await handleBadDraftResponse(`Unable to get users for draft '${id}'`, rsp, scope); + } + + const users: DraftUser[] = ((await rsp.json()) as Result).data; + + return users; + } catch (ex) { + Logger.error(ex, scope); + + throw ex; + } + } + + @log({ args: { 1: false } }) + async addDraftUsers(id: string, pendingUsers: DraftPendingUser[]): Promise { + const scope = getLogScope(); + + type Result = { data: DraftUser[] }; + type Request = { id: string; users: DraftPendingUser[] }; + + try { + if (pendingUsers.length === 0) { + throw new Error('No changes found'); + } + + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}/users`, { + method: 'POST', + body: JSON.stringify({ + id: id, + users: pendingUsers, + } as Request), + }); + + if (rsp?.ok === false) { + await handleBadDraftResponse(`Unable to add users for draft '${id}'`, rsp, scope); + } + + const users: DraftUser[] = ((await rsp.json()) as Result).data; + + return users; + } catch (ex) { + Logger.error(ex, scope); + + throw ex; + } + } + + @log() + async removeDraftUser(id: string, userId: DraftUser['userId']): Promise { + const scope = getLogScope(); + try { + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${id}/users/${userId}`, { method: 'DELETE' }); + + if (rsp?.ok === false) { + await handleBadDraftResponse(`Unable to update user ${userId} for draft '${id}'`, rsp, scope); + } + + return true; + } catch (ex) { + Logger.error(ex, scope); + + throw ex; + } + } + + @log() + async getRepositoryOrIdentity( + draftId: Draft['id'], + repoId: GkRepositoryId, + options?: { openIfNeeded?: boolean; keepOpen?: boolean; prompt?: boolean; skipRefValidation?: boolean }, + ): Promise { + const identity = await this.getRepositoryIdentity(draftId, repoId); + return (await this.container.repositoryIdentity.getRepository(identity, options)) ?? identity; + } + + @log() + async getRepositoryIdentity(draftId: Draft['id'], repoId: GkRepositoryId): Promise { + type Result = { data: RepositoryIdentityResponse }; + + const rsp = await this.connection.fetchGkDevApi(`/v1/drafts/${draftId}/git-repositories/${repoId}`, { + method: 'GET', + }); + const data = ((await rsp.json()) as Result).data; + + let name: string; + if ('name' in data && typeof data.name === 'string') { + name = data.name; + } else if (data.provider?.repoName != null) { + name = data.provider.repoName; + } else if (data.remote?.url != null && data.remote?.domain != null && data.remote?.path != null) { + const matcher = getRemoteProviderMatcher(this.container); + const provider = matcher(data.remote.url, data.remote.domain, data.remote.path); + name = provider?.repoName ?? data.remote.path; + } else { + name = + data.remote?.path ?? + `Unknown ${data.initialCommitSha ? ` (${shortenRevision(data.initialCommitSha)})` : ''}`; + } + + return { + id: data.id, + createdAt: new Date(data.createdAt), + updatedAt: new Date(data.updatedAt), + name: name, + initialCommitSha: data.initialCommitSha, + remote: data.remote, + provider: data.provider, + }; + } + + async getProviderAuthFromRepoOrIntegrationId( + repoOrIntegrationId: Repository | IntegrationId, + ): Promise { + let integration; + if (isRepository(repoOrIntegrationId)) { + const remoteProvider = await repoOrIntegrationId.getBestRemoteWithIntegration(); + if (remoteProvider == null) return undefined; + + integration = await remoteProvider.getIntegration(); + } else { + const metadata = providersMetadata[repoOrIntegrationId]; + if (metadata == null) return undefined; + + integration = await this.container.integrations.get(repoOrIntegrationId, metadata.domain); + } + if (integration == null) return undefined; + + const session = await integration.getSession('code-suggest'); + if (session == null) return undefined; + + return { + provider: integration.authProvider.id, + token: session.accessToken, + }; + } + + async getProviderAuthForDraft(draft: Draft): Promise { + if (draft.changesets == null || draft.changesets.length === 0) { + return undefined; + } + + let patch: DraftPatch | undefined; + for (const changeset of draft.changesets) { + const changesetPatch = changeset.patches?.find(patch => patch.repository ?? patch.gkRepositoryId); + if (changesetPatch != null) { + patch = changesetPatch; + } + } + + if (patch == null) { + return undefined; + } + + let repo: Repository | undefined; + // avoid calling getRepositoryOrIdentity if possible + if (patch.repository != null) { + if (patch.repository instanceof Repository) { + repo = patch.repository; + } else { + repo = await this.container.repositoryIdentity.getRepository(patch.repository); + } + } + + if (repo == null) { + const repositoryOrIdentity = await this.getRepositoryOrIdentity(draft.id, patch.gkRepositoryId); + if (!(repositoryOrIdentity instanceof Repository)) { + return undefined; + } + + repo = repositoryOrIdentity; + } + + return this.getProviderAuthFromRepoOrIntegrationId(repo); + } + + async getCodeSuggestions( + pullRequest: PullRequest, + repository: Repository, + options?: { includeArchived?: boolean }, + ): Promise; + async getCodeSuggestions( + launchpadItem: LaunchpadItem, + integrationId: IntegrationId, + options?: { includeArchived?: boolean }, + ): Promise; + @log({ args: { 0: i => i.id, 1: r => (isRepository(r) ? r.id : r) } }) + async getCodeSuggestions( + item: PullRequest | LaunchpadItem, + repositoryOrIntegrationId: Repository | IntegrationId, + options?: { includeArchived?: boolean }, + ): Promise { + const entityIdentifier = getEntityIdentifierInput(item); + const prEntityId = EntityIdentifierUtils.encode(entityIdentifier); + const providerAuth = await this.getProviderAuthFromRepoOrIntegrationId(repositoryOrIntegrationId); + + // swallowing this error as we don't need to fail here + try { + const drafts = await this.getDraftsCore({ + prEntityId: prEntityId, + providerAuth: providerAuth, + isArchived: options?.includeArchived != null ? options.includeArchived : true, + }); + return drafts; + } catch (_ex) { + return []; + } + } + + @log({ args: { 0: prs => prs.map(pr => pr.id).join(',') } }) + async getCodeSuggestionCounts(pullRequests: PullRequest[]): Promise { + const scope = getLogScope(); + + type Result = { data: CodeSuggestionCountsResponse }; + + const prEntityIds = pullRequests.map(pr => { + return EntityIdentifierUtils.encode(getEntityIdentifierInput(pr)); + }); + + const body = JSON.stringify({ + prEntityIds: prEntityIds, + }); + + try { + const rsp = await this.connection.fetchGkDevApi( + 'v1/drafts/counts', + { + method: 'POST', + body: body, + }, + { + query: 'type=suggested_pr_change', + }, + ); + + if (!rsp.ok) { + await handleBadDraftResponse('Unable to open code suggestion counts', rsp, scope); + } + + return ((await rsp.json()) as Result).data.counts; + } catch (ex) { + debugger; + Logger.error(ex, scope); + + throw ex; + } + } + + generateWebUrl(draftId: string): string; + generateWebUrl(draft: Draft): string; + generateWebUrl(draftOrDraftId: Draft | string): string { + const id = typeof draftOrDraftId === 'string' ? draftOrDraftId : draftOrDraftId.id; + return this.container.generateWebGkDevUrl(`/drafts/${id}`); + } +} + +async function handleBadDraftResponse(message: string, rsp?: any, scope?: LogScope) { + let json: { error?: { message?: string } } | { error?: string } | undefined; + try { + json = (await rsp?.json()) as { error?: { message?: string } } | { error?: string } | undefined; + } catch {} + const rspErrorMessage = typeof json?.error === 'string' ? json.error : json?.error?.message ?? rsp?.statusText; + const errorMessage = rsp != null ? `${message}: (${rsp?.status}) ${rspErrorMessage}` : message; + Logger.error(undefined, scope, errorMessage); + throw new Error(errorMessage); +} + +function formatDraft( + draftResponse: DraftResponse, + options?: { + account?: SubscriptionAccount; + members?: OrganizationMember[]; + fromPrEntityId?: boolean; + }, +): Draft { + let author: Draft['author']; + let isMine = false; + + if (draftResponse.createdBy === options?.account?.id) { + isMine = true; + author = { + id: draftResponse.createdBy, + name: `${options.account.name} (you)`, + email: options.account.email, + avatarUri: getAvatarUri(options.account.email), + }; + } else { + let member; + if (options?.members?.length) { + member = options.members.find(m => m.id === draftResponse.createdBy); + } + + author = { + id: draftResponse.createdBy, + name: member?.name ?? 'Unknown', + email: member?.email, + avatarUri: getAvatarUri(member?.email), + }; + } + + let role = draftResponse.role; + if (!role) { + if (options?.fromPrEntityId === true) { + role = 'editor'; + } else { + role = 'viewer'; + } + } + + return { + draftType: 'cloud', + type: draftResponse.type, + id: draftResponse.id, + createdAt: new Date(draftResponse.createdAt), + updatedAt: new Date(draftResponse.updatedAt ?? draftResponse.createdAt), + author: author, + isMine: isMine, + organizationId: draftResponse.organizationId || undefined, + role: role, + isPublished: draftResponse.isPublished, + + title: draftResponse.title, + description: draftResponse.description, + + deepLinkUrl: draftResponse.deepLink, + visibility: draftResponse.visibility, + + isArchived: draftResponse.isArchived, + archivedBy: draftResponse.archivedBy, + archivedReason: draftResponse.archivedReason, + archivedAt: draftResponse.archivedAt != null ? new Date(draftResponse.archivedAt) : draftResponse.archivedAt, + + latestChangesetId: draftResponse.latestChangesetId, + }; +} + +function formatChangeset(changesetResponse: DraftChangesetResponse): DraftChangeset { + return { + id: changesetResponse.id, + createdAt: new Date(changesetResponse.createdAt), + updatedAt: new Date(changesetResponse.updatedAt ?? changesetResponse.createdAt), + draftId: changesetResponse.draftId, + parentChangesetId: changesetResponse.parentChangesetId, + userId: changesetResponse.userId, + + gitUserName: changesetResponse.gitUserName, + gitUserEmail: changesetResponse.gitUserEmail, + deepLinkUrl: changesetResponse.deepLink, + + patches: changesetResponse.patches.map((patch: DraftPatchResponse) => formatPatch(patch)), + }; +} + +function formatPatch( + patchResponse: DraftPatchResponse, + options?: { + commit?: GitCommit; + contents?: string; + files?: DraftPatchFileChange[]; + repository?: Repository | RepositoryIdentity; + }, +): DraftPatch { + return { + type: 'cloud', + id: patchResponse.id, + createdAt: new Date(patchResponse.createdAt), + updatedAt: new Date(patchResponse.updatedAt ?? patchResponse.createdAt), + draftId: patchResponse.draftId, + changesetId: patchResponse.changesetId, + userId: patchResponse.userId, + + baseBranchName: patchResponse.baseBranchName, + baseRef: patchResponse.baseCommitSha, + gkRepositoryId: patchResponse.gitRepositoryId, + secureLink: patchResponse.secureDownloadData, + + commit: options?.commit, + contents: options?.contents, + files: options?.files, + repository: options?.repository, + }; +} + +export function getDraftEntityIdentifier(draft: Draft, patch?: DraftPatch): EntityIdentifier | undefined { + if (draft.prEntityId != null) { + return EntityIdentifierUtils.decode(draft.prEntityId); + } + + if (patch?.prEntityId != null) { + return EntityIdentifierUtils.decode(patch.prEntityId); + } + + return undefined; +} diff --git a/src/plus/github/models.ts b/src/plus/github/models.ts deleted file mode 100644 index 6d78f099f38ba..0000000000000 --- a/src/plus/github/models.ts +++ /dev/null @@ -1,371 +0,0 @@ -import type { Endpoints } from '@octokit/types'; -import { GitFileIndexStatus } from '../../git/models/file'; -import type { IssueLabel, IssueMember, IssueOrPullRequestType } from '../../git/models/issue'; -import { Issue } from '../../git/models/issue'; -import { - PullRequest, - PullRequestMergeableState, - PullRequestReviewDecision, - PullRequestState, -} from '../../git/models/pullRequest'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; - -export interface GitHubBlame { - ranges: GitHubBlameRange[]; - viewer?: string; -} - -export interface GitHubBlameRange { - startingLine: number; - endingLine: number; - commit: GitHubCommit; -} - -export interface GitHubBranch { - name: string; - target: { - oid: string; - commitUrl: string; - authoredDate: string; - committedDate: string; - }; -} - -export interface GitHubCommit { - oid: string; - parents: { nodes: { oid: string }[] }; - message: string; - additions?: number | undefined; - changedFiles?: number | undefined; - deletions?: number | undefined; - author: { avatarUrl: string | undefined; date: string; email: string | undefined; name: string }; - committer: { date: string; email: string | undefined; name: string }; - - files?: Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']['files']; -} - -export interface GitHubCommitRef { - oid: string; -} - -export type GitHubContributor = Endpoints['GET /repos/{owner}/{repo}/contributors']['response']['data'][0]; -export interface GitHubIssueOrPullRequest { - type: IssueOrPullRequestType; - number: number; - createdAt: string; - closed: boolean; - closedAt: string | null; - title: string; - url: string; -} - -export interface GitHubPagedResult { - pageInfo: GitHubPageInfo; - totalCount: number; - values: T[]; -} -export interface GitHubPageInfo { - startCursor?: string | null; - endCursor?: string | null; - hasNextPage: boolean; - hasPreviousPage: boolean; -} - -export type GitHubPullRequestState = 'OPEN' | 'CLOSED' | 'MERGED'; -export interface GitHubPullRequest { - author: { - login: string; - avatarUrl: string; - url: string; - }; - permalink: string; - number: number; - title: string; - state: GitHubPullRequestState; - updatedAt: string; - closedAt: string | null; - mergedAt: string | null; - repository: { - isFork: boolean; - owner: { - login: string; - }; - }; -} - -export interface GitHubDetailedIssue extends GitHubIssueOrPullRequest { - date: Date; - updatedAt: Date; - author: { - login: string; - avatarUrl: string; - url: string; - }; - assignees: { nodes: IssueMember[] }; - repository: { - name: string; - owner: { - login: string; - }; - }; - labels?: { nodes: IssueLabel[] }; - reactions?: { - totalCount: number; - }; - comments?: { - totalCount: number; - }; -} - -export type GitHubPullRequestReviewDecision = 'CHANGES_REQUESTED' | 'APPROVED' | 'REVIEW_REQUIRED'; -export type GitHubPullRequestMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; - -export interface GitHubDetailedPullRequest extends GitHubPullRequest { - baseRefName: string; - baseRefOid: string; - baseRepository: { - name: string; - owner: { - login: string; - }; - }; - headRefName: string; - headRefOid: string; - headRepository: { - name: string; - owner: { - login: string; - }; - }; - reviewDecision: GitHubPullRequestReviewDecision; - isReadByViewer: boolean; - isDraft: boolean; - isCrossRepository: boolean; - checksUrl: string; - totalCommentsCount: number; - mergeable: GitHubPullRequestMergeableState; - additions: number; - deletions: number; - reviewRequests: { - nodes: { - asCodeOwner: boolean; - requestedReviewer: { - login: string; - avatarUrl: string; - url: string; - }; - }[]; - }; - assignees: { - nodes: { - login: string; - avatarUrl: string; - url: string; - }[]; - }; -} - -export namespace GitHubPullRequest { - export function from(pr: GitHubPullRequest, provider: RichRemoteProvider): PullRequest { - return new PullRequest( - provider, - { - name: pr.author.login, - avatarUrl: pr.author.avatarUrl, - url: pr.author.url, - }, - String(pr.number), - pr.title, - pr.permalink, - fromState(pr.state), - new Date(pr.updatedAt), - pr.closedAt == null ? undefined : new Date(pr.closedAt), - pr.mergedAt == null ? undefined : new Date(pr.mergedAt), - ); - } - - export function fromState(state: GitHubPullRequestState): PullRequestState { - return state === 'MERGED' - ? PullRequestState.Merged - : state === 'CLOSED' - ? PullRequestState.Closed - : PullRequestState.Open; - } - - export function toState(state: PullRequestState): GitHubPullRequestState { - return state === PullRequestState.Merged ? 'MERGED' : state === PullRequestState.Closed ? 'CLOSED' : 'OPEN'; - } - - export function fromReviewDecision(reviewDecision: GitHubPullRequestReviewDecision): PullRequestReviewDecision { - switch (reviewDecision) { - case 'APPROVED': - return PullRequestReviewDecision.Approved; - case 'CHANGES_REQUESTED': - return PullRequestReviewDecision.ChangesRequested; - case 'REVIEW_REQUIRED': - return PullRequestReviewDecision.ReviewRequired; - } - } - - export function toReviewDecision(reviewDecision: PullRequestReviewDecision): GitHubPullRequestReviewDecision { - switch (reviewDecision) { - case PullRequestReviewDecision.Approved: - return 'APPROVED'; - case PullRequestReviewDecision.ChangesRequested: - return 'CHANGES_REQUESTED'; - case PullRequestReviewDecision.ReviewRequired: - return 'REVIEW_REQUIRED'; - } - } - - export function fromMergeableState(mergeableState: GitHubPullRequestMergeableState): PullRequestMergeableState { - switch (mergeableState) { - case 'MERGEABLE': - return PullRequestMergeableState.Mergeable; - case 'CONFLICTING': - return PullRequestMergeableState.Conflicting; - case 'UNKNOWN': - return PullRequestMergeableState.Unknown; - } - } - - export function toMergeableState(mergeableState: PullRequestMergeableState): GitHubPullRequestMergeableState { - switch (mergeableState) { - case PullRequestMergeableState.Mergeable: - return 'MERGEABLE'; - case PullRequestMergeableState.Conflicting: - return 'CONFLICTING'; - case PullRequestMergeableState.Unknown: - return 'UNKNOWN'; - } - } - - export function fromDetailed(pr: GitHubDetailedPullRequest, provider: RichRemoteProvider): PullRequest { - return new PullRequest( - provider, - { - name: pr.author.login, - avatarUrl: pr.author.avatarUrl, - url: pr.author.url, - }, - String(pr.number), - pr.title, - pr.permalink, - fromState(pr.state), - new Date(pr.updatedAt), - pr.closedAt == null ? undefined : new Date(pr.closedAt), - pr.mergedAt == null ? undefined : new Date(pr.mergedAt), - fromMergeableState(pr.mergeable), - { - head: { - exists: pr.headRepository != null, - owner: pr.headRepository?.owner.login, - repo: pr.baseRepository?.name, - sha: pr.headRefOid, - branch: pr.headRefName, - }, - base: { - exists: pr.baseRepository != null, - owner: pr.baseRepository?.owner.login, - repo: pr.baseRepository?.name, - sha: pr.baseRefOid, - branch: pr.baseRefName, - }, - isCrossRepository: pr.isCrossRepository, - }, - pr.isDraft, - pr.additions, - pr.deletions, - pr.totalCommentsCount, - fromReviewDecision(pr.reviewDecision), - pr.reviewRequests.nodes.map(r => ({ - isCodeOwner: r.asCodeOwner, - reviewer: { - name: r.requestedReviewer.login, - avatarUrl: r.requestedReviewer.avatarUrl, - url: r.requestedReviewer.url, - }, - })), - pr.assignees.nodes.map(r => ({ - name: r.login, - avatarUrl: r.avatarUrl, - url: r.url, - })), - ); - } -} - -export namespace GitHubDetailedIssue { - export function from(value: GitHubDetailedIssue, provider: RichRemoteProvider): Issue { - return new Issue( - { - id: provider.id, - name: provider.name, - domain: provider.domain, - icon: provider.icon, - }, - String(value.number), - value.title, - value.url, - new Date(value.createdAt), - value.closed, - new Date(value.updatedAt), - { - name: value.author.login, - avatarUrl: value.author.avatarUrl, - url: value.author.url, - }, - { - owner: value.repository.owner.login, - repo: value.repository.name, - }, - value.assignees.nodes.map(assignee => ({ - name: assignee.name, - avatarUrl: assignee.avatarUrl, - url: assignee.url, - })), - value.closedAt == null ? undefined : new Date(value.closedAt), - value.labels?.nodes == null - ? undefined - : value.labels.nodes.map(label => ({ - color: label.color, - name: label.name, - })), - value.comments?.totalCount, - value.reactions?.totalCount, - ); - } -} - -export interface GitHubTag { - name: string; - target: { - oid: string; - commitUrl: string; - authoredDate: string; - committedDate: string; - message?: string | null; - tagger?: { - date: string; - } | null; - }; -} - -export function fromCommitFileStatus( - status: NonNullable[0]['status'], -): GitFileIndexStatus | undefined { - switch (status) { - case 'added': - return GitFileIndexStatus.Added; - case 'changed': - case 'modified': - return GitFileIndexStatus.Modified; - case 'removed': - return GitFileIndexStatus.Deleted; - case 'renamed': - return GitFileIndexStatus.Renamed; - case 'copied': - return GitFileIndexStatus.Copied; - } - return undefined; -} diff --git a/src/plus/gitlab/models.ts b/src/plus/gitlab/models.ts deleted file mode 100644 index 1f170b802267a..0000000000000 --- a/src/plus/gitlab/models.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { PullRequest, PullRequestState } from '../../git/models/pullRequest'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; - -export interface GitLabUser { - id: number; - name: string; - username: string; - publicEmail: string | undefined; - state: string; - avatarUrl: string | undefined; - webUrl: string; -} - -export interface GitLabCommit { - id: string; - short_id: string; - created_at: Date; - parent_ids: string[]; - title: string; - message: string; - author_name: string; - author_email: string; - authored_date: Date; - committer_name: string; - committer_email: string; - committed_date: Date; - status: string; - project_id: number; -} - -export interface GitLabIssue { - iid: string; - author: { - name: string; - avatarUrl: string | null; - webUrl: string; - } | null; - title: string; - description: string; - createdAt: string; - updatedAt: string; - closedAt: string; - webUrl: string; - state: 'opened' | 'closed' | 'locked'; -} - -export interface GitLabMergeRequest { - iid: string; - author: { - name: string; - avatarUrl: string | null; - webUrl: string; - } | null; - title: string; - description: string | null; - state: GitLabMergeRequestState; - createdAt: string; - updatedAt: string; - mergedAt: string | null; - webUrl: string; -} - -export enum GitLabMergeRequestState { - OPEN = 'opened', - CLOSED = 'closed', - MERGED = 'merged', - LOCKED = 'locked', -} - -export namespace GitLabMergeRequest { - export function fromState(state: GitLabMergeRequestState): PullRequestState { - return state === GitLabMergeRequestState.MERGED - ? PullRequestState.Merged - : state === GitLabMergeRequestState.CLOSED || state === GitLabMergeRequestState.LOCKED - ? PullRequestState.Closed - : PullRequestState.Open; - } - - export function toState(state: PullRequestState): GitLabMergeRequestState { - return state === PullRequestState.Merged - ? GitLabMergeRequestState.MERGED - : state === PullRequestState.Closed - ? GitLabMergeRequestState.CLOSED - : GitLabMergeRequestState.OPEN; - } -} - -export interface GitLabMergeRequestREST { - id: number; - iid: number; - author: { - name: string; - avatar_url?: string; - web_url: string; - } | null; - title: string; - description: string; - state: GitLabMergeRequestState; - created_at: string; - updated_at: string; - closed_at: string | null; - merged_at: string | null; - web_url: string; -} - -export namespace GitLabMergeRequestREST { - export function from(pr: GitLabMergeRequestREST, provider: RichRemoteProvider): PullRequest { - return new PullRequest( - provider, - { - name: pr.author?.name ?? 'Unknown', - avatarUrl: pr.author?.avatar_url ?? '', - url: pr.author?.web_url ?? '', - }, - String(pr.iid), - pr.title, - pr.web_url, - GitLabMergeRequest.fromState(pr.state), - new Date(pr.updated_at), - pr.closed_at == null ? undefined : new Date(pr.closed_at), - pr.merged_at == null ? undefined : new Date(pr.merged_at), - ); - } -} diff --git a/src/plus/gk/account/authenticationConnection.ts b/src/plus/gk/account/authenticationConnection.ts new file mode 100644 index 0000000000000..5eddb41c82dff --- /dev/null +++ b/src/plus/gk/account/authenticationConnection.ts @@ -0,0 +1,276 @@ +import { uuid } from '@env/crypto'; +import type { Response } from '@env/fetch'; +import type { CancellationToken, Disposable, StatusBarItem } from 'vscode'; +import { CancellationTokenSource, env, StatusBarAlignment, Uri, window } from 'vscode'; +import type { TrackingContext } from '../../../constants.telemetry'; +import type { Container } from '../../../container'; +import { debug } from '../../../system/decorators/log'; +import type { DeferredEvent, DeferredEventExecutor } from '../../../system/event'; +import { promisifyDeferred } from '../../../system/event'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; +import { openUrl } from '../../../system/vscode/utils'; +import type { ServerConnection } from '../serverConnection'; + +export const LoginUriPathPrefix = 'login'; +export const AuthenticationUriPathPrefix = 'did-authenticate'; + +interface AccountInfo { + id: string; + accountName: string; +} + +export class AuthenticationConnection implements Disposable { + private _cancellationSource: CancellationTokenSource | undefined; + private _deferredCodeExchanges = new Map>(); + private _pendingStates = new Map(); + private _statusBarItem: StatusBarItem | undefined; + + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + dispose() {} + + abort(): Promise { + if (this._cancellationSource == null) return Promise.resolve(); + + this._cancellationSource.cancel(); + // This should allow the current auth request to abort before continuing + return new Promise(resolve => setTimeout(resolve, 50)); + } + + @debug({ args: false, exit: r => `returned ${r.id}` }) + async getAccountInfo(token: string): Promise { + const scope = getLogScope(); + + let rsp: Response; + try { + rsp = await this.connection.fetchApi('user', undefined, { token: token }); + } catch (ex) { + Logger.error(ex, scope); + throw ex; + } + + if (!rsp.ok) { + Logger.error(undefined, `Getting account info failed: (${rsp.status}) ${rsp.statusText}`); + throw new Error(rsp.statusText); + } + + const json: { id: string; username: string } = await rsp.json(); + return { id: json.id, accountName: json.username }; + } + + @debug() + async login( + scopes: string[], + scopeKey: string, + signUp: boolean = false, + context?: TrackingContext, + ): Promise { + const scope = getLogScope(); + + this.updateStatusBarItem(true); + + // Include a state parameter here to prevent CSRF attacks + const gkstate = uuid(); + const existingStates = this._pendingStates.get(scopeKey) ?? []; + this._pendingStates.set(scopeKey, [...existingStates, gkstate]); + + const callbackUri = await env.asExternalUri( + Uri.parse(`${env.uriScheme}://${this.container.context.extension.id}/${AuthenticationUriPathPrefix}`), + ); + + const uri = this.container.getGkDevUri( + signUp ? 'register' : 'login', + `${scopes.includes('gitlens') ? 'source=gitlens&' : ''}${ + context != null ? `context=${context}&` : '' + }state=${encodeURIComponent(gkstate)}&redirect_uri=${encodeURIComponent(callbackUri.toString(true))}`, + ); + + if (!(await openUrl(uri.toString(true)))) { + Logger.error(undefined, scope, 'Opening login URL failed'); + + this._pendingStates.delete(scopeKey); + this.updateStatusBarItem(false); + throw new Error('Cancelled'); + } + + // Ensure there is only a single listener for the URI callback, in case the user starts the login process multiple times before completing it + let deferredCodeExchange = this._deferredCodeExchanges.get(scopeKey); + if (deferredCodeExchange == null) { + deferredCodeExchange = promisifyDeferred( + this.container.uri.onDidReceiveAuthenticationUri, + this.getUriHandlerDeferredExecutor(), + ); + this._deferredCodeExchanges.set(scopeKey, deferredCodeExchange); + } + + this._cancellationSource?.cancel(); + this._cancellationSource = new CancellationTokenSource(); + + try { + const code = await Promise.race([ + deferredCodeExchange.promise, + new Promise((resolve, reject) => + this.openCompletionInputFallback(this._cancellationSource!.token, resolve, reject), + ), + new Promise( + (_, reject) => + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + this._cancellationSource?.token.onCancellationRequested(() => reject('Cancelled')), + ), + new Promise((_, reject) => setTimeout(reject, 120000, 'Cancelled')), + ]); + + const token = await this.getTokenFromCodeAndState(code, gkstate, scopeKey); + return token; + } catch (ex) { + Logger.error(ex, scope); + throw ex; + } finally { + this._cancellationSource?.cancel(); + this._cancellationSource = undefined; + + this._pendingStates.delete(scopeKey); + deferredCodeExchange?.cancel(); + this._deferredCodeExchanges.delete(scopeKey); + this.updateStatusBarItem(false); + } + } + + private async openCompletionInputFallback( + cancellationToken: CancellationToken, + resolve: (token: string) => void, + reject: (error: string) => void, + ) { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + let code: string | undefined = undefined; + + try { + if (cancellationToken.isCancellationRequested) return; + + code = await new Promise(resolve => { + disposables.push( + cancellationToken.onCancellationRequested(() => input.hide()), + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(e => { + if (!e) { + input.validationMessage = 'Please enter a valid code'; + return; + } + + input.validationMessage = undefined; + }), + input.onDidAccept(() => resolve(input.value)), + ); + + input.title = 'GitKraken Sign In'; + input.placeholder = 'Please enter the provided authorization code'; + input.prompt = 'If the auto-redirect fails, paste the authorization code'; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (code != null) { + resolve(code); + } else { + reject('Cancelled'); + } + } + + async getTokenFromCodeAndState(code: string, state?: string, scopeKey?: string): Promise { + if (state != null && scopeKey != null) { + const existingStates = this._pendingStates.get(scopeKey); + if (!existingStates?.includes(state)) { + throw new Error('Getting token failed: Invalid state'); + } + } + + const rsp = await this.connection.fetchGkDevApi( + 'oauth/access_token', + { + method: 'POST', + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: 'gitkraken.gitlens', + code: code, + state: state ?? '', + }), + }, + { + unAuthenticated: true, + }, + ); + + if (!rsp.ok) { + throw new Error(`Getting token failed: (${rsp.status}) ${rsp.statusText}`); + } + + const json: { access_token: string } = await rsp.json(); + if (json.access_token == null) { + throw new Error('Getting token failed: No access token returned'); + } + + return json.access_token; + } + + private getUriHandlerDeferredExecutor(): DeferredEventExecutor { + return (uri: Uri, resolve, reject) => { + const queryParams: URLSearchParams = new URLSearchParams(uri.query); + const code = queryParams.get('code'); + if (code == null) { + reject('Code not returned'); + return; + } + + resolve(code); + }; + } + + private updateStatusBarItem(signingIn?: boolean) { + if (signingIn && this._statusBarItem == null) { + this._statusBarItem = window.createStatusBarItem('gitlens.plus.signIn', StatusBarAlignment.Left); + this._statusBarItem.name = 'GitKraken Sign in'; + this._statusBarItem.text = 'Signing in to GitKraken...'; + this._statusBarItem.show(); + } + + if (!signingIn && this._statusBarItem != null) { + this._statusBarItem.dispose(); + this._statusBarItem = undefined; + } + } + + async getExchangeToken(redirectPath?: string): Promise { + const redirectUrl = + redirectPath != null + ? await env.asExternalUri( + Uri.parse(`${env.uriScheme}://${this.container.context.extension.id}/${redirectPath}`), + ) + : undefined; + + const rsp = await this.connection.fetchGkDevApi('v1/login/auth-exchange', { + method: 'POST', + body: JSON.stringify({ + source: 'gitlens', + redirectUrl: redirectUrl?.toString(), + }), + }); + + if (!rsp.ok) { + throw new Error(`Failed to get exchange token: (${rsp.status}) ${rsp.statusText}`); + } + + const json: { data: { exchangeToken: string } } = await rsp.json(); + return json.data.exchangeToken; + } +} diff --git a/src/plus/subscription/authenticationProvider.ts b/src/plus/gk/account/authenticationProvider.ts similarity index 69% rename from src/plus/subscription/authenticationProvider.ts rename to src/plus/gk/account/authenticationProvider.ts index 460ce85a5ad14..4bd6e4593f989 100644 --- a/src/plus/subscription/authenticationProvider.ts +++ b/src/plus/gk/account/authenticationProvider.ts @@ -1,15 +1,18 @@ +import { uuid } from '@env/crypto'; import type { AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationSession, } from 'vscode'; -import { authentication, Disposable, EventEmitter, extensions, window } from 'vscode'; -import { uuid } from '@env/crypto'; -import type { Container } from '../../container'; -import { Logger } from '../../logger'; -import { getLogScope } from '../../logScope'; -import { debug } from '../../system/decorators/log'; -import type { ServerConnection } from './serverConnection'; +import { Disposable, EventEmitter, window } from 'vscode'; +import type { TrackingContext } from '../../../constants.telemetry'; +import type { Container, Environment } from '../../../container'; +import { CancellationError } from '../../../errors'; +import { debug } from '../../../system/decorators/log'; +import { Logger } from '../../../system/logger'; +import { getLogScope, setLogScopeExit } from '../../../system/logger.scope'; +import type { ServerConnection } from '../serverConnection'; +import { AuthenticationConnection } from './authenticationConnection'; interface StoredSession { id: string; @@ -22,26 +25,37 @@ interface StoredSession { scopes: string[]; } -const authenticationId = 'gitlens+'; -const authenticationLabel = 'GitLens+'; +export const authenticationProviderId = 'gitlens+'; +export const authenticationProviderScopes = ['gitlens']; + +export interface AuthenticationProviderOptions { + signUp?: boolean; + signIn?: { code: string; state?: string }; + context?: TrackingContext; +} -export class SubscriptionAuthenticationProvider implements AuthenticationProvider, Disposable { +export class AccountAuthenticationProvider implements AuthenticationProvider, Disposable { private _onDidChangeSessions = new EventEmitter(); get onDidChangeSessions() { return this._onDidChangeSessions.event; } private readonly _disposable: Disposable; + private readonly _authConnection: AuthenticationConnection; private _sessionsPromise: Promise; + private _optionsByScope: Map | undefined; + + constructor( + private readonly container: Container, + connection: ServerConnection, + ) { + this._authConnection = new AuthenticationConnection(container, connection); - constructor(private readonly container: Container, private readonly server: ServerConnection) { // Contains the current state of the sessions we have available. this._sessionsPromise = this.getSessionsFromStorage(); this._disposable = Disposable.from( - authentication.registerAuthenticationProvider(authenticationId, authenticationLabel, this, { - supportsMultipleAccounts: false, - }), + this._authConnection, this.container.storage.onDidChangeSecrets(() => this.checkForUpdates()), ); } @@ -50,24 +64,40 @@ export class SubscriptionAuthenticationProvider implements AuthenticationProvide this._disposable.dispose(); } - private get secretStorageKey(): string { + private get secretStorageKey(): `gitlens.plus.auth:${Environment}` { return `gitlens.plus.auth:${this.container.env}`; } abort(): Promise { - return this.server.abort(); + return this._authConnection.abort(); + } + + public setOptionsForScopes(scopes: string[], options: AuthenticationProviderOptions) { + this._optionsByScope ??= new Map(); + this._optionsByScope.set(getScopesKey(scopes), options); + } + + public clearOptionsForScopes(scopes: string[]) { + this._optionsByScope?.delete(getScopesKey(scopes)); } @debug() public async createSession(scopes: string[]): Promise { const scope = getLogScope(); + const options = this._optionsByScope?.get(getScopesKey(scopes)); + if (options != null) { + this._optionsByScope?.delete(getScopesKey(scopes)); + } // Ensure that the scopes are sorted consistently (since we use them for matching and order doesn't matter) scopes = scopes.sort(); const scopesKey = getScopesKey(scopes); try { - const token = await this.server.login(scopes, scopesKey); + const token = + options?.signIn != null + ? await this._authConnection.getTokenFromCodeAndState(options.signIn.code, options.signIn.state) + : await this._authConnection.login(scopes, scopesKey, options?.signUp, options?.context); const session = await this.createSessionForToken(token, scopes); const sessions = await this._sessionsPromise; @@ -84,10 +114,14 @@ export class SubscriptionAuthenticationProvider implements AuthenticationProvide return session; } catch (ex) { // If login was cancelled, do not notify user. - if (ex === 'Cancelled') throw ex; + if (ex === 'Cancelled' || ex.message === 'Cancelled') throw ex; Logger.error(ex, scope); - void window.showErrorMessage(`Unable to sign in to GitLens+: ${ex}`); + void window.showErrorMessage( + `Unable to sign in to GitKraken: ${ + ex instanceof CancellationError ? 'request timed out' : ex + }. Please try again. If this issue persists, please contact support.`, + ); throw ex; } } @@ -102,9 +136,7 @@ export class SubscriptionAuthenticationProvider implements AuthenticationProvide const sessions = await this._sessionsPromise; const filtered = scopes != null ? sessions.filter(s => getScopesKey(s.scopes) === scopesKey) : sessions; - if (scope != null) { - scope.exitDetails = ` \u2022 Found ${filtered.length} sessions`; - } + setLogScopeExit(scope, ` \u2022 Found ${filtered.length} sessions`); return filtered; } @@ -129,7 +161,7 @@ export class SubscriptionAuthenticationProvider implements AuthenticationProvide this._onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); } catch (ex) { Logger.error(ex, scope); - void window.showErrorMessage(`Unable to sign out of GitLens+: ${ex}`); + void window.showErrorMessage(`Unable to sign out of GitKraken: ${ex}`); throw ex; } } @@ -165,55 +197,11 @@ export class SubscriptionAuthenticationProvider implements AuthenticationProvide this._onDidChangeSessions.fire({ added: [], removed: removed, changed: [] }); } catch (ex) { Logger.error(ex, scope); - void window.showErrorMessage(`Unable to sign out of GitLens+: ${ex}`); + void window.showErrorMessage(`Unable to sign out of GitKraken: ${ex}`); throw ex; } } - private _migrated: boolean | undefined; - async tryMigrateSession(): Promise { - if (this._migrated == null) { - this._migrated = this.container.storage.get('plus:migratedAuthentication', false); - } - if (this._migrated) return undefined; - - let session: AuthenticationSession | undefined; - try { - if (extensions.getExtension('gitkraken.gitkraken-authentication') == null) return; - - session = await authentication.getSession('gitkraken', ['gitlens'], { - createIfNone: false, - }); - if (session == null) return; - - session = { - id: uuid(), - accessToken: session.accessToken, - account: { ...session.account }, - scopes: session.scopes, - }; - - const sessions = await this._sessionsPromise; - const scopesKey = getScopesKey(session.scopes); - const sessionIndex = sessions.findIndex(s => s.id === session!.id || getScopesKey(s.scopes) === scopesKey); - if (sessionIndex > -1) { - sessions.splice(sessionIndex, 1, session); - } else { - sessions.push(session); - } - - await this.storeSessions(sessions); - - this._onDidChangeSessions.fire({ added: [session], removed: [], changed: [] }); - } catch (ex) { - Logger.error(ex, 'Unable to migrate authentication'); - } finally { - this._migrated = true; - void this.container.storage.store('plus:migratedAuthentication', true); - } - return session; - } - private async checkForUpdates() { const previousSessions = await this._sessionsPromise; this._sessionsPromise = this.getSessionsFromStorage(); @@ -242,8 +230,22 @@ export class SubscriptionAuthenticationProvider implements AuthenticationProvide } } + public async getOrCreateSession( + scopes: string[], + createIfNeeded: boolean, + ): Promise { + const session = (await this.getSessions(scopes))[0]; + if (session != null) { + return session; + } + if (!createIfNeeded) { + return undefined; + } + return this.createSession(scopes); + } + private async createSessionForToken(token: string, scopes: string[]): Promise { - const userInfo = await this.server.getAccountInfo(token); + const userInfo = await this._authConnection.getAccountInfo(token); return { id: uuid(), accessToken: token, @@ -281,7 +283,7 @@ export class SubscriptionAuthenticationProvider implements AuthenticationProvide let userInfo: { id: string; accountName: string } | undefined; if (session.account == null) { try { - userInfo = await this.server.getAccountInfo(session.accessToken); + userInfo = await this._authConnection.getAccountInfo(session.accessToken); Logger.debug(`Verified session with scopes=${scopesKey}`); } catch (ex) { // Remove sessions that return unauthorized response @@ -323,10 +325,13 @@ export class SubscriptionAuthenticationProvider implements AuthenticationProvide Logger.error(ex, `Unable to store ${sessions.length} sessions`); } } + + async getExchangeToken(redirectPath?: string): Promise { + return this._authConnection.getExchangeToken(redirectPath); + } } function getScopesKey(scopes: readonly string[]): string; -function getScopesKey(scopes: undefined): string | undefined; function getScopesKey(scopes: readonly string[] | undefined): string | undefined; function getScopesKey(scopes: readonly string[] | undefined): string | undefined { return scopes?.join('|'); diff --git a/src/plus/gk/account/organization.ts b/src/plus/gk/account/organization.ts new file mode 100644 index 0000000000000..bf61599cf6062 --- /dev/null +++ b/src/plus/gk/account/organization.ts @@ -0,0 +1,60 @@ +export interface Organization { + readonly id: string; + readonly name: string; + readonly role: OrganizationRole; +} + +export type OrganizationRole = 'owner' | 'admin' | 'billing' | 'user'; + +export type OrganizationsResponse = Organization[]; + +export interface FullOrganization { + readonly id: string; + readonly name: string; + readonly domain: string; + readonly updatedToNewRoles: boolean; + readonly memberCount: number; + readonly members: OrganizationMember[]; + readonly connections: OrganizationConnection[]; + readonly type: OrganizationType; + readonly isOnChargebee: boolean; +} + +export enum OrganizationType { + Enterprise = 'ENTERPRISE', + Individual = 'INDIVIDUAL', + Pro = 'PRO', + Teams = 'TEAMS', +} + +export type OrganizationConnection = Record; + +export interface OrganizationMember { + readonly id: string; + readonly email: string; + readonly name: string; + readonly username: string; + readonly role: OrganizationRole; + readonly licenseConsumption: Record; +} + +export interface OrganizationSettings { + aiSettings: OrganizationSetting; + draftsSettings: OrganizationDraftsSettings; +} + +export interface OrganizationSetting { + readonly enabled: boolean; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface OrganizationDraftsSettings extends OrganizationSetting { + readonly bucket: + | { + readonly name: string; + readonly region: string; + readonly provider: string; + } + | undefined; +} diff --git a/src/plus/gk/account/organizationService.ts b/src/plus/gk/account/organizationService.ts new file mode 100644 index 0000000000000..590491b1794dd --- /dev/null +++ b/src/plus/gk/account/organizationService.ts @@ -0,0 +1,247 @@ +import { Disposable, window } from 'vscode'; +import type { Container } from '../../../container'; +import { gate } from '../../../system/decorators/gate'; +import { once } from '../../../system/function'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; +import { setContext } from '../../../system/vscode/context'; +import type { ServerConnection } from '../serverConnection'; +import type { + FullOrganization, + Organization, + OrganizationMember, + OrganizationSettings, + OrganizationsResponse, +} from './organization'; +import type { SubscriptionChangeEvent } from './subscriptionService'; + +const organizationsCacheExpiration = 24 * 60 * 60 * 1000; // 1 day + +export class OrganizationService implements Disposable { + private _disposable: Disposable; + private _organizations: Organization[] | null | undefined; + private _fullOrganizations: Map | undefined; + private _organizationSettings: Map | undefined; + + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) { + this._disposable = Disposable.from( + once(container.onReady)(async () => { + const orgId = await this.getActiveOrganizationId(); + void this.updateOrganizationPermissions(orgId); + }), + container.subscription.onDidChange(this.onSubscriptionChanged, this), + ); + } + + dispose(): void { + this._disposable.dispose(); + } + + @gate() + async getOrganizations(options?: { + force?: boolean; + accessToken?: string; + userId?: string; + }): Promise { + const scope = getLogScope(); + const userId = options?.userId ?? (await this.container.subscription.getSubscription(true))?.account?.id; + if (userId == null) { + this.updateOrganizations(undefined); + return this._organizations; + } + + if (this._organizations === undefined || options?.force) { + if (!options?.force) { + this.loadStoredOrganizations(userId); + if (this._organizations != null) return this._organizations; + } + + let rsp; + try { + rsp = await this.connection.fetchApi( + 'user/organizations-light', + { + method: 'GET', + }, + { token: options?.accessToken }, + ); + } catch (ex) { + debugger; + Logger.error(ex, scope); + + void window.showErrorMessage(`Unable to get organizations due to error: ${ex}`, 'OK'); + this.updateOrganizations(undefined); + return this._organizations; + } + + if (!rsp.ok) { + debugger; + Logger.error( + undefined, + scope, + `Unable to get organizations; status=(${rsp.status}): ${rsp.statusText}`, + ); + + void window.showErrorMessage(`Unable to get organizations; Status: ${rsp.statusText}`, 'OK'); + + // Setting to null prevents hitting the API again until you reload + this.updateOrganizations(null); + return this._organizations; + } + + const organizationsResponse = (await rsp.json()) as OrganizationsResponse; + const organizations = organizationsResponse.map((o: any) => ({ + id: o.id, + name: o.name, + role: o.role, + })); + + await this.storeOrganizations(organizations, userId); + this.updateOrganizations(organizations); + } + + return this._organizations; + } + + @gate() + private loadStoredOrganizations(userId: string): void { + const storedOrganizations = this.container.storage.get(`gk:${userId}:organizations`); + if (storedOrganizations == null) return; + const { timestamp, data: organizations } = storedOrganizations; + if (timestamp == null || Date.now() - timestamp > organizationsCacheExpiration) { + return; + } + + this.updateOrganizations(organizations); + } + + private async storeOrganizations(organizations: Organization[], userId: string): Promise { + return this.container.storage.store(`gk:${userId}:organizations`, { + v: 1, + timestamp: Date.now(), + data: organizations, + }); + } + + private onSubscriptionChanged(e: SubscriptionChangeEvent): void { + if (e.current?.account?.id == null) { + this.updateOrganizations(undefined); + } + void this.updateOrganizationPermissions(e.current?.activeOrganization?.id); + } + + private updateOrganizations(organizations: Organization[] | null | undefined): void { + this._organizations = organizations; + void setContext('gitlens:gk:hasOrganizations', (organizations ?? []).length > 1); + } + + private async updateOrganizationPermissions(orgId: string | undefined): Promise { + const settings = orgId != null ? await this.getOrganizationSettings(orgId) : undefined; + + void setContext('gitlens:gk:organization:ai:enabled', settings?.aiSettings.enabled ?? true); + void setContext('gitlens:gk:organization:drafts:byob', settings?.draftsSettings.bucket != null); + void setContext('gitlens:gk:organization:drafts:enabled', settings?.draftsSettings.enabled ?? true); + } + + @gate() + private async getFullOrganization( + id: string, + options?: { force?: boolean }, + ): Promise { + if (!this._fullOrganizations?.has(id) || options?.force === true) { + const rsp = await this.connection.fetchApi(`organization/${id}`, { method: 'GET' }); + if (!rsp.ok) { + Logger.error( + '', + getLogScope(), + `Unable to get organization; status=(${rsp.status}): ${rsp.statusText}`, + ); + return undefined; + } + + const organization = (await rsp.json()) as FullOrganization; + if (this._fullOrganizations == null) { + this._fullOrganizations = new Map(); + } + organization.members.sort((a, b) => (a.name ?? a.username).localeCompare(b.name ?? b.username)); + this._fullOrganizations.set(id, organization); + } + return this._fullOrganizations.get(id); + } + + @gate() + async getMembers( + organizationId?: string | undefined, + options?: { force?: boolean }, + ): Promise { + if (organizationId == null) { + organizationId = await this.getActiveOrganizationId(); + if (organizationId == null) return []; + } + + const organization = await this.getFullOrganization(organizationId, options); + return organization?.members ?? []; + } + + async getMemberById(id: string, organizationId: string): Promise { + return (await this.getMembers(organizationId)).find(m => m.id === id); + } + + async getMembersByIds(ids: string[], organizationId: string): Promise { + return (await this.getMembers(organizationId)).filter(m => ids.includes(m.id)); + } + + private async getActiveOrganizationId(cached = true): Promise { + const subscription = await this.container.subscription.getSubscription(cached); + return subscription?.activeOrganization?.id; + } + + @gate() + async getOrganizationSettings( + orgId: string | undefined, + options?: { force?: boolean }, + ): Promise { + type OrganizationSettingsResponse = { + data: OrganizationSettings; + error: string | undefined; + }; + // TODO: maybe getActiveOrganizationId(false) when force is true + const id = orgId ?? (await this.getActiveOrganizationId()); + if (id == null) return undefined; + + if (!this._organizationSettings?.has(id) || options?.force === true) { + const rsp = await this.connection.fetchApi( + `v1/organizations/settings`, + { method: 'GET' }, + { organizationId: id }, + ); + if (!rsp.ok) { + Logger.error( + '', + getLogScope(), + `Unable to get organization settings; status=(${rsp.status}): ${rsp.statusText}`, + ); + return undefined; + } + + const organizationResponse = (await rsp.json()) as OrganizationSettingsResponse; + if (organizationResponse.error != null) { + Logger.error( + '', + getLogScope(), + `Unable to get organization settings; status=(${rsp.status}): ${organizationResponse.error}`, + ); + return undefined; + } + + if (this._organizationSettings == null) { + this._organizationSettings = new Map(); + } + this._organizationSettings.set(id, organizationResponse.data); + } + return this._organizationSettings.get(id); + } +} diff --git a/src/plus/gk/account/promos.ts b/src/plus/gk/account/promos.ts new file mode 100644 index 0000000000000..40ad735c6e604 --- /dev/null +++ b/src/plus/gk/account/promos.ts @@ -0,0 +1,77 @@ +import type { PromoKeys } from '../../../constants'; +import { SubscriptionState } from './subscription'; + +export interface Promo { + readonly key: PromoKeys; + readonly code?: string; + readonly states?: SubscriptionState[]; + readonly expiresOn?: number; + readonly startsOn?: number; + + readonly command?: `command:${string}`; + readonly commandTooltip?: string; +} + +// Must be ordered by applicable order +const promos: Promo[] = [ + { + key: 'launchpad', + code: 'GLLAUNCHPAD24', + states: [ + SubscriptionState.Free, + SubscriptionState.FreeInPreviewTrial, + SubscriptionState.FreePreviewTrialExpired, + SubscriptionState.FreePlusInTrial, + SubscriptionState.FreePlusTrialExpired, + SubscriptionState.FreePlusTrialReactivationEligible, + ], + expiresOn: new Date('2024-09-27T06:59:00.000Z').getTime(), + commandTooltip: 'Launchpad Sale: Save 75% or more on GitLens Pro', + }, + { + key: 'launchpad-extended', + code: 'GLLAUNCHPAD24', + states: [ + SubscriptionState.Free, + SubscriptionState.FreeInPreviewTrial, + SubscriptionState.FreePreviewTrialExpired, + SubscriptionState.FreePlusInTrial, + SubscriptionState.FreePlusTrialExpired, + SubscriptionState.FreePlusTrialReactivationEligible, + ], + startsOn: new Date('2024-09-27T06:59:00.000Z').getTime(), + expiresOn: new Date('2024-10-14T06:59:00.000Z').getTime(), + commandTooltip: 'Launchpad Sale: Save 75% or more on GitLens Pro', + }, + { + key: 'pro50', + states: [ + SubscriptionState.Free, + SubscriptionState.FreeInPreviewTrial, + SubscriptionState.FreePreviewTrialExpired, + SubscriptionState.FreePlusInTrial, + SubscriptionState.FreePlusTrialExpired, + SubscriptionState.FreePlusTrialReactivationEligible, + ], + commandTooltip: 'Limited-Time Sale: Save 33% or more on your 1st seat of Pro. See your special price', + }, +]; + +export function getApplicablePromo(state: number | undefined, key?: PromoKeys): Promo | undefined { + if (state == null) return undefined; + + for (const promo of promos) { + if ((key == null || key === promo.key) && isPromoApplicable(promo, state)) return promo; + } + + return undefined; +} + +function isPromoApplicable(promo: Promo, state: number): boolean { + const now = Date.now(); + return ( + (promo.states == null || promo.states.includes(state)) && + (promo.expiresOn == null || promo.expiresOn > now) && + (promo.startsOn == null || promo.startsOn < now) + ); +} diff --git a/src/subscription.ts b/src/plus/gk/account/subscription.ts similarity index 58% rename from src/subscription.ts rename to src/plus/gk/account/subscription.ts index 364d5a2987895..9ed1169f688b9 100644 --- a/src/subscription.ts +++ b/src/plus/gk/account/subscription.ts @@ -1,5 +1,8 @@ // NOTE@eamodio This file is referenced in the webviews to we can't use anything vscode or other imports that aren't available in the webviews -import { getDateDifference } from './system/date'; +import { getDateDifference } from '../../../system/date'; +import type { Organization } from './organization'; + +export const SubscriptionUpdatedUriPathPrefix = 'did-update-subscription'; export const enum SubscriptionPlanId { Free = 'free', @@ -22,12 +25,19 @@ export interface Subscription { previewTrial?: SubscriptionPreviewTrial; state: SubscriptionState; + + lastValidatedAt?: number; + + readonly activeOrganization?: Organization; } export interface SubscriptionPlan { readonly id: SubscriptionPlanId; readonly name: string; readonly bundle: boolean; + readonly trialReactivationCount: number; + readonly nextTrialOptInDate?: string | undefined; + readonly cancelled: boolean; readonly startedOn: string; readonly expiresOn?: string | undefined; readonly organizationId: string | undefined; @@ -39,7 +49,6 @@ export interface SubscriptionAccount { readonly email: string | undefined; readonly verified: boolean; readonly createdOn: string; - readonly organizationIds: string[]; } export interface SubscriptionPreviewTrial { @@ -47,21 +56,48 @@ export interface SubscriptionPreviewTrial { readonly expiresOn: string; } +// NOTE: Pay attention to gitlens:plus:state in package.json when modifying this enum +// NOTE: This is reported in telemetry so we should NOT change the values export const enum SubscriptionState { /** Indicates a user who hasn't verified their email address yet */ VerificationRequired = -1, /** Indicates a Free user who hasn't yet started the preview trial */ Free = 0, /** Indicates a Free user who is in preview trial */ - FreeInPreviewTrial, + FreeInPreviewTrial = 1, /** Indicates a Free user who's preview has expired trial */ - FreePreviewTrialExpired, + FreePreviewTrialExpired = 2, /** Indicates a Free+ user with a completed trial */ - FreePlusInTrial, - /** Indicates a Free+ user who's trial has expired */ - FreePlusTrialExpired, + FreePlusInTrial = 3, + /** Indicates a Free+ user who's trial has expired and is not yet eligible for reactivation */ + FreePlusTrialExpired = 4, + /** Indicated a Free+ user who's trial has expired and is eligible for reactivation */ + FreePlusTrialReactivationEligible = 5, /** Indicates a Paid user */ - Paid, + Paid = 6, +} + +export function getSubscriptionStateString(state: SubscriptionState | undefined): string { + switch (state) { + case SubscriptionState.VerificationRequired: + return 'verification'; + case SubscriptionState.Free: + return 'free'; + case SubscriptionState.FreeInPreviewTrial: + return 'preview'; + case SubscriptionState.FreePreviewTrialExpired: + return 'preview-expired'; + case SubscriptionState.FreePlusInTrial: + return 'trial'; + case SubscriptionState.FreePlusTrialExpired: + return 'trial-expired'; + case SubscriptionState.FreePlusTrialReactivationEligible: + return 'trial-reactivation-eligible'; + case SubscriptionState.Paid: + return 'paid'; + default: + return 'unknown'; + } } export function computeSubscriptionState(subscription: Optional): SubscriptionState { @@ -78,8 +114,13 @@ export function computeSubscriptionState(subscription: Optional([ ]); export function getSubscriptionPlanPriority(id: SubscriptionPlanId | undefined): number { - return plansPriority.get(id)!; + return plansPriority.get(id) ?? -1; } export function getSubscriptionTimeRemaining( @@ -163,11 +215,11 @@ export function getTimeRemaining( expiresOn: string | undefined, unit?: 'days' | 'hours' | 'minutes' | 'seconds', ): number | undefined { - return expiresOn != null ? getDateDifference(Date.now(), new Date(expiresOn), unit) : undefined; + return expiresOn != null ? getDateDifference(Date.now(), new Date(expiresOn), unit, Math.round) : undefined; } export function isSubscriptionPaid(subscription: Optional): boolean { - return isSubscriptionPaidPlan(subscription.plan.effective.id); + return isSubscriptionPaidPlan(subscription.plan.actual.id); } export function isSubscriptionPaidPlan(id: SubscriptionPlanId): id is PaidSubscriptionPlans { @@ -183,7 +235,47 @@ export function isSubscriptionTrial(subscription: Optional): boolean { + if ( + subscription.account == null || + !isSubscriptionTrial(subscription) || + isSubscriptionPreviewTrialExpired(subscription) === false + ) { + return false; + } + + const remaining = getSubscriptionTimeRemaining(subscription); + return remaining != null ? remaining <= 0 : true; +} + export function isSubscriptionPreviewTrialExpired(subscription: Optional): boolean | undefined { const remaining = getTimeRemaining(subscription.previewTrial?.expiresOn); return remaining != null ? remaining <= 0 : undefined; } + +export function isSubscriptionStatePaidOrTrial(state: SubscriptionState | undefined): boolean { + if (state == null) return false; + return ( + state === SubscriptionState.Paid || + state === SubscriptionState.FreeInPreviewTrial || + state === SubscriptionState.FreePlusInTrial + ); +} + +export function isSubscriptionStateTrial(state: SubscriptionState | undefined): boolean { + if (state == null) return false; + return state === SubscriptionState.FreeInPreviewTrial || state === SubscriptionState.FreePlusInTrial; +} + +export function hasAccountFromSubscriptionState(state: SubscriptionState | undefined): boolean { + if (state == null) return false; + return ( + state !== SubscriptionState.Free && + state !== SubscriptionState.FreePreviewTrialExpired && + state !== SubscriptionState.FreeInPreviewTrial + ); +} + +export function assertSubscriptionState( + subscription: Optional, +): asserts subscription is Subscription {} diff --git a/src/plus/gk/account/subscriptionService.ts b/src/plus/gk/account/subscriptionService.ts new file mode 100644 index 0000000000000..b34b7dde7ea08 --- /dev/null +++ b/src/plus/gk/account/subscriptionService.ts @@ -0,0 +1,1590 @@ +import { getPlatform } from '@env/platform'; +import type { + AuthenticationProviderAuthenticationSessionsChangeEvent, + AuthenticationSession, + CancellationToken, + Event, + MessageItem, + StatusBarItem, +} from 'vscode'; +import { + CancellationTokenSource, + version as codeVersion, + Disposable, + env, + EventEmitter, + MarkdownString, + ProgressLocation, + StatusBarAlignment, + ThemeColor, + Uri, + window, +} from 'vscode'; +import type { OpenWalkthroughCommandArgs } from '../../../commands/walkthroughs'; +import { urls } from '../../../constants'; +import type { CoreColors } from '../../../constants.colors'; +import { Commands } from '../../../constants.commands'; +import type { Source, TrackingContext } from '../../../constants.telemetry'; +import type { Container } from '../../../container'; +import { AccountValidationError, RequestsAreBlockedTemporarilyError } from '../../../errors'; +import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; +import { createFromDateDelta, fromNow } from '../../../system/date'; +import { gate } from '../../../system/decorators/gate'; +import { debug, log } from '../../../system/decorators/log'; +import { take } from '../../../system/event'; +import type { Deferrable } from '../../../system/function'; +import { debounce, once } from '../../../system/function'; +import { Logger } from '../../../system/logger'; +import { getLogScope, setLogScopeExit } from '../../../system/logger.scope'; +import { flatten } from '../../../system/object'; +import { pauseOnCancelOrTimeout } from '../../../system/promise'; +import { pluralize } from '../../../system/string'; +import { satisfies } from '../../../system/version'; +import { executeCommand, registerCommand } from '../../../system/vscode/command'; +import { configuration } from '../../../system/vscode/configuration'; +import { setContext } from '../../../system/vscode/context'; +import { openUrl } from '../../../system/vscode/utils'; +import type { GKCheckInResponse } from '../checkin'; +import { getSubscriptionFromCheckIn } from '../checkin'; +import type { ServerConnection } from '../serverConnection'; +import { ensurePlusFeaturesEnabled } from '../utils'; +import { LoginUriPathPrefix } from './authenticationConnection'; +import { authenticationProviderScopes } from './authenticationProvider'; +import type { Organization } from './organization'; +import { getApplicablePromo } from './promos'; +import type { Subscription } from './subscription'; +import { + assertSubscriptionState, + computeSubscriptionState, + getSubscriptionPlan, + getSubscriptionPlanName, + getSubscriptionStateString, + getSubscriptionTimeRemaining, + getTimeRemaining, + isSubscriptionExpired, + isSubscriptionInProTrial, + isSubscriptionPaid, + isSubscriptionTrial, + SubscriptionPlanId, + SubscriptionState, + SubscriptionUpdatedUriPathPrefix, +} from './subscription'; + +export interface SubscriptionChangeEvent { + readonly current: Subscription; + readonly previous: Subscription; + readonly etag: number; +} + +export class SubscriptionService implements Disposable { + private _onDidChange = new EventEmitter(); + get onDidChange(): Event { + return this._onDidChange.event; + } + + private _onDidCheckIn = new EventEmitter(); + get onDidCheckIn(): Event { + return this._onDidCheckIn.event; + } + + private _disposable: Disposable; + private _subscription!: Subscription; + private _getCheckInData: () => Promise; + private _statusBarSubscription: StatusBarItem | undefined; + private _validationTimer: ReturnType | undefined; + + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + previousVersion: string | undefined, + ) { + this._disposable = Disposable.from( + once(container.onReady)(this.onReady, this), + this.container.accountAuthentication.onDidChangeSessions( + e => setTimeout(() => this.onAuthenticationChanged(e), 0), + this, + ), + configuration.onDidChange(e => { + if (configuration.changed(e, 'plusFeatures')) { + this.updateContext(); + } + }), + container.uri.onDidReceiveSubscriptionUpdatedUri(this.checkUpdatedSubscription, this), + container.uri.onDidReceiveLoginUri(this.onLoginUri, this), + ); + + const subscription = this.getStoredSubscription(); + this._getCheckInData = () => Promise.resolve(undefined); + // Resets the preview trial state on the upgrade to 14.0 + if (subscription != null) { + if (satisfies(previousVersion, '< 14.0')) { + subscription.previewTrial = undefined; + } + + if (subscription.account?.id != null) { + this._getCheckInData = () => this.loadStoredCheckInData(subscription.account!.id); + } + } + + this.changeSubscription(subscription, { silent: true }); + setTimeout(() => void this.ensureSession(false), 10000); + } + + dispose(): void { + this._statusBarSubscription?.dispose(); + + this._disposable.dispose(); + } + + private async onAuthenticationChanged(e: AuthenticationProviderAuthenticationSessionsChangeEvent) { + let session = this._session; + if (session == null && this._sessionPromise != null) { + session = await this._sessionPromise; + } + + if (session != null && e.removed?.some(s => s.id === session.id)) { + this._session = undefined; + this._sessionPromise = undefined; + void this.logout(undefined, undefined); + return; + } + + const updated = e.added?.[0] ?? e.changed?.[0]; + if (updated == null) return; + + if (updated.id === session?.id && updated.accessToken === session?.accessToken) { + return; + } + + this._session = session; + void this.validate({ force: true }); + } + + private _etag: number = 0; + get etag(): number { + return this._etag; + } + + private onReady() { + this._disposable = Disposable.from( + this._disposable, + this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), + ...this.registerCommands(), + ); + this.updateContext(); + } + + private onRepositoriesChanged(_e: RepositoriesChangeEvent): void { + this.updateContext(); + } + + private registerCommands(): Disposable[] { + void this.container.viewCommands; + + return [ + registerCommand(Commands.PlusLogin, (src?: Source) => this.loginOrSignUp(false, src)), + registerCommand(Commands.PlusSignUp, (src?: Source) => this.loginOrSignUp(true, src)), + registerCommand(Commands.PlusLogout, (src?: Source) => this.logout(undefined, src)), + registerCommand(Commands.GKSwitchOrganization, () => this.switchOrganization()), + + registerCommand(Commands.PlusManage, (src?: Source) => this.manage(src)), + registerCommand(Commands.PlusShowPlans, (src?: Source) => this.showPlans(src)), + registerCommand(Commands.PlusStartPreviewTrial, (src?: Source) => this.startPreviewTrial(src)), + registerCommand(Commands.PlusReactivateProTrial, (src?: Source) => this.reactivateProTrial(src)), + registerCommand(Commands.PlusResendVerification, (src?: Source) => this.resendVerification(src)), + registerCommand(Commands.PlusUpgrade, (src?: Source) => this.upgrade(src)), + + registerCommand(Commands.PlusHide, (src?: Source) => this.setProFeaturesVisibility(false, src)), + registerCommand(Commands.PlusRestore, (src?: Source) => this.setProFeaturesVisibility(true, src)), + + registerCommand(Commands.PlusValidate, (src?: Source) => this.validate({ force: true }, src)), + ]; + } + + async getAuthenticationSession(createIfNeeded: boolean = false): Promise { + return this.ensureSession(createIfNeeded); + } + + async getSubscription(cached = false): Promise { + const promise = this.ensureSession(false); + if (!cached) { + void (await promise); + } + return this._subscription; + } + + @debug() + async learnAboutPro(source: Source, originalSource: Source | undefined): Promise { + if (originalSource != null) { + source.detail = { + ...(typeof source.detail === 'string' ? { action: source.detail } : source.detail), + ...flatten(originalSource, 'original'), + }; + } + + const subscription = await this.getSubscription(); + switch (subscription.state) { + case SubscriptionState.VerificationRequired: + case SubscriptionState.Free: + case SubscriptionState.FreeInPreviewTrial: + case SubscriptionState.FreePreviewTrialExpired: + void executeCommand(Commands.OpenWalkthrough, { + ...source, + step: 'pro-features', + }); + break; + case SubscriptionState.FreePlusInTrial: + void executeCommand(Commands.OpenWalkthrough, { + ...source, + step: 'pro-trial', + }); + break; + case SubscriptionState.FreePlusTrialExpired: + void executeCommand(Commands.OpenWalkthrough, { + ...source, + step: 'pro-upgrade', + }); + break; + case SubscriptionState.FreePlusTrialReactivationEligible: + void executeCommand(Commands.OpenWalkthrough, { + ...source, + step: 'pro-reactivate', + }); + break; + case SubscriptionState.Paid: + void executeCommand(Commands.OpenWalkthrough, { + ...source, + step: 'pro-paid', + }); + break; + } + } + + private async showPlanMessage(source: Source | undefined) { + if (!(await this.ensureSession(false))) return; + const { + account, + plan: { actual, effective }, + } = this._subscription; + + if (account?.verified === false) { + const days = getSubscriptionTimeRemaining(this._subscription, 'days') ?? 7; + + const verify: MessageItem = { title: 'Resend Email' }; + const learn: MessageItem = { title: 'See Pro Features' }; + const confirm: MessageItem = { title: 'Continue', isCloseAffordance: true }; + const result = await window.showInformationMessage( + isSubscriptionPaid(this._subscription) + ? `You are now on the ${actual.name} plan. \n\nYou must first verify your email. Once verified, you will have full access to Pro features.` + : `Welcome to your ${ + effective.name + } Trial.\n\nYou must first verify your email. Once verified, you will have full access to Pro features for ${ + days < 1 ? '<1 more day' : pluralize('day', days, { infix: ' more ' }) + }.`, + { + modal: true, + detail: `Your ${ + isSubscriptionPaid(this._subscription) ? 'plan' : 'trial' + } also includes access to our DevEx platform, unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.`, + }, + verify, + learn, + confirm, + ); + + if (result === verify) { + void this.resendVerification(source); + } else if (result === learn) { + void this.learnAboutPro({ source: 'prompt', detail: { action: 'trial-started-verify-email' } }, source); + } + } else if (isSubscriptionPaid(this._subscription)) { + const learn: MessageItem = { title: 'See Pro Features' }; + const confirm: MessageItem = { title: 'Continue', isCloseAffordance: true }; + const result = await window.showInformationMessage( + `You are now on the ${actual.name} plan and have full access to Pro features.`, + { + modal: true, + detail: 'Your plan also includes access to our DevEx platform, unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.', + }, + learn, + confirm, + ); + + if (result === learn) { + void this.learnAboutPro({ source: 'prompt', detail: { action: 'upgraded' } }, source); + } + } else if (isSubscriptionTrial(this._subscription)) { + const days = getSubscriptionTimeRemaining(this._subscription, 'days') ?? 0; + + const learn: MessageItem = { title: 'See Pro Features' }; + const confirm: MessageItem = { title: 'Continue', isCloseAffordance: true }; + const result = await window.showInformationMessage( + `Welcome to your ${effective.name} Trial.\n\nYou now have full access to Pro features for ${ + days < 1 ? '<1 more day' : pluralize('day', days, { infix: ' more ' }) + }.`, + { + modal: true, + detail: 'Your trial also includes access to our DevEx platform, unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.', + }, + confirm, + learn, + ); + + if (result === learn) { + void this.learnAboutPro({ source: 'prompt', detail: { action: 'trial-started' } }, source); + } + } else { + const upgrade: MessageItem = { title: 'Upgrade to Pro' }; + const learn: MessageItem = { title: 'See Pro Features' }; + const confirm: MessageItem = { title: 'Continue', isCloseAffordance: true }; + const result = await window.showInformationMessage( + `You are now on the ${actual.name} plan.`, + { + modal: true, + detail: 'You only have access to Pro features on publicly-hosted repos. For full access to Pro features, please upgrade to a paid plan.\nA paid plan also includes access to our DevEx platform, unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.', + }, + upgrade, + learn, + confirm, + ); + + if (result === upgrade) { + void this.upgrade(source); + } else if (result === learn) { + void this.learnAboutPro({ source: 'prompt', detail: { action: 'trial-ended' } }, source); + } + } + } + + @log() + async loginOrSignUp(signUp: boolean, source: Source | undefined): Promise { + if (!(await ensurePlusFeaturesEnabled())) return false; + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + 'subscription/action', + { action: signUp ? 'sign-up' : 'sign-in' }, + source, + ); + } + + const context = getTrackingContextFromSource(source); + return this.loginCore({ signUp: signUp, source: source, context: context }); + } + + async loginWithCode(authentication: { code: string; state?: string }, source?: Source): Promise { + if (!(await ensurePlusFeaturesEnabled())) return false; + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'sign-in' }, source); + } + + const session = await this.ensureSession(false); + if (session != null) { + await this.logout(undefined, source); + } + + return this.loginCore({ signIn: authentication, source: source }); + } + + private async loginCore(options?: { + signUp?: boolean; + source?: Source; + signIn?: { code: string; state?: string }; + context?: TrackingContext; + }): Promise { + // Abort any waiting authentication to ensure we can start a new flow + await this.container.accountAuthentication.abort(); + void this.showAccountView(); + + const session = await this.ensureSession(true, { + signIn: options?.signIn, + signUp: options?.signUp, + context: options?.context, + }); + const loggedIn = Boolean(session); + if (loggedIn) { + void this.showPlanMessage(options?.source); + } + return loggedIn; + } + + @log() + async logout(reset: boolean = false, source: Source | undefined): Promise { + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'sign-out' }, source); + } + + return this.logoutCore(reset); + } + + private async logoutCore(reset: boolean = false): Promise { + this.connection.resetRequestExceptionCount(); + this._lastValidatedDate = undefined; + if (this._validationTimer != null) { + clearInterval(this._validationTimer); + this._validationTimer = undefined; + } + + await this.container.accountAuthentication.abort(); + + this._sessionPromise = undefined; + if (this._session != null) { + void this.container.accountAuthentication.removeSession(this._session.id); + this._session = undefined; + } else { + // Even if we don't have a session, make sure to remove any other matching sessions + void this.container.accountAuthentication.removeSessionsByScopes(authenticationProviderScopes); + } + + if (reset && this.container.debugging) { + this.changeSubscription(undefined); + + return; + } + + this.changeSubscription({ + ...this._subscription, + plan: { + actual: getSubscriptionPlan( + SubscriptionPlanId.Free, + false, + 0, + undefined, + this._subscription.plan?.actual?.startedOn != null + ? new Date(this._subscription.plan.actual.startedOn) + : undefined, + ), + effective: getSubscriptionPlan( + SubscriptionPlanId.Free, + false, + 0, + undefined, + this._subscription.plan?.effective?.startedOn != null + ? new Date(this._subscription.plan.actual.startedOn) + : undefined, + ), + }, + account: undefined, + activeOrganization: undefined, + }); + } + + @log() + async manage(source: Source | undefined): Promise { + const scope = getLogScope(); + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'manage' }, source); + } + + try { + const exchangeToken = await this.container.accountAuthentication.getExchangeToken(); + void env.openExternal(this.container.getGkDevExchangeUri(exchangeToken, 'account')); + } catch (ex) { + Logger.error(ex, scope); + void env.openExternal(this.container.getGkDevUri('account')); + } + } + + @gate(() => '') + @log() + async reactivateProTrial(source: Source | undefined): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + const scope = getLogScope(); + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'reactivate' }, source); + } + + const session = await this.ensureSession(false); + if (session == null) return; + + try { + const rsp = await this.connection.fetchApi('user/reactivate-trial', { + method: 'POST', + body: JSON.stringify({ client: 'gitlens' }), + }); + + if (!rsp.ok) { + if (rsp.status === 409) { + void window.showErrorMessage( + 'You are not eligible to reactivate your Pro trial. If you feel that is an error, please contact support.', + 'OK', + ); + return; + } + + void window.showErrorMessage( + `Unable to reactivate trial: (${rsp.status}) ${rsp.statusText}. Please try again. If this issue persists, please contact support.`, + 'OK', + ); + return; + } + } catch (ex) { + if (ex instanceof RequestsAreBlockedTemporarilyError) { + void window.showErrorMessage( + 'Unable to reactivate trial: Too many failed requests. Please reload the window and try again.', + 'OK', + ); + return; + } + + void window.showErrorMessage( + `Unable to reactivate trial. Please try again. If this issue persists, please contact support.`, + 'OK', + ); + Logger.error(ex, scope); + return; + } + + // Trial was reactivated. Do a check-in to update, and show a message if successful. + try { + await this.checkInAndValidate(session, { force: true }); + if (isSubscriptionTrial(this._subscription)) { + const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); + + const confirm: MessageItem = { title: 'OK' }; + const learn: MessageItem = { title: "See What's New" }; + const result = await window.showInformationMessage( + `Your Pro trial has been reactivated! Experience all the new Pro features for another ${pluralize( + 'day', + remaining ?? 0, + )}.`, + { modal: true }, + confirm, + learn, + ); + + if (result === learn) { + void openUrl(urls.releaseNotes); + } + } + } catch (ex) { + Logger.error(ex, scope); + debugger; + } + } + + @gate(() => '') + @log() + async resendVerification(source: Source | undefined): Promise { + if (this._subscription.account?.verified) return true; + + const scope = getLogScope(); + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'resend-verification' }, source); + } + void this.showAccountView(true); + + const session = await this.ensureSession(false); + if (session == null) return false; + + try { + const rsp = await this.connection.fetchApi( + 'resend-email', + { + method: 'POST', + body: JSON.stringify({ id: session.account.id }), + }, + { token: session.accessToken }, + ); + + if (!rsp.ok) { + debugger; + Logger.error( + '', + scope, + `Unable to resend verification email; status=(${rsp.status}): ${rsp.statusText}`, + ); + + void window.showErrorMessage(`Unable to resend verification email; Status: ${rsp.statusText}`, 'OK'); + + return false; + } + + const confirm = { title: 'Recheck' }; + const cancel = { title: 'Cancel' }; + const result = await window.showInformationMessage( + "Once you have verified your email address, click 'Recheck'.", + confirm, + cancel, + ); + + if (result === confirm) { + await this.validate({ force: true }, source); + return true; + } + } catch (ex) { + Logger.error(ex, scope); + debugger; + + void window.showErrorMessage('Unable to resend verification email', 'OK'); + } + + return false; + } + + private setProFeaturesVisibility(visible: boolean, source: Source | undefined) { + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + 'subscription/action', + { action: 'visibility', visible: visible }, + source, + ); + } + + void configuration.updateEffective('plusFeatures.enabled', visible); + } + + @log() + async showAccountView(silent: boolean = false): Promise { + if (silent && !configuration.get('plusFeatures.enabled', undefined, true)) return; + + if (!this.container.accountView.visible) { + await executeCommand(Commands.ShowAccountView); + } + } + + private showPlans(source: Source | undefined): void { + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'pricing' }, source); + } + + void openUrl(urls.pricing); + } + + @gate(() => '') + @log() + async startPreviewTrial(source: Source | undefined): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'start-preview-trial' }, source); + } + + let { plan, previewTrial } = this._subscription; + if (previewTrial != null) { + void this.showAccountView(); + + if (plan.effective.id === SubscriptionPlanId.Free) { + const signUp: MessageItem = { title: 'Start Pro Trial' }; + const signIn: MessageItem = { title: 'Sign In' }; + const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showInformationMessage( + 'Do you want to start your free 7-day Pro trial for full access to Pro features?', + { modal: true }, + signUp, + signIn, + cancel, + ); + + if (result === signUp || result === signIn) { + void this.loginOrSignUp(result === signUp, source); + } + } + + return; + } + + // Don't overwrite a trial that is already in progress + if (isSubscriptionInProTrial(this._subscription)) return; + + const startedOn = new Date(); + + let days: number; + let expiresOn = new Date(startedOn); + if (this.container.debugging) { + expiresOn = createFromDateDelta(expiresOn, { minutes: 1 }); + days = 0; + } else { + // Normalize the date to just before midnight on the same day + expiresOn.setHours(23, 59, 59, 999); + expiresOn = createFromDateDelta(expiresOn, { days: 3 }); + days = 3; + } + + previewTrial = { + startedOn: startedOn.toISOString(), + expiresOn: expiresOn.toISOString(), + }; + + this.changeSubscription({ + ...this._subscription, + plan: { + ...this._subscription.plan, + effective: getSubscriptionPlan(SubscriptionPlanId.Pro, false, 0, undefined, startedOn, expiresOn), + }, + previewTrial: previewTrial, + }); + + setTimeout(async () => { + const confirm: MessageItem = { title: 'Continue' }; + const learn: MessageItem = { title: 'See Pro Features' }; + const result = await window.showInformationMessage( + `You can now preview local Pro features for ${ + days < 1 ? '1 day' : pluralize('day', days) + }, or [start your free 7-day Pro trial](command:gitlens.plus.signUp "Start Pro Trial") for full access to Pro features.`, + confirm, + learn, + ); + + if (result === learn) { + void this.learnAboutPro({ source: 'notification', detail: { action: 'preview-started' } }, source); + } + }, 1); + } + + @log() + async upgrade(source: Source | undefined): Promise { + const scope = getLogScope(); + + if (!(await ensurePlusFeaturesEnabled())) return; + + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('subscription/action', { action: 'upgrade' }, source); + } + + if (this._subscription.account != null) { + // Do a pre-check-in to see if we've already upgraded to a paid plan. + try { + const session = await this.ensureSession(false); + if (session != null) { + if ((await this.checkUpdatedSubscription()) === SubscriptionState.Paid) { + return; + } + } + } catch {} + } + + const query = new URLSearchParams(); + query.set('source', 'gitlens'); + query.set('product', 'gitlens'); + + const hasAccount = this._subscription.account != null; + + const successUri = await env.asExternalUri( + Uri.parse( + `${env.uriScheme}://${this.container.context.extension.id}/${ + hasAccount ? SubscriptionUpdatedUriPathPrefix : LoginUriPathPrefix + }`, + ), + ); + query.set('success_uri', successUri.toString(true)); + + const promoCode = getApplicablePromo(this._subscription.state)?.code; + if (promoCode != null) { + query.set('promoCode', promoCode); + } + + const activeOrgId = this._subscription.activeOrganization?.id; + if (activeOrgId != null) { + query.set('org', activeOrgId); + } + + const context = getTrackingContextFromSource(source); + if (context != null) { + query.set('context', context); + } + + try { + if (hasAccount) { + const token = await this.container.accountAuthentication.getExchangeToken( + SubscriptionUpdatedUriPathPrefix, + ); + const purchasePath = `purchase/checkout?${query.toString()}`; + if (!(await openUrl(this.container.getGkDevExchangeUri(token, purchasePath).toString(true)))) return; + } else if ( + !(await openUrl(this.container.getGkDevUri('purchase/checkout', query.toString()).toString(true))) + ) { + return; + } + } catch (ex) { + Logger.error(ex, scope); + if (!(await openUrl(this.container.getGkDevUri('purchase/checkout', query.toString()).toString(true)))) { + return; + } + } + + const completionPromises = [new Promise(resolve => setTimeout(() => resolve(false), 5 * 60 * 1000))]; + + if (hasAccount) { + completionPromises.push( + new Promise(resolve => + take( + window.onDidChangeWindowState, + 2, + )(e => { + if (e.focused) resolve(true); + }), + ), + new Promise(resolve => + once(this.container.uri.onDidReceiveSubscriptionUpdatedUri)(() => resolve(false)), + ), + ); + } else { + completionPromises.push( + new Promise(resolve => once(this.container.uri.onDidReceiveLoginUri)(() => resolve(false))), + ); + } + + const refresh = await Promise.race(completionPromises); + + if (refresh) { + void this.checkUpdatedSubscription(); + } + } + + @gate(o => `${o?.force ?? false}`) + @log() + async validate(options?: { force?: boolean }, _source?: Source | undefined): Promise { + const scope = getLogScope(); + + const session = await this.ensureSession(false); + if (session == null) { + this.changeSubscription(this._subscription); + return; + } + + try { + await this.checkInAndValidate(session, options); + } catch (ex) { + Logger.error(ex, scope); + debugger; + } + } + + private _lastValidatedDate: Date | undefined; + + @debug({ args: { 0: s => s?.account?.label } }) + private async checkInAndValidate( + session: AuthenticationSession, + options?: { force?: boolean; showSlowProgress?: boolean; organizationId?: string }, + ): Promise { + const scope = getLogScope(); + + // Only check in if we haven't in the last 12 hours + if ( + !options?.force && + this._lastValidatedDate != null && + Date.now() - this._lastValidatedDate.getTime() < 12 * 60 * 60 * 1000 && + !isSubscriptionExpired(this._subscription) + ) { + setLogScopeExit(scope, ` (${fromNow(this._lastValidatedDate.getTime(), true)})...`, 'skipped'); + return; + } + + const validating = this.checkInAndValidateCore(session, options?.organizationId); + if (!options?.showSlowProgress) return validating; + + // Show progress if we are waiting too long + const result = await pauseOnCancelOrTimeout(validating, undefined, 3000); + if (result.paused) { + return window.withProgress( + { location: ProgressLocation.Notification, title: 'Validating your GitKraken account...' }, + () => result.value, + ); + } + + return result.value; + } + + @gate(s => s.account.id) + @debug({ args: { 0: s => s?.account?.label } }) + private async checkInAndValidateCore( + session: AuthenticationSession, + organizationId?: string, + ): Promise { + const scope = getLogScope(); + this._lastValidatedDate = undefined; + + try { + const checkInData = { + id: session.account.id, + platform: getPlatform(), + gitlensVersion: this.container.version, + machineId: env.machineId, + sessionId: env.sessionId, + vscodeEdition: env.appName, + vscodeHost: env.appHost, + vscodeVersion: codeVersion, + previewStartedOn: this._subscription.previewTrial?.startedOn, + previewExpiresOn: this._subscription.previewTrial?.expiresOn, + }; + + const rsp = await this.connection.fetchApi( + 'gitlens/checkin', + { + method: 'POST', + body: JSON.stringify(checkInData), + }, + { token: session.accessToken, organizationId: organizationId }, + ); + + if (!rsp.ok) { + this._getCheckInData = () => Promise.resolve(undefined); + throw new AccountValidationError('Unable to validate account', undefined, rsp.status, rsp.statusText); + } + + this._onDidCheckIn.fire(); + + const data: GKCheckInResponse = await rsp.json(); + this._getCheckInData = () => Promise.resolve(data); + this.storeCheckInData(data); + + await this.validateAndUpdateSubscriptions(data, session); + return data; + } catch (ex) { + this._getCheckInData = () => Promise.resolve(undefined); + + Logger.error(ex, scope); + debugger; + + // If we cannot check in, validate stored subscription + this.changeSubscription(this._subscription); + if (ex instanceof AccountValidationError) throw ex; + + throw new AccountValidationError('Unable to validate account', ex); + } finally { + this.startDailyValidationTimer(); + } + } + + private startDailyValidationTimer(): void { + if (this._validationTimer != null) { + clearInterval(this._validationTimer); + } + + // Check 4 times a day to ensure we validate at least once a day + this._validationTimer = setInterval( + () => { + if (this._lastValidatedDate == null || this._lastValidatedDate.getDate() !== new Date().getDate()) { + void this.ensureSession(false, { force: true }); + } + }, + 6 * 60 * 60 * 1000, + ); + } + + private storeCheckInData(data: GKCheckInResponse): void { + if (data.user?.id == null) return; + + void this.container.storage.store(`gk:${data.user.id}:checkin`, { + v: 1, + timestamp: Date.now(), + data: data, + }); + } + + private async loadStoredCheckInData(userId: string): Promise { + const scope = getLogScope(); + const storedCheckIn = this.container.storage.get(`gk:${userId}:checkin`); + // If more than a day old, ignore + if (storedCheckIn?.timestamp == null || Date.now() - storedCheckIn.timestamp > 24 * 60 * 60 * 1000) { + // Attempt a check-in to see if we can get a new one + const session = await this.getAuthenticationSession(false); + if (session == null) return undefined; + + try { + return await this.checkInAndValidate(session, { force: true }); + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + + return storedCheckIn?.data; + } + + @debug() + private async validateAndUpdateSubscriptions(data: GKCheckInResponse, session: AuthenticationSession) { + const scope = getLogScope(); + let organizations: Organization[]; + try { + organizations = + (await this.container.organizations.getOrganizations({ + force: true, + accessToken: session.accessToken, + userId: session.account.id, + })) ?? []; + } catch (ex) { + Logger.error(ex, scope); + organizations = []; + } + let chosenOrganizationId: string | undefined = configuration.get('gitKraken.activeOrganizationId') ?? undefined; + if (chosenOrganizationId === '') { + chosenOrganizationId = undefined; + } else if (chosenOrganizationId != null && !organizations.some(o => o.id === chosenOrganizationId)) { + chosenOrganizationId = undefined; + void configuration.updateEffective('gitKraken.activeOrganizationId', undefined); + } + const subscription = getSubscriptionFromCheckIn(data, organizations, chosenOrganizationId); + this._lastValidatedDate = new Date(); + this.changeSubscription( + { + ...this._subscription, + ...subscription, + }, + { store: true }, + ); + } + + private _sessionPromise: Promise | undefined; + private _session: AuthenticationSession | null | undefined; + + @gate() + @debug() + private async ensureSession( + createIfNeeded: boolean, + options?: { + force?: boolean; + signUp?: boolean; + signIn?: { code: string; state?: string }; + context?: TrackingContext; + }, + ): Promise { + if (this._sessionPromise != null) { + void (await this._sessionPromise); + } + + if (!options?.force && this._session != null) return this._session; + if (this._session === null && !createIfNeeded) return undefined; + + if (this._sessionPromise === undefined) { + this._sessionPromise = this.getOrCreateSession(createIfNeeded, { + signUp: options?.signUp, + signIn: options?.signIn, + context: options?.context, + }).then( + s => { + this._session = s; + this._sessionPromise = undefined; + return this._session; + }, + () => { + this._session = null; + this._sessionPromise = undefined; + return this._session; + }, + ); + } + + const session = await this._sessionPromise; + return session ?? undefined; + } + + @debug() + private async getOrCreateSession( + createIfNeeded: boolean, + options?: { signUp?: boolean; signIn?: { code: string; state?: string }; context?: TrackingContext }, + ): Promise { + const scope = getLogScope(); + + let session: AuthenticationSession | null | undefined; + try { + if (options != null && createIfNeeded) { + this.container.accountAuthentication.setOptionsForScopes(authenticationProviderScopes, options); + } + session = await this.container.accountAuthentication.getOrCreateSession( + authenticationProviderScopes, + createIfNeeded, + ); + } catch (ex) { + session = null; + if (options != null && createIfNeeded) { + this.container.accountAuthentication.clearOptionsForScopes(authenticationProviderScopes); + } + + if (ex instanceof Error && ex.message.includes('User did not consent')) { + setLogScopeExit(scope, ' \u2022 User declined authentication'); + await this.logoutCore(); + return null; + } + + Logger.error(ex, scope); + } + + if (session == null) { + setLogScopeExit(scope, ' \u2022 No valid session was found'); + await this.logoutCore(); + return session ?? null; + } + + try { + await this.checkInAndValidate(session, { showSlowProgress: createIfNeeded, force: createIfNeeded }); + } catch (ex) { + Logger.error(ex, scope); + debugger; + + this.container.telemetry.sendEvent('account/validation/failed', { + 'account.id': session.account.id, + exception: String(ex), + code: ex.original?.code, + statusCode: ex.statusCode, + }); + + setLogScopeExit( + scope, + ` \u2022 Account validation failed (${ex.statusCode ?? ex.original?.code})`, + 'FAILED', + ); + + if (ex instanceof AccountValidationError) { + const name = session.account.label; + + // if ( + // (ex.statusCode != null && ex.statusCode < 500) || + // (ex.statusCode == null && (ex.original as any)?.code !== 'ENOTFOUND') + // ) { + if ( + (ex.original as any)?.code !== 'ENOTFOUND' && + ex.statusCode != null && + ex.statusCode < 500 && + ex.statusCode >= 400 + ) { + session = null; + await this.logoutCore(); + + if (createIfNeeded) { + const unauthorized = ex.statusCode === 401; + queueMicrotask(async () => { + const confirm: MessageItem = { title: 'Retry Sign In' }; + const result = await window.showErrorMessage( + `Unable to sign in to your (${name}) GitKraken account. Please try again. If this issue persists, please contact support.${ + unauthorized ? '' : ` Error=${ex.message}` + }`, + confirm, + ); + + if (result === confirm) { + void this.loginOrSignUp(false, { + source: 'subscription', + detail: { + error: 'validation-failed', + 'error.message': ex.message, + }, + }); + } + }); + } + } else { + session = session ?? null; + + // if ((ex.original as any)?.code !== 'ENOTFOUND') { + // void window.showErrorMessage( + // `Unable to sign in to your (${name}) GitKraken account right now. Please try again in a few minutes. If this issue persists, please contact support. Error=${ex.message}`, + // 'OK', + // ); + // } + } + } + } + + this.connection.resetRequestExceptionCount(); + return session; + } + + @debug() + private changeSubscription( + subscription: Optional | undefined, + options?: { silent?: boolean; store?: boolean }, + ): void { + if (subscription == null) { + subscription = { + plan: { + actual: getSubscriptionPlan(SubscriptionPlanId.Free, false, 0, undefined), + effective: getSubscriptionPlan(SubscriptionPlanId.Free, false, 0, undefined), + }, + account: undefined, + state: SubscriptionState.Free, + }; + } + + // If the effective plan has expired, then replace it with the actual plan + if (isSubscriptionExpired(subscription)) { + subscription = { + ...subscription, + plan: { + ...subscription.plan, + effective: subscription.plan.actual, + }, + }; + } + + // If we don't have a paid plan (or a non-preview trial), check if the preview trial has expired, if not apply it + if ( + !isSubscriptionPaid(subscription) && + subscription.previewTrial != null && + (getTimeRemaining(subscription.previewTrial.expiresOn) ?? 0) > 0 + ) { + subscription = { + ...subscription, + plan: { + ...subscription.plan, + effective: getSubscriptionPlan( + SubscriptionPlanId.Pro, + false, + 0, + undefined, + new Date(subscription.previewTrial.startedOn), + new Date(subscription.previewTrial.expiresOn), + ), + }, + }; + } + + subscription.state = computeSubscriptionState(subscription); + assertSubscriptionState(subscription); + + const promo = getApplicablePromo(subscription.state); + void setContext('gitlens:promo', promo?.key); + + const previous = this._subscription as typeof this._subscription | undefined; // Can be undefined here, since we call this in the constructor + // Check the previous and new subscriptions are exactly the same + const matches = previous != null && JSON.stringify(previous) === JSON.stringify(subscription); + + // If the previous and new subscriptions are exactly the same, kick out + if (matches) { + if (options?.store) { + void this.storeSubscription(subscription); + } + return; + } + + queueMicrotask(() => { + let data = flattenSubscription(subscription); + this.container.telemetry.setGlobalAttributes(data); + + data = { + ...data, + ...(!matches ? flattenSubscription(previous, 'previous') : {}), + }; + + this.container.telemetry.sendEvent(previous == null ? 'subscription' : 'subscription/changed', data); + }); + + void this.storeSubscription(subscription); + + this._subscription = subscription; + this._etag = Date.now(); + + if (!options?.silent) { + this.updateContext(); + + if (previous != null) { + this._onDidChange.fire({ current: subscription, previous: previous, etag: this._etag }); + } + } + } + + private getStoredSubscription(): Subscription | undefined { + const storedSubscription = this.container.storage.get('premium:subscription'); + + let lastValidatedAt: number | undefined; + let subscription: Subscription | undefined; + if (storedSubscription?.data != null) { + ({ lastValidatedAt, ...subscription } = storedSubscription.data); + this._lastValidatedDate = lastValidatedAt != null ? new Date(lastValidatedAt) : undefined; + } else { + subscription = undefined; + } + + if (subscription != null) { + // Migrate the plan names to the latest names + (subscription.plan.actual as Mutable).name = getSubscriptionPlanName( + subscription.plan.actual.id, + ); + (subscription.plan.effective as Mutable).name = getSubscriptionPlanName( + subscription.plan.effective.id, + ); + } + + return subscription; + } + + private async storeSubscription(subscription: Subscription): Promise { + return this.container.storage.store('premium:subscription', { + v: 1, + data: { ...subscription, lastValidatedAt: this._lastValidatedDate?.getTime() }, + }); + } + + private _cancellationSource: CancellationTokenSource | undefined; + private _updateAccessContextDebounced: Deferrable | undefined; + + private updateContext(): void { + this._updateAccessContextDebounced?.cancel(); + if (this._updateAccessContextDebounced == null) { + this._updateAccessContextDebounced = debounce(this.updateAccessContext.bind(this), 500); + } + + if (this._cancellationSource != null) { + this._cancellationSource.cancel(); + } + this._cancellationSource = new CancellationTokenSource(); + + void this._updateAccessContextDebounced(this._cancellationSource.token); + this.updateStatusBar(); + + const { + plan: { actual }, + state, + } = this._subscription; + + void setContext('gitlens:plus', actual.id != SubscriptionPlanId.Free ? actual.id : undefined); + void setContext('gitlens:plus:state', state); + } + + private async updateAccessContext(cancellation: CancellationToken): Promise { + let allowed: boolean | 'mixed' = false; + // For performance reasons, only check if we have any repositories + if (this.container.git.repositoryCount !== 0) { + ({ allowed } = await this.container.git.access()); + if (cancellation.isCancellationRequested) return; + } + + const plusFeatures = configuration.get('plusFeatures.enabled') ?? true; + + let disallowedRepos: string[] | undefined; + + if (!plusFeatures && allowed === 'mixed') { + disallowedRepos = []; + for (const repo of this.container.git.repositories) { + if (repo.closed) continue; + + const access = await this.container.git.access(undefined, repo.uri); + if (cancellation.isCancellationRequested) return; + + if (!access.allowed) { + disallowedRepos.push(repo.uri.toString()); + } + } + } + + void setContext('gitlens:plus:enabled', Boolean(allowed) || plusFeatures); + void setContext('gitlens:plus:required', allowed === false); + void setContext('gitlens:plus:disallowedRepos', disallowedRepos); + } + + private updateStatusBar(): void { + const { + account, + plan: { effective }, + state, + } = this._subscription; + + if (effective.id === SubscriptionPlanId.Free) { + this._statusBarSubscription?.dispose(); + this._statusBarSubscription = undefined; + return; + } + + const trial = isSubscriptionTrial(this._subscription); + if (!trial && account?.verified !== false) { + this._statusBarSubscription?.dispose(); + this._statusBarSubscription = undefined; + return; + } + + if (this._statusBarSubscription == null) { + this._statusBarSubscription = window.createStatusBarItem( + 'gitlens.plus.subscription', + StatusBarAlignment.Left, + 1, + ); + } + + this._statusBarSubscription.name = 'GitKraken Subscription'; + this._statusBarSubscription.command = Commands.ShowAccountView; + + if (account?.verified === false) { + this._statusBarSubscription.text = `$(warning) ${effective.name} (Unverified)`; + this._statusBarSubscription.backgroundColor = new ThemeColor( + 'statusBarItem.warningBackground' satisfies CoreColors, + ); + this._statusBarSubscription.tooltip = new MarkdownString( + trial + ? `**Please verify your email**\n\nYou must verify your email before you can start your **${effective.name}** trial.\n\nClick for details` + : `**Please verify your email**\n\nYou must verify your email before you can use Pro features on privately-hosted repos.\n\nClick for details`, + true, + ); + } else { + const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); + const isReactivatedTrial = + state === SubscriptionState.FreePlusInTrial && effective.trialReactivationCount > 0; + + this._statusBarSubscription.text = `${effective.name} (Trial)`; + this._statusBarSubscription.tooltip = new MarkdownString( + `${ + isReactivatedTrial + ? `[See what's new](${urls.releaseNotes}) with ${pluralize('day', remaining ?? 0, { + infix: ' more ', + })} in your **${effective.name}** trial.` + : `You have ${pluralize('day', remaining ?? 0)} remaining in your **${effective.name}** trial.` + } Once your trial ends, you'll need a paid plan for full access to [Pro features](command:gitlens.openWalkthrough?%7B%22step%22%3A%22pro-trial%22,%22source%22%3A%22prompt%22%7D).\n\nYour trial also includes access to our [DevEx platform](${ + urls.platform + }), unleashing powerful Git visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal.`, + true, + ); + } + + this._statusBarSubscription.show(); + } + + async switchOrganization(): Promise { + const scope = getLogScope(); + if (this._session == null) return; + + let organizations; + try { + organizations = await this.container.organizations.getOrganizations(); + } catch (ex) { + debugger; + Logger.error(ex, scope); + return; + } + + if (organizations == null || organizations.length === 0) return; + + // Show a quickpick to select the active organization + const picks: { label: string; org: Organization | null }[] = organizations.map(org => ({ + label: org.name, + org: org, + })); + + const pick = await window.showQuickPick(picks, { + title: 'Switch Organization', + placeHolder: 'Select the active organization for your GitKraken account', + }); + + const currentActiveOrganization = this._subscription?.activeOrganization; + if (pick?.org == null) { + return; + } + + if (currentActiveOrganization != null && pick.org.id === currentActiveOrganization.id) { + return; + } + + await this.checkInAndValidate(this._session, { force: true, organizationId: pick.org.id }); + const checkInData = await this._getCheckInData(); + if (checkInData == null) return; + + const organizationSubscription = getSubscriptionFromCheckIn(checkInData, organizations, pick.org.id); + + if (configuration.get('gitKraken.activeOrganizationId') !== pick.org.id) { + await configuration.updateEffective('gitKraken.activeOrganizationId', pick.org.id); + } + + this.changeSubscription( + { + ...this._subscription, + ...organizationSubscription, + }, + { store: true }, + ); + } + + onLoginUri(uri: Uri) { + const scope = getLogScope(); + const queryParams = new URLSearchParams(uri.query); + const code = queryParams.get('code'); + const state = queryParams.get('state'); + const context = queryParams.get('context'); + let contextMessage = 'sign in to GitKraken'; + + switch (context) { + case 'start_trial': + contextMessage = 'start a Pro trial'; + break; + } + + if (code == null) { + Logger.error(undefined, scope, `No code provided. Link: ${uri.toString(true)}`); + void window.showErrorMessage( + `Unable to ${contextMessage} with that link. Please try clicking the link again. If this issue persists, please contact support.`, + ); + return; + } + + void this.loginWithCode({ code: code, state: state ?? undefined }, { source: 'deeplink' }); + } + + async checkUpdatedSubscription(): Promise { + if (this._session == null) return undefined; + const oldSubscriptionState = this._subscription.state; + await this.checkInAndValidate(this._session, { force: true }); + if (oldSubscriptionState !== this._subscription.state) { + void this.showPlanMessage({ source: 'subscription' }); + } + + return this._subscription.state; + } +} + +type FlattenedSubscription = { + 'subscription.state'?: SubscriptionState; + 'subscription.status'?: + | 'verification' + | 'free' + | 'preview' + | 'preview-expired' + | 'trial' + | 'trial-expired' + | 'trial-reactivation-eligible' + | 'paid' + | 'unknown'; +} & Partial< + Record<`account.${string}`, string | number | boolean | undefined> & + Record<`subscription.${string}`, string | number | boolean | undefined> & + Record<`subscription.previewTrial.${string}`, string | number | boolean | undefined> & + Record<`previous.account.${string}`, string | number | boolean | undefined> & + Record<`previous.subscription.${string}`, string | number | boolean | undefined> & + Record<`previous.subscription.previewTrial.${string}`, string | number | boolean | undefined> +>; + +function flattenSubscription( + subscription: Optional | undefined, + prefix?: string, +): FlattenedSubscription { + if (subscription == null) return {}; + + return { + ...flatten(subscription.account, `${prefix ? `${prefix}.` : ''}account`, { + joinArrays: true, + skipPaths: ['name', 'email'], + }), + ...flatten(subscription.plan, `${prefix ? `${prefix}.` : ''}subscription`, { + skipPaths: ['actual.name', 'effective.name'], + }), + ...flatten(subscription.previewTrial, `${prefix ? `${prefix}.` : ''}subscription.previewTrial`, { + skipPaths: ['actual.name', 'effective.name'], + }), + 'subscription.state': subscription.state, + 'subscription.stateString': getSubscriptionStateString(subscription.state), + }; +} + +function getTrackingContextFromSource(source: Source | undefined): TrackingContext | undefined { + switch (source?.source) { + case 'graph': + return 'graph'; + case 'launchpad': + return 'launchpad'; + case 'timeline': + return 'visual_file_history'; + case 'git-commands': + if (source.detail != null && typeof source.detail !== 'string' && 'action' in source.detail) { + switch (source.detail.action) { + case 'worktree': + return 'worktrees'; + case 'launchpad': + return 'launchpad'; + } + } + break; + case 'worktrees': + return 'worktrees'; + } + + return undefined; +} diff --git a/src/plus/gk/checkin.ts b/src/plus/gk/checkin.ts new file mode 100644 index 0000000000000..0a9da2163a561 --- /dev/null +++ b/src/plus/gk/checkin.ts @@ -0,0 +1,261 @@ +import type { Organization } from './account/organization'; +import type { Subscription } from './account/subscription'; +import { getSubscriptionPlan, getSubscriptionPlanPriority, SubscriptionPlanId } from './account/subscription'; + +export interface GKCheckInResponse { + readonly user: GKUser; + readonly licenses: { + readonly paidLicenses: Record; + readonly effectiveLicenses: Record; + }; + readonly nextOptInDate?: string; +} + +export interface GKUser { + readonly id: string; + readonly name: string; + readonly email: string; + readonly status: 'activated' | 'pending'; + readonly createdDate: string; + readonly firstGitLensCheckIn?: string; +} + +export interface GKLicense { + readonly latestStatus: 'active' | 'canceled' | 'cancelled' | 'expired' | 'in_trial' | 'non_renewing' | 'trial'; + readonly latestStartDate: string; + readonly latestEndDate: string; + readonly organizationId: string | undefined; + readonly reactivationCount?: number; + readonly nextOptInDate?: string; +} + +export type GKLicenseType = + | 'gitlens-pro' + | 'gitlens-teams' + | 'gitlens-hosted-enterprise' + | 'gitlens-self-hosted-enterprise' + | 'gitlens-standalone-enterprise' + | 'bundle-pro' + | 'bundle-teams' + | 'bundle-hosted-enterprise' + | 'bundle-self-hosted-enterprise' + | 'bundle-standalone-enterprise' + | 'gitkraken_v1-pro' + | 'gitkraken_v1-teams' + | 'gitkraken_v1-hosted-enterprise' + | 'gitkraken_v1-self-hosted-enterprise' + | 'gitkraken_v1-standalone-enterprise' + | 'gitkraken-v1-pro' + | 'gitkraken-v1-teams' + | 'gitkraken-v1-hosted-enterprise' + | 'gitkraken-v1-self-hosted-enterprise' + | 'gitkraken-v1-standalone-enterprise'; + +export function getSubscriptionFromCheckIn( + data: GKCheckInResponse, + organizations: Organization[], + organizationId?: string, +): Omit { + const account: Subscription['account'] = { + id: data.user.id, + name: data.user.name, + email: data.user.email, + verified: data.user.status === 'activated', + createdOn: data.user.createdDate, + }; + + let effectiveLicenses = Object.entries(data.licenses.effectiveLicenses) as [GKLicenseType, GKLicense][]; + let paidLicenses = Object.entries(data.licenses.paidLicenses) as [GKLicenseType, GKLicense][]; + paidLicenses = paidLicenses.filter( + license => license[1].latestStatus !== 'expired' && license[1].latestStatus !== 'cancelled', + ); + if (paidLicenses.length > 1) { + paidLicenses.sort( + (a, b) => + getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + + licenseStatusPriority(b[1].latestStatus) - + (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + + licenseStatusPriority(a[1].latestStatus)), + ); + } + if (effectiveLicenses.length > 1) { + effectiveLicenses.sort( + (a, b) => + getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + + licenseStatusPriority(b[1].latestStatus) - + (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + + licenseStatusPriority(a[1].latestStatus)), + ); + } + + const effectiveLicensesByOrganizationId = new Map(); + const paidLicensesByOrganizationId = new Map(); + for (const licenseData of effectiveLicenses) { + const [, license] = licenseData; + if (license.organizationId == null) continue; + const existingLicense = effectiveLicensesByOrganizationId.get(license.organizationId); + if (existingLicense == null) { + effectiveLicensesByOrganizationId.set(license.organizationId, licenseData); + } + } + + for (const licenseData of paidLicenses) { + const [, license] = licenseData; + if (license.organizationId == null) continue; + const existingLicense = paidLicensesByOrganizationId.get(license.organizationId); + if (existingLicense == null) { + paidLicensesByOrganizationId.set(license.organizationId, licenseData); + } + } + + const organizationsWithNoLicense = organizations.filter( + organization => + !paidLicensesByOrganizationId.has(organization.id) && + !effectiveLicensesByOrganizationId.has(organization.id), + ); + + if (organizationId != null) { + paidLicenses = paidLicenses.filter( + ([, license]) => license.organizationId === organizationId || license.organizationId == null, + ); + effectiveLicenses = effectiveLicenses.filter( + ([, license]) => license.organizationId === organizationId || license.organizationId == null, + ); + } + + let actual: Subscription['plan']['actual'] | undefined; + const bestPaidLicense = paidLicenses.length > 0 ? paidLicenses[0] : undefined; + const bestEffectiveLicense = effectiveLicenses.length > 0 ? effectiveLicenses[0] : undefined; + const chosenPaidLicense = + organizationId != null ? paidLicensesByOrganizationId.get(organizationId) ?? bestPaidLicense : bestPaidLicense; + if (chosenPaidLicense != null) { + const [licenseType, license] = chosenPaidLicense; + actual = getSubscriptionPlan( + convertLicenseTypeToPlanId(licenseType), + isBundleLicenseType(licenseType), + license.reactivationCount ?? 0, + license.organizationId, + new Date(license.latestStartDate), + new Date(license.latestEndDate), + ); + } + + if (actual == null) { + actual = getSubscriptionPlan( + SubscriptionPlanId.FreePlus, + false, + 0, + undefined, + data.user.firstGitLensCheckIn != null + ? new Date(data.user.firstGitLensCheckIn) + : data.user.createdDate != null + ? new Date(data.user.createdDate) + : undefined, + undefined, + undefined, + data.nextOptInDate, + ); + } + + let effective: Subscription['plan']['effective'] | undefined; + const chosenEffectiveLicense = + organizationId != null + ? effectiveLicensesByOrganizationId.get(organizationId) ?? bestEffectiveLicense + : bestEffectiveLicense; + if (chosenEffectiveLicense != null) { + const [licenseType, license] = chosenEffectiveLicense; + effective = getSubscriptionPlan( + convertLicenseTypeToPlanId(licenseType), + isBundleLicenseType(licenseType), + license.reactivationCount ?? 0, + license.organizationId, + new Date(license.latestStartDate), + new Date(license.latestEndDate), + license.latestStatus === 'cancelled', + license.nextOptInDate ?? data.nextOptInDate, + ); + } + + if (effective == null || getSubscriptionPlanPriority(actual.id) >= getSubscriptionPlanPriority(effective.id)) { + effective = { ...actual }; + } + + let activeOrganization: Organization | undefined; + if (organizationId != null) { + activeOrganization = organizations.find(organization => organization.id === organizationId); + } else if (effective?.organizationId != null) { + activeOrganization = organizations.find(organization => organization.id === effective.organizationId); + } else if (organizationsWithNoLicense.length > 0) { + activeOrganization = organizationsWithNoLicense[0]; + } + + return { + plan: { + actual: actual, + effective: effective, + }, + account: account, + activeOrganization: activeOrganization, + }; +} + +function convertLicenseTypeToPlanId(licenseType: GKLicenseType): SubscriptionPlanId { + switch (licenseType) { + case 'gitlens-pro': + case 'bundle-pro': + case 'gitkraken_v1-pro': + case 'gitkraken-v1-pro': + return SubscriptionPlanId.Pro; + case 'gitlens-teams': + case 'bundle-teams': + case 'gitkraken_v1-teams': + case 'gitkraken-v1-teams': + return SubscriptionPlanId.Teams; + case 'gitlens-hosted-enterprise': + case 'gitlens-self-hosted-enterprise': + case 'gitlens-standalone-enterprise': + case 'bundle-hosted-enterprise': + case 'bundle-self-hosted-enterprise': + case 'bundle-standalone-enterprise': + case 'gitkraken_v1-hosted-enterprise': + case 'gitkraken_v1-self-hosted-enterprise': + case 'gitkraken_v1-standalone-enterprise': + case 'gitkraken-v1-hosted-enterprise': + case 'gitkraken-v1-self-hosted-enterprise': + case 'gitkraken-v1-standalone-enterprise': + return SubscriptionPlanId.Enterprise; + default: + return SubscriptionPlanId.FreePlus; + } +} + +function isBundleLicenseType(licenseType: GKLicenseType): boolean { + switch (licenseType) { + case 'bundle-pro': + case 'bundle-teams': + case 'bundle-hosted-enterprise': + case 'bundle-self-hosted-enterprise': + case 'bundle-standalone-enterprise': + return true; + default: + return false; + } +} + +function licenseStatusPriority(status: GKLicense['latestStatus']): number { + switch (status) { + case 'active': + return 100; + case 'expired': + case 'cancelled': + return -100; + case 'in_trial': + case 'trial': + return 1; + case 'canceled': + case 'non_renewing': + return 0; + default: + return -200; + } +} diff --git a/src/plus/gk/serverConnection.ts b/src/plus/gk/serverConnection.ts new file mode 100644 index 0000000000000..0f8c369e30bb9 --- /dev/null +++ b/src/plus/gk/serverConnection.ts @@ -0,0 +1,378 @@ +import type { HeadersInit, RequestInfo, RequestInit, Response } from '@env/fetch'; +import { fetch as _fetch, getProxyAgent } from '@env/fetch'; +import { getPlatform } from '@env/platform'; +import type { RequestError } from '@octokit/request-error'; +import type { CancellationToken } from 'vscode'; +import { version as codeVersion, env, Uri, window } from 'vscode'; +import type { Disposable } from '../../api/gitlens'; +import type { Container } from '../../container'; +import { + AuthenticationError, + AuthenticationErrorReason, + AuthenticationRequiredError, + CancellationError, + RequestClientError, + RequestGoneError, + RequestNotFoundError, + RequestRateLimitError, + RequestsAreBlockedTemporarilyError, + RequestUnprocessableEntityError, +} from '../../errors'; +import { + showGkDisconnectedTooManyFailedRequestsWarningMessage, + showGkRequestFailed500WarningMessage, + showGkRequestTimedOutWarningMessage, +} from '../../messages'; +import { memoize } from '../../system/decorators/memoize'; +import { Logger } from '../../system/logger'; +import type { LogScope } from '../../system/logger.scope'; +import { getLogScope } from '../../system/logger.scope'; + +interface FetchOptions { + cancellation?: CancellationToken; + timeout?: number; +} + +interface GKFetchOptions extends FetchOptions { + token?: string; + unAuthenticated?: boolean; + query?: string; + organizationId?: string | false; +} + +export class ServerConnection implements Disposable { + constructor(private readonly container: Container) {} + + dispose() {} + + @memoize() + private get baseApiUri(): Uri { + if (this.container.env === 'staging') { + return Uri.parse('https://stagingapi.gitkraken.com'); + } + + if (this.container.env === 'dev') { + return Uri.parse('https://devapi.gitkraken.com'); + } + + return Uri.parse('https://api.gitkraken.com'); + } + + getApiUrl(...pathSegments: string[]) { + return Uri.joinPath(this.baseApiUri, ...pathSegments).toString(); + } + + @memoize() + private get baseGkDevApiUri(): Uri { + if (this.container.env === 'staging') { + return Uri.parse('https://staging-api.gitkraken.dev'); + } + + if (this.container.env === 'dev') { + return Uri.parse('https://dev-api.gitkraken.dev'); + } + + return Uri.parse('https://api.gitkraken.dev'); + } + + getGkDevApiUrl(...pathSegments: string[]) { + return Uri.joinPath(this.baseGkDevApiUri, ...pathSegments).toString(); + } + + @memoize() + get userAgent(): string { + // TODO@eamodio figure out standardized format/structure for our user agents + return `${this.container.debugging ? 'GitLens-Debug' : this.container.prerelease ? 'GitLens-Pre' : 'GitLens'}/${ + this.container.version + } (${env.appName}/${codeVersion}; ${getPlatform()})`; + } + + @memoize() + get clientName(): string { + return this.container.debugging + ? 'gitlens-vsc-debug' + : this.container.prerelease + ? 'gitlens-vsc-pre' + : 'gitlens-vsc'; + } + + async fetch(url: RequestInfo, init?: RequestInit, options?: FetchOptions): Promise { + const scope = getLogScope(); + + if (options?.cancellation?.isCancellationRequested) throw new CancellationError(); + + const aborter = new AbortController(); + + let timeout; + if (options?.cancellation != null) { + timeout = options.timeout; // Don't set a default timeout if we have a cancellation token + options.cancellation.onCancellationRequested(() => aborter.abort()); + } else { + timeout = options?.timeout ?? 60 * 1000; + } + + const timer = timeout != null ? setTimeout(() => aborter.abort(), timeout) : undefined; + + try { + const promise = _fetch(url, { + agent: getProxyAgent(), + ...init, + headers: { + 'User-Agent': this.userAgent, + ...init?.headers, + }, + signal: aborter?.signal, + }); + void promise.finally(() => clearTimeout(timer)); + return await promise; + } catch (ex) { + Logger.error(ex, scope); + if (ex.name === 'AbortError') throw new CancellationError(ex); + + throw ex; + } + } + + async fetchApi(path: string, init?: RequestInit, options?: GKFetchOptions): Promise { + return this.gkFetch(this.getApiUrl(path), init, options); + } + + async fetchApiGraphQL(path: string, request: GraphQLRequest, init?: RequestInit, options?: GKFetchOptions) { + return this.fetchApi( + path, + { + method: 'POST', + ...init, + body: JSON.stringify(request), + }, + options, + ); + } + + async fetchGkDevApi(path: string, init?: RequestInit, options?: GKFetchOptions): Promise { + return this.gkFetch(this.getGkDevApiUrl(path), init, options); + } + + private async gkFetch(url: RequestInfo, init?: RequestInit, options?: GKFetchOptions): Promise { + if (this.requestsAreBlocked) { + throw new RequestsAreBlockedTemporarilyError(); + } + const scope = getLogScope(); + + try { + let token; + ({ token, ...options } = options ?? {}); + if (!options?.unAuthenticated) { + token ??= await this.getAccessToken(); + } + + const headers: Record = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Client-Name': this.clientName, + 'Client-Version': this.container.version, + ...init?.headers, + }; + + // only check for cached subscription or we'll get into an infinite loop + let organizationId = options?.organizationId; + if (organizationId === undefined) { + organizationId = (await this.container.subscription.getSubscription(true)).activeOrganization?.id; + } + + if (organizationId) { + headers['gk-org-id'] = organizationId; + } + + if (options?.query != null) { + if (url instanceof URL) { + url.search = options.query; + } else if (typeof url === 'string') { + url = `${url}?${options.query}`; + } + } + + const rsp = await this.fetch( + url, + { + ...init, + headers: headers as HeadersInit, + }, + options, + ); + if (!rsp.ok) { + await this.handleGkUnsuccessfulResponse(rsp, scope); + } else { + this.resetRequestExceptionCount(); + } + return rsp; + } catch (ex) { + this.handleGkRequestError('gitkraken', ex, scope); + throw ex; + } + } + + private buildRequestRateLimitError(token: string | undefined, ex: RequestError) { + let resetAt: number | undefined; + + const reset = ex.response?.headers?.['x-ratelimit-reset']; + if (reset != null) { + resetAt = parseInt(reset, 10); + if (Number.isNaN(resetAt)) { + resetAt = undefined; + } + } + return new RequestRateLimitError(ex, token, resetAt); + } + + private async handleGkUnsuccessfulResponse(rsp: Response, scope: LogScope | undefined): Promise { + let content; + switch (rsp.status) { + // Forbidden + case 403: + if (rsp.statusText.includes('rate limit')) { + this.trackRequestException(); + } + return; + // Too Many Requests + case 429: + this.trackRequestException(); + return; + // Internal Server Error + case 500: + this.trackRequestException(); + void showGkRequestFailed500WarningMessage( + 'GitKraken failed to respond and might be experiencing issues. Please visit the [GitKraken status page](https://cloud.gitkrakenstatus.com) for more information.', + ); + return; + // Bad Gateway + case 502: { + // Be sure to clone the response so we don't impact any upstream consumers + content = await rsp.clone().text(); + + Logger.error(undefined, scope, `GitKraken request failed: ${content} (${rsp.statusText})`); + if (content.includes('timeout')) { + this.trackRequestException(); + void showGkRequestTimedOutWarningMessage(); + } + return; + } + // Service Unavailable + case 503: { + // Be sure to clone the response so we don't impact any upstream consumers + content = await rsp.clone().text(); + + Logger.error(undefined, scope, `GitKraken request failed: ${content} (${rsp.statusText})`); + this.trackRequestException(); + void showGkRequestFailed500WarningMessage( + 'GitKraken failed to respond and might be experiencing issues. Please visit the [GitKraken status page](https://cloud.gitkrakenstatus.com) for more information.', + ); + return; + } + } + + if (rsp.status >= 400 && rsp.status < 500) return; + + if (Logger.isDebugging) { + // Be sure to clone the response so we don't impact any upstream consumers + content ??= await rsp.clone().text(); + void window.showErrorMessage(`DEBUGGING: GitKraken request failed: ${content} (${rsp.statusText})`); + } + } + + private handleGkRequestError( + token: string | undefined, + ex: RequestError | (Error & { name: 'AbortError' }), + scope: LogScope | undefined, + ): void { + if (ex instanceof CancellationError) throw ex; + if (ex.name === 'AbortError') throw new CancellationError(ex); + + switch (ex.status) { + case 404: // Not found + throw new RequestNotFoundError(ex); + case 410: // Gone + throw new RequestGoneError(ex); + case 422: // Unprocessable Entity + throw new RequestUnprocessableEntityError(ex); + case 401: // Unauthorized + throw new AuthenticationError('gitkraken', AuthenticationErrorReason.Unauthorized, ex); + case 429: //Too Many Requests + this.trackRequestException(); + throw this.buildRequestRateLimitError(token, ex); + case 403: // Forbidden + if (ex.message.includes('rate limit')) { + this.trackRequestException(); + throw this.buildRequestRateLimitError(token, ex); + } + throw new AuthenticationError('gitkraken', AuthenticationErrorReason.Forbidden, ex); + case 500: // Internal Server Error + Logger.error(ex, scope); + if (ex.response != null) { + this.trackRequestException(); + void showGkRequestFailed500WarningMessage( + 'GitKraken failed to respond and might be experiencing issues. Please visit the [GitKraken status page](https://cloud.gitkrakenstatus.com) for more information.', + ); + } + return; + case 502: // Bad Gateway + Logger.error(ex, scope); + if (ex.message.includes('timeout')) { + this.trackRequestException(); + void showGkRequestTimedOutWarningMessage(); + } + break; + case 503: // Service Unavailable + Logger.error(ex, scope); + this.trackRequestException(); + void showGkRequestFailed500WarningMessage( + 'GitKraken failed to respond and might be experiencing issues. Please visit the [GitKraken status page](https://cloud.gitkrakenstatus.com) for more information.', + ); + return; + default: + if (ex.status >= 400 && ex.status < 500) throw new RequestClientError(ex); + break; + } + + if (Logger.isDebugging) { + void window.showErrorMessage( + `DEBUGGING: GitKraken request failed: ${(ex.response as any)?.errors?.[0]?.message ?? ex.message}`, + ); + } + } + + private async getAccessToken() { + const session = await this.container.subscription.getAuthenticationSession(); + if (session != null) return session.accessToken; + + throw new AuthenticationRequiredError(); + } + + private requestExceptionCount = 0; + private requestsAreBlocked = false; + + resetRequestExceptionCount(): void { + this.requestExceptionCount = 0; + this.requestsAreBlocked = false; + } + + trackRequestException(): void { + this.requestExceptionCount++; + + if (this.requestExceptionCount >= 5 && !this.requestsAreBlocked) { + void showGkDisconnectedTooManyFailedRequestsWarningMessage(); + this.requestsAreBlocked = true; + this.requestExceptionCount = 0; + } + } +} + +export interface GraphQLRequest { + query: string; + operationName?: string; + variables?: Record; +} + +export function getUrl(base: Uri, ...pathSegments: string[]) { + return Uri.joinPath(base, ...pathSegments).toString(); +} diff --git a/src/plus/subscription/utils.ts b/src/plus/gk/utils.ts similarity index 64% rename from src/plus/subscription/utils.ts rename to src/plus/gk/utils.ts index 031bad8519740..77140b34e4686 100644 --- a/src/plus/subscription/utils.ts +++ b/src/plus/gk/utils.ts @@ -1,11 +1,10 @@ import type { MessageItem } from 'vscode'; import { window } from 'vscode'; -import { configuration } from '../../configuration'; -import { ContextKeys } from '../../constants'; -import { getContext } from '../../context'; +import { configuration } from '../../system/vscode/configuration'; +import { getContext } from '../../system/vscode/context'; export function arePlusFeaturesEnabled(): boolean { - return getContext(ContextKeys.PlusEnabled, configuration.get('plusFeatures.enabled', undefined, true)); + return getContext('gitlens:plus:enabled', configuration.get('plusFeatures.enabled', undefined, true)); } export async function ensurePlusFeaturesEnabled(): Promise { @@ -14,7 +13,7 @@ export async function ensurePlusFeaturesEnabled(): Promise { const confirm: MessageItem = { title: 'Enable' }; const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; const result = await window.showInformationMessage( - 'GitLens+ features are currently disabled. Would you like to enable them?', + 'Pro features are currently disabled. Would you like to enable them?', { modal: true }, confirm, cancel, diff --git a/src/plus/integrationAuthentication.ts b/src/plus/integrationAuthentication.ts deleted file mode 100644 index 880031aeb5098..0000000000000 --- a/src/plus/integrationAuthentication.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { AuthenticationSession, Disposable } from 'vscode'; -import type { Container } from '../container'; -import { debug } from '../system/decorators/log'; - -interface StoredSession { - id: string; - accessToken: string; - account?: { - label?: string; - displayName?: string; - id: string; - }; - scopes: string[]; -} - -export interface IntegrationAuthenticationSessionDescriptor { - domain: string; - scopes: string[]; - [key: string]: unknown; -} - -export interface IntegrationAuthenticationProvider { - getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string; - createSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise; -} - -export class IntegrationAuthenticationService implements Disposable { - private readonly providers = new Map(); - - constructor(private readonly container: Container) {} - - dispose() { - this.providers.clear(); - } - - registerProvider(providerId: string, provider: IntegrationAuthenticationProvider): Disposable { - if (this.providers.has(providerId)) throw new Error(`Provider with id ${providerId} already registered`); - - this.providers.set(providerId, provider); - return { - dispose: () => this.providers.delete(providerId), - }; - } - - hasProvider(providerId: string): boolean { - return this.providers.has(providerId); - } - - @debug() - async createSession( - providerId: string, - descriptor?: IntegrationAuthenticationSessionDescriptor, - ): Promise { - const provider = this.providers.get(providerId); - if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); - - const session = await provider?.createSession(descriptor); - if (session == null) return undefined; - - const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); - await this.container.storage.storeSecret(key, JSON.stringify(session)); - - return session; - } - - @debug() - async getSession( - providerId: string, - descriptor?: IntegrationAuthenticationSessionDescriptor, - options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, - ): Promise { - const provider = this.providers.get(providerId); - if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); - - const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); - - if (options?.forceNewSession) { - await this.container.storage.deleteSecret(key); - } - - let storedSession: StoredSession | undefined; - try { - const sessionJSON = await this.container.storage.getSecret(key); - if (sessionJSON) { - storedSession = JSON.parse(sessionJSON); - } - } catch (ex) { - try { - await this.container.storage.deleteSecret(key); - } catch {} - - if (!options?.createIfNeeded) { - throw ex; - } - } - - if (options?.createIfNeeded && storedSession == null) { - return this.createSession(providerId, descriptor); - } - - return storedSession as AuthenticationSession | undefined; - } - - @debug() - async deleteSession(providerId: string, descriptor?: IntegrationAuthenticationSessionDescriptor) { - const provider = this.providers.get(providerId); - if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); - - const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); - await this.container.storage.deleteSecret(key); - } - - private getSecretKey(providerId: string, id: string): `gitlens.integration.auth:${string}` { - return `gitlens.integration.auth:${providerId}|${id}`; - } -} diff --git a/src/plus/integrations/authentication/azureDevOps.ts b/src/plus/integrations/authentication/azureDevOps.ts new file mode 100644 index 0000000000000..5478a0819ff20 --- /dev/null +++ b/src/plus/integrations/authentication/azureDevOps.ts @@ -0,0 +1,121 @@ +import type { Disposable, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import { base64 } from '../../../system/string'; +import { HostingIntegrationId } from '../providers/models'; +import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthentication'; +import { LocalIntegrationAuthenticationProvider } from './integrationAuthentication'; +import type { ProviderAuthenticationSession } from './models'; + +export class AzureDevOpsAuthenticationProvider extends LocalIntegrationAuthenticationProvider { + protected override get authProviderId(): HostingIntegrationId.AzureDevOps { + return HostingIntegrationId.AzureDevOps; + } + + override async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + let azureOrganization: string | undefined = descriptor?.organization as string | undefined; + if (!azureOrganization) { + const orgInput = window.createInputBox(); + orgInput.ignoreFocusOut = true; + const orgInputDisposables: Disposable[] = []; + try { + azureOrganization = await new Promise(resolve => { + orgInputDisposables.push( + orgInput.onDidHide(() => resolve(undefined)), + orgInput.onDidChangeValue(() => (orgInput.validationMessage = undefined)), + orgInput.onDidAccept(() => { + const value = orgInput.value.trim(); + if (!value) { + orgInput.validationMessage = 'An organization is required'; + return; + } + + resolve(value); + }), + ); + + orgInput.title = `Azure DevOps Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + orgInput.placeholder = 'Organization'; + orgInput.prompt = 'Enter your Azure DevOps organization'; + orgInput.show(); + }); + } finally { + orgInput.dispose(); + orgInputDisposables.forEach(d => void d.dispose()); + } + } + + if (!azureOrganization) return undefined; + + const tokenInput = window.createInputBox(); + tokenInput.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let token; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the Azure DevOps Access Tokens Page', + }; + + token = await new Promise(resolve => { + disposables.push( + tokenInput.onDidHide(() => resolve(undefined)), + tokenInput.onDidChangeValue(() => (tokenInput.validationMessage = undefined)), + tokenInput.onDidAccept(() => { + const value = tokenInput.value.trim(); + if (!value) { + tokenInput.validationMessage = 'A personal access token is required'; + return; + } + + resolve(value); + }), + tokenInput.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse( + `https://${ + descriptor?.domain ?? 'dev.azure.com' + }/${azureOrganization}/_usersSettings/tokens`, + ), + ); + } + }), + ); + + tokenInput.password = true; + tokenInput.title = `Azure DevOps Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + tokenInput.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + tokenInput.prompt = `Paste your [Azure DevOps Personal Access Token](https://${ + descriptor?.domain ?? 'dev.azure.com' + }/${azureOrganization}/_usersSettings/tokens "Get your Azure DevOps Access Token")`; + tokenInput.buttons = [infoButton]; + + tokenInput.show(); + }); + } finally { + tokenInput.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!token) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: base64(`:${token}`), + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + cloud: false, + }; + } +} diff --git a/src/plus/integrations/authentication/bitbucket.ts b/src/plus/integrations/authentication/bitbucket.ts new file mode 100644 index 0000000000000..835b2624cbc08 --- /dev/null +++ b/src/plus/integrations/authentication/bitbucket.ts @@ -0,0 +1,133 @@ +import type { Disposable, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import { base64 } from '../../../system/string'; +import { HostingIntegrationId } from '../providers/models'; +import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthentication'; +import { LocalIntegrationAuthenticationProvider } from './integrationAuthentication'; +import type { ProviderAuthenticationSession } from './models'; + +export class BitbucketAuthenticationProvider extends LocalIntegrationAuthenticationProvider { + protected override get authProviderId(): HostingIntegrationId.Bitbucket { + return HostingIntegrationId.Bitbucket; + } + + override async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + let bitbucketUsername: string | undefined = descriptor?.username as string | undefined; + if (!bitbucketUsername) { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the Bitbucket Settings Page', + }; + + const usernameInput = window.createInputBox(); + usernameInput.ignoreFocusOut = true; + const usernameInputDisposables: Disposable[] = []; + try { + bitbucketUsername = await new Promise(resolve => { + usernameInputDisposables.push( + usernameInput.onDidHide(() => resolve(undefined)), + usernameInput.onDidChangeValue(() => (usernameInput.validationMessage = undefined)), + usernameInput.onDidAccept(() => { + const value = usernameInput.value.trim(); + if (!value) { + usernameInput.validationMessage = 'A Bitbucket username is required'; + return; + } + + resolve(value); + }), + usernameInput.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse(`https://${descriptor?.domain ?? 'bitbucket.org'}/account/settings/`), + ); + } + }), + ); + + usernameInput.title = `Bitbucket Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + usernameInput.placeholder = 'Username'; + usernameInput.prompt = `Enter your [Bitbucket Username](https://${ + descriptor?.domain ?? 'bitbucket.org' + }/account/settings/ "Get your Bitbucket App Password")`; + usernameInput.show(); + }); + } finally { + usernameInput.dispose(); + usernameInputDisposables.forEach(d => void d.dispose()); + } + } + + if (!bitbucketUsername) return undefined; + + const appPasswordInput = window.createInputBox(); + appPasswordInput.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let appPassword; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the Bitbucket App Passwords Page', + }; + + appPassword = await new Promise(resolve => { + disposables.push( + appPasswordInput.onDidHide(() => resolve(undefined)), + appPasswordInput.onDidChangeValue(() => (appPasswordInput.validationMessage = undefined)), + appPasswordInput.onDidAccept(() => { + const value = appPasswordInput.value.trim(); + if (!value) { + appPasswordInput.validationMessage = 'An app password is required'; + return; + } + + resolve(value); + }), + appPasswordInput.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse( + `https://${descriptor?.domain ?? 'bitbucket.org'}/account/settings/app-passwords/`, + ), + ); + } + }), + ); + + appPasswordInput.password = true; + appPasswordInput.title = `Bitbucket Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + appPasswordInput.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + appPasswordInput.prompt = `Paste your [Bitbucket App Password](https://${ + descriptor?.domain ?? 'bitbucket.org' + }/account/settings/app-passwords/ "Get your Bitbucket App Password")`; + appPasswordInput.buttons = [infoButton]; + + appPasswordInput.show(); + }); + } finally { + appPasswordInput.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!appPassword) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: base64(`${bitbucketUsername}:${appPassword}`), + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + cloud: false, + }; + } +} diff --git a/src/plus/integrations/authentication/cloudIntegrationService.ts b/src/plus/integrations/authentication/cloudIntegrationService.ts new file mode 100644 index 0000000000000..94df0c2d6350c --- /dev/null +++ b/src/plus/integrations/authentication/cloudIntegrationService.ts @@ -0,0 +1,93 @@ +import type { Container } from '../../../container'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; +import type { ServerConnection } from '../../gk/serverConnection'; +import type { IntegrationId } from '../providers/models'; +import type { CloudIntegrationAuthenticationSession, CloudIntegrationConnection } from './models'; +import { toCloudIntegrationType } from './models'; + +export class CloudIntegrationService { + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + async getConnections(): Promise { + const scope = getLogScope(); + + const providersRsp = await this.connection.fetchGkDevApi( + 'v1/provider-tokens', + { method: 'GET' }, + { organizationId: false }, + ); + if (!providersRsp.ok) { + const error = (await providersRsp.json())?.error; + const errorMessage = + typeof error === 'string' ? error : (error?.message as string) ?? providersRsp.statusText; + if (error != null) { + Logger.error(undefined, scope, `Failed to get connected providers from cloud: ${errorMessage}`); + } + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('cloudIntegrations/getConnections/failed', { + code: providersRsp.status, + }); + } + return undefined; + } + + return (await providersRsp.json())?.data as Promise; + } + + async getConnectionSession( + id: IntegrationId, + refreshToken?: string, + ): Promise { + const scope = getLogScope(); + + const refresh = Boolean(refreshToken); + const cloudIntegrationType = toCloudIntegrationType[id]; + if (cloudIntegrationType == null) { + Logger.error(undefined, scope, `Unsupported cloud integration type: ${id}`); + return undefined; + } + const reqInitOptions = refreshToken + ? { + method: 'POST', + body: JSON.stringify({ + access_token: refreshToken, + }), + } + : { method: 'GET' }; + + const tokenRsp = await this.connection.fetchGkDevApi( + `v1/provider-tokens/${cloudIntegrationType}${refresh ? '/refresh' : ''}`, + reqInitOptions, + { organizationId: false }, + ); + if (!tokenRsp.ok) { + const error = (await tokenRsp.json())?.error; + const errorMessage = typeof error === 'string' ? error : (error?.message as string) ?? tokenRsp.statusText; + if (error != null) { + Logger.error( + undefined, + scope, + `Failed to ${refresh ? 'refresh' : 'get'} ${id} token from cloud: ${errorMessage}`, + ); + } + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + refreshToken + ? 'cloudIntegrations/refreshConnection/failed' + : 'cloudIntegrations/getConnection/failed', + { + code: tokenRsp.status, + 'integration.id': id, + }, + ); + } + return undefined; + } + + return (await tokenRsp.json())?.data as Promise; + } +} diff --git a/src/plus/integrations/authentication/github.ts b/src/plus/integrations/authentication/github.ts new file mode 100644 index 0000000000000..793247b445aca --- /dev/null +++ b/src/plus/integrations/authentication/github.ts @@ -0,0 +1,143 @@ +import { wrapForForcedInsecureSSL } from '@env/fetch'; +import type { Disposable, QuickInputButton } from 'vscode'; +import { authentication, env, ThemeIcon, Uri, window } from 'vscode'; +import type { Sources } from '../../../constants.telemetry'; +import type { Container } from '../../../container'; +import { HostingIntegrationId, SelfHostedIntegrationId } from '../providers/models'; +import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthentication'; +import { + CloudIntegrationAuthenticationProvider, + LocalIntegrationAuthenticationProvider, +} from './integrationAuthentication'; +import type { ProviderAuthenticationSession } from './models'; + +export class GitHubAuthenticationProvider extends CloudIntegrationAuthenticationProvider { + constructor(container: Container) { + super(container); + this.disposables.push( + authentication.onDidChangeSessions(e => { + if (e.provider.id === this.authProviderId) { + this.fireDidChange(); + } + }), + ); + } + + protected override get authProviderId(): HostingIntegrationId.GitHub { + return HostingIntegrationId.GitHub; + } + + private async getBuiltInExistingSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + forceNewSession?: boolean, + ): Promise { + if (descriptor == null) return undefined; + + return wrapForForcedInsecureSSL( + this.container.integrations.ignoreSSLErrors({ id: this.authProviderId, domain: descriptor?.domain }), + async () => { + const session = await authentication.getSession(this.authProviderId, descriptor.scopes, { + forceNewSession: forceNewSession ? true : undefined, + silent: forceNewSession ? undefined : true, + }); + if (session == null) return undefined; + return { + ...session, + cloud: false, + }; + }, + ); + } + + public override async getSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean; source?: Sources }, + ): Promise { + let vscodeSession = await this.getBuiltInExistingSession(descriptor); + + if (vscodeSession != null && options?.forceNewSession) { + vscodeSession = await this.getBuiltInExistingSession(descriptor, true); + } + + if (vscodeSession != null) return vscodeSession; + + return super.getSession(descriptor, options); + } + + protected override getCompletionInputTitle(): string { + return 'Connect to GitHub'; + } +} + +export class GitHubEnterpriseAuthenticationProvider extends LocalIntegrationAuthenticationProvider { + protected override get authProviderId(): SelfHostedIntegrationId.GitHubEnterprise { + return SelfHostedIntegrationId.GitHubEnterprise; + } + + override async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let token; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the GitHub Access Tokens Page', + }; + + token = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(() => (input.validationMessage = undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = 'A personal access token is required'; + return; + } + + resolve(value); + }), + input.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse(`https://${descriptor?.domain ?? 'github.com'}/settings/tokens`), + ); + } + }), + ); + + input.password = true; + input.title = `GitHub Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; + input.placeholder = `Requires a classic token with ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + input.prompt = `Paste your [GitHub Personal Access Token](https://${ + descriptor?.domain ?? 'github.com' + }/settings/tokens "Get your GitHub Access Token")`; + + input.buttons = [infoButton]; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!token) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: token, + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + cloud: false, + }; + } +} diff --git a/src/plus/integrations/authentication/gitlab.ts b/src/plus/integrations/authentication/gitlab.ts new file mode 100644 index 0000000000000..9a1801bce40b5 --- /dev/null +++ b/src/plus/integrations/authentication/gitlab.ts @@ -0,0 +1,102 @@ +import type { Disposable, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import type { Container } from '../../../container'; +import type { SelfHostedIntegrationId } from '../providers/models'; +import { HostingIntegrationId } from '../providers/models'; +import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthentication'; +import { + CloudIntegrationAuthenticationProvider, + LocalIntegrationAuthenticationProvider, +} from './integrationAuthentication'; +import type { ProviderAuthenticationSession } from './models'; + +type GitLabId = HostingIntegrationId.GitLab | SelfHostedIntegrationId.GitLabSelfHosted; + +export class GitLabLocalAuthenticationProvider extends LocalIntegrationAuthenticationProvider { + constructor( + container: Container, + protected readonly authProviderId: GitLabId, + ) { + super(container); + } + + override async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let token; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the GitLab Access Tokens Page', + }; + + token = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(() => (input.validationMessage = undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = 'A personal access token is required'; + return; + } + + resolve(value); + }), + input.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse( + `https://${descriptor?.domain ?? 'gitlab.com'}/-/profile/personal_access_tokens`, + ), + ); + } + }), + ); + + input.password = true; + input.title = `GitLab Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; + input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + input.prompt = `Paste your [GitLab Personal Access Token](https://${ + descriptor?.domain ?? 'gitlab.com' + }/-/user_settings/personal_access_tokens?name=GitLens+Access+token&scopes=${ + descriptor?.scopes.join(',') ?? 'all' + } "Get your GitLab Access Token")`; + input.buttons = [infoButton]; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!token) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: token, + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + cloud: false, + }; + } +} + +export class GitLabCloudAuthenticationProvider extends CloudIntegrationAuthenticationProvider { + protected override get authProviderId(): GitLabId { + return HostingIntegrationId.GitLab; + } + + protected override getCompletionInputTitle(): string { + return 'Connect to GitLab'; + } +} diff --git a/src/plus/integrations/authentication/integrationAuthentication.ts b/src/plus/integrations/authentication/integrationAuthentication.ts new file mode 100644 index 0000000000000..a235a982444d0 --- /dev/null +++ b/src/plus/integrations/authentication/integrationAuthentication.ts @@ -0,0 +1,561 @@ +import { wrapForForcedInsecureSSL } from '@env/fetch'; +import type { CancellationToken, Disposable, Event, Uri } from 'vscode'; +import { authentication, EventEmitter, window } from 'vscode'; +import type { IntegrationAuthenticationKeys } from '../../../constants.storage'; +import type { Sources } from '../../../constants.telemetry'; +import type { Container } from '../../../container'; +import { gate } from '../../../system/decorators/gate'; +import { debug, log } from '../../../system/decorators/log'; +import type { DeferredEventExecutor } from '../../../system/event'; +import type { IntegrationId } from '../providers/models'; +import { + HostingIntegrationId, + IssueIntegrationId, + SelfHostedIntegrationId, + supportedIntegrationIds, +} from '../providers/models'; +import type { ProviderAuthenticationSession } from './models'; +import { isSupportedCloudIntegrationId } from './models'; + +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) + +interface StoredSession { + id: string; + accessToken: string; + account?: { + label?: string; + displayName?: string; + id: string; + }; + scopes: string[]; + cloud?: boolean; + expiresAt?: string; +} + +export interface IntegrationAuthenticationProviderDescriptor { + id: IntegrationId; + scopes: string[]; +} + +export interface IntegrationAuthenticationSessionDescriptor { + domain: string; + scopes: string[]; + [key: string]: unknown; +} + +export interface IntegrationAuthenticationProvider extends Disposable { + deleteSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise; + getSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean; source?: Sources }, + ): Promise; + get onDidChange(): Event; +} + +abstract class IntegrationAuthenticationProviderBase + implements IntegrationAuthenticationProvider +{ + protected readonly disposables: Disposable[] = []; + + constructor(protected readonly container: Container) {} + + dispose() { + this.disposables.forEach(d => void d.dispose()); + } + + private readonly _onDidChange = new EventEmitter(); + get onDidChange(): Event { + return this._onDidChange.event; + } + + protected abstract get authProviderId(): ID; + + protected abstract fetchOrCreateSession( + storedSession: ProviderAuthenticationSession | undefined, + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean; refreshIfExpired?: boolean; source?: Sources }, + ): Promise; + + protected abstract deleteAllSecrets(sessionId: string): Promise; + + protected abstract storeSession(sessionId: string, session: ProviderAuthenticationSession): Promise; + + protected abstract restoreSession(sessionId: string): Promise; + + protected async deleteSecret(key: IntegrationAuthenticationKeys) { + await this.container.storage.deleteSecret(key); + } + + protected async writeSecret( + key: IntegrationAuthenticationKeys, + session: ProviderAuthenticationSession | StoredSession, + ) { + await this.container.storage.storeSecret(key, JSON.stringify(session)); + } + + protected async readSecret(key: IntegrationAuthenticationKeys): Promise { + let storedSession: StoredSession | undefined; + try { + const sessionJSON = await this.container.storage.getSecret(key); + if (sessionJSON) { + storedSession = JSON.parse(sessionJSON); + } + } catch (_ex) { + try { + await this.deleteSecret(key); + } catch {} + } + return storedSession; + } + + protected getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { + return descriptor?.domain ?? ''; + } + + protected getLocalSecretKey(id: string): `gitlens.integration.auth:${IntegrationId}|${string}` { + return `gitlens.integration.auth:${this.authProviderId}|${id}`; + } + + @debug() + async deleteSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise { + const sessionId = this.getSessionId(descriptor); + const storedSession = await this.restoreSession(sessionId); + await this.deleteAllSecrets(sessionId); + if (storedSession != null) { + this.fireDidChange(); + } + } + + @debug() + async getSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean; source?: Sources }, + ): Promise { + const sessionId = this.getSessionId(descriptor); + + const storedSession = await this.restoreSession(sessionId); + + let session = storedSession; + if (options?.forceNewSession) { + session = undefined; + await this.deleteAllSecrets(sessionId); + } + + const isExpiredSession = session?.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now(); + if (session == null || isExpiredSession) { + session = await this.fetchOrCreateSession(storedSession, descriptor, { + ...options, + refreshIfExpired: isExpiredSession, + }); + + if (session != null) { + await this.storeSession(sessionId, session); + } + } + + this.fireIfChanged(storedSession, session); + return session; + } + + protected fireIfChanged( + storedSession: ProviderAuthenticationSession | undefined, + newSession: ProviderAuthenticationSession | undefined, + ) { + if (storedSession?.accessToken === newSession?.accessToken) return; + + queueMicrotask(() => this._onDidChange.fire()); + } + protected fireDidChange() { + queueMicrotask(() => this._onDidChange.fire()); + } +} + +export abstract class LocalIntegrationAuthenticationProvider< + ID extends IntegrationId = IntegrationId, +> extends IntegrationAuthenticationProviderBase { + protected override async deleteAllSecrets(sessionId: string) { + await this.deleteSecret(this.getLocalSecretKey(sessionId)); + } + + protected override async storeSession(sessionId: string, session: ProviderAuthenticationSession) { + await this.writeSecret(this.getLocalSecretKey(sessionId), session); + } + + protected override async restoreSession(sessionId: string): Promise { + const key = this.getLocalSecretKey(sessionId); + return convertStoredSessionToSession(await this.readSecret(key), false); + } + + protected abstract createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise; + + protected override async fetchOrCreateSession( + storedSession: ProviderAuthenticationSession | undefined, + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean; source?: Sources }, + ) { + if (!options?.createIfNeeded && !options?.forceNewSession) return storedSession; + + return this.createSession(descriptor); + } +} + +export abstract class CloudIntegrationAuthenticationProvider< + ID extends IntegrationId = IntegrationId, +> extends IntegrationAuthenticationProviderBase { + private getCloudSecretKey(id: string): `gitlens.integration.auth.cloud:${IntegrationId}|${string}` { + return `gitlens.integration.auth.cloud:${this.authProviderId}|${id}`; + } + + protected override async deleteAllSecrets(sessionId: string) { + await Promise.allSettled([ + this.deleteSecret(this.getLocalSecretKey(sessionId)), + this.deleteSecret(this.getCloudSecretKey(sessionId)), + ]); + } + + protected override async storeSession(sessionId: string, session: ProviderAuthenticationSession) { + await this.writeSecret(this.getCloudSecretKey(sessionId), session); + } + + /** + * This method gets the session from the storage and returns it. + * Howewer, if a cloud session is stored with a local key, it will be renamed and saved in the storage with the cloud key. + */ + protected override async restoreSession(sessionId: string): Promise { + let cloudIfMissing = false; + // At first we try to restore a token with the local key + let session = await this.readSecret(this.getLocalSecretKey(sessionId)); + if (session != null) { + // Check the `expiresAt` field + // If it has an expiresAt property and the key is the old type, then it's a cloud session, + // so delete it from the local key and + // store with the "cloud" type key, and then use that one. + // Otherwise it's a local session under the local key, so just return it. + if (session.expiresAt != null) { + cloudIfMissing = true; + await Promise.allSettled([ + this.deleteSecret(this.getLocalSecretKey(sessionId)), + this.writeSecret(this.getCloudSecretKey(sessionId), session), + ]); + } + } + + // If no local session we try to restore a session with the cloud key + if (session == null) { + cloudIfMissing = true; + session = await this.readSecret(this.getCloudSecretKey(sessionId)); + } + + return convertStoredSessionToSession(session, cloudIfMissing); + } + + protected override async fetchOrCreateSession( + storedSession: ProviderAuthenticationSession | undefined, + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean; refreshIfExpired?: boolean; source?: Sources }, + ): Promise { + // TODO: This is a stopgap to make sure we're not hammering the api on automatic calls to get the session. + // Ultimately we want to timestamp calls to syncCloudIntegrations and use that to determine whether we should + // make the call or not. + let session = + options?.refreshIfExpired || options?.createIfNeeded || options?.forceNewSession + ? await this.fetchSession(descriptor) + : undefined; + + if (shouldForceNewSession(storedSession, session, options)) { + // TODO: gk.dev doesn't yet support forcing a new session, so the user will need to disconnect and reconnect + void this.connectCloudIntegration(false, options?.source); + } else if (shouldCreateSession(session, options)) { + const connected = await this.connectCloudIntegration(true, options?.source); + if (!connected) return undefined; + session = await this.getSession(descriptor, { source: options?.source }); + } + return session; + } + + private async connectCloudIntegration(skipIfConnected: boolean, source: Sources | undefined): Promise { + if (isSupportedCloudIntegrationId(this.authProviderId)) { + return this.container.integrations.connectCloudIntegrations( + { integrationIds: [this.authProviderId], skipIfConnected: skipIfConnected, skipPreSync: true }, + { + source: source ?? 'integrations', + detail: { + action: 'connect', + integration: this.authProviderId, + }, + }, + ); + } + + return false; + } + + private async fetchSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + const loggedIn = await this.container.subscription.getAuthenticationSession(false); + if (!loggedIn) return undefined; + + const cloudIntegrations = await this.container.cloudIntegrations; + if (cloudIntegrations == null) return undefined; + + let session = await cloudIntegrations.getConnectionSession(this.authProviderId); + + // Make an exception for GitHub because they always return 0 + if (session?.expiresIn === 0 && this.authProviderId === HostingIntegrationId.GitHub) { + // It never expires so don't refresh it frequently: + session.expiresIn = maxSmallIntegerV8; // maximum expiration length + } + + if (session != null && session.expiresIn < 60) { + session = await cloudIntegrations.getConnectionSession(this.authProviderId, session.accessToken); + } + + if (!session) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: session.accessToken, + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + cloud: true, + expiresAt: new Date(session.expiresIn * 1000 + Date.now()), + }; + } + + private async openCompletionInput(cancellationToken: CancellationToken) { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + try { + if (cancellationToken.isCancellationRequested) return; + + await new Promise(resolve => { + disposables.push( + cancellationToken.onCancellationRequested(() => input.hide()), + input.onDidHide(() => resolve(undefined)), + input.onDidAccept(() => resolve(undefined)), + ); + + input.title = this.getCompletionInputTitle(); + input.placeholder = 'Please enter the provided authorization code'; + input.prompt = ''; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + } + + protected abstract getCompletionInputTitle(): string; + + private getUriHandlerDeferredExecutor(): DeferredEventExecutor { + return (uri: Uri, resolve, reject) => { + const queryParams: URLSearchParams = new URLSearchParams(uri.query); + const provider = queryParams.get('provider'); + if (provider !== this.authProviderId) { + reject('Invalid provider'); + return; + } + + resolve(uri.toString(true)); + }; + } +} + +class BuiltInAuthenticationProvider extends LocalIntegrationAuthenticationProvider { + constructor( + container: Container, + protected readonly authProviderId: IntegrationId, + ) { + super(container); + this.disposables.push( + authentication.onDidChangeSessions(e => { + if (e.provider.id === this.authProviderId) { + this.fireDidChange(); + } + }), + ); + } + + protected override createSession(): Promise { + throw new Error('Method `createSession` should never be used in BuiltInAuthenticationProvider'); + } + + @debug() + override async getSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, + ): Promise { + if (descriptor == null) return undefined; + + const { createIfNeeded, forceNewSession } = options ?? {}; + return wrapForForcedInsecureSSL( + this.container.integrations.ignoreSSLErrors({ id: this.authProviderId, domain: descriptor?.domain }), + async () => { + const session = await authentication.getSession(this.authProviderId, descriptor.scopes, { + createIfNone: forceNewSession ? undefined : createIfNeeded, + silent: !createIfNeeded && !forceNewSession ? true : undefined, + forceNewSession: forceNewSession ? true : undefined, + }); + if (session == null) return undefined; + + return { + ...session, + cloud: false, + }; + }, + ); + } +} + +export class IntegrationAuthenticationService implements Disposable { + private readonly providers = new Map(); + + constructor(private readonly container: Container) {} + + dispose() { + this.providers.forEach(p => void p.dispose()); + this.providers.clear(); + } + + async get(providerId: IntegrationId): Promise { + return this.ensureProvider(providerId); + } + + @log() + async reset() { + // TODO: This really isn't ideal, since it will only work for "cloud" providers as we won't have any more specific descriptors + await Promise.allSettled( + supportedIntegrationIds.map(async providerId => (await this.ensureProvider(providerId)).deleteSession()), + ); + } + + supports(providerId: string): boolean { + switch (providerId) { + case HostingIntegrationId.AzureDevOps: + case HostingIntegrationId.Bitbucket: + case SelfHostedIntegrationId.GitHubEnterprise: + case HostingIntegrationId.GitLab: + case SelfHostedIntegrationId.GitLabSelfHosted: + case IssueIntegrationId.Jira: + return true; + case HostingIntegrationId.GitHub: + return isSupportedCloudIntegrationId(HostingIntegrationId.GitHub); + default: + return false; + } + } + + @gate() + private async ensureProvider(providerId: IntegrationId): Promise { + let provider = this.providers.get(providerId); + if (provider == null) { + switch (providerId) { + case HostingIntegrationId.AzureDevOps: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './azureDevOps') + ).AzureDevOpsAuthenticationProvider(this.container); + break; + case HostingIntegrationId.Bitbucket: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './bitbucket') + ).BitbucketAuthenticationProvider(this.container); + break; + case HostingIntegrationId.GitHub: + provider = isSupportedCloudIntegrationId(HostingIntegrationId.GitHub) + ? new ( + await import(/* webpackChunkName: "integrations" */ './github') + ).GitHubAuthenticationProvider(this.container) + : new BuiltInAuthenticationProvider(this.container, providerId); + + break; + case SelfHostedIntegrationId.GitHubEnterprise: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './github') + ).GitHubEnterpriseAuthenticationProvider(this.container); + break; + case HostingIntegrationId.GitLab: + provider = isSupportedCloudIntegrationId(HostingIntegrationId.GitLab) + ? new ( + await import(/* webpackChunkName: "integrations" */ './gitlab') + ).GitLabCloudAuthenticationProvider(this.container) + : new ( + await import(/* webpackChunkName: "integrations" */ './gitlab') + ).GitLabLocalAuthenticationProvider(this.container, HostingIntegrationId.GitLab); + break; + case SelfHostedIntegrationId.GitLabSelfHosted: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './gitlab') + ).GitLabLocalAuthenticationProvider(this.container, SelfHostedIntegrationId.GitLabSelfHosted); + break; + case IssueIntegrationId.Jira: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './jira') + ).JiraAuthenticationProvider(this.container); + break; + default: + provider = new BuiltInAuthenticationProvider(this.container, providerId); + } + this.providers.set(providerId, provider); + } + + return provider; + } +} + +function convertStoredSessionToSession( + storedSession: StoredSession, + cloudIfMissing: boolean, +): ProviderAuthenticationSession; +function convertStoredSessionToSession( + storedSession: StoredSession | undefined, + cloudIfMissing: boolean, +): ProviderAuthenticationSession | undefined; +function convertStoredSessionToSession( + storedSession: StoredSession | undefined, + cloudIfMissing: boolean, +): ProviderAuthenticationSession | undefined { + if (storedSession == null) return undefined; + + return { + id: storedSession.id, + accessToken: storedSession.accessToken, + account: { + id: storedSession.account?.id ?? '', + label: storedSession.account?.label ?? '', + }, + scopes: storedSession.scopes, + cloud: storedSession.cloud ?? cloudIfMissing, + expiresAt: storedSession.expiresAt ? new Date(storedSession.expiresAt) : undefined, + }; +} + +function shouldForceNewSession( + storedSession: ProviderAuthenticationSession | undefined, + newSession: ProviderAuthenticationSession | undefined, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, +) { + return ( + options?.forceNewSession && + storedSession != null && + newSession != null && + storedSession.accessToken === newSession.accessToken + ); +} + +function shouldCreateSession( + storedSession: ProviderAuthenticationSession | undefined, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, +) { + return options?.createIfNeeded && storedSession == null; +} diff --git a/src/plus/integrations/authentication/jira.ts b/src/plus/integrations/authentication/jira.ts new file mode 100644 index 0000000000000..436f9a3264f75 --- /dev/null +++ b/src/plus/integrations/authentication/jira.ts @@ -0,0 +1,12 @@ +import { IssueIntegrationId } from '../providers/models'; +import { CloudIntegrationAuthenticationProvider } from './integrationAuthentication'; + +export class JiraAuthenticationProvider extends CloudIntegrationAuthenticationProvider { + protected override get authProviderId(): IssueIntegrationId.Jira { + return IssueIntegrationId.Jira; + } + + protected override getCompletionInputTitle(): string { + return 'Connect to Jira'; + } +} diff --git a/src/plus/integrations/authentication/models.ts b/src/plus/integrations/authentication/models.ts new file mode 100644 index 0000000000000..bf204d7b8e81e --- /dev/null +++ b/src/plus/integrations/authentication/models.ts @@ -0,0 +1,72 @@ +import type { AuthenticationSession } from 'vscode'; +import { configuration } from '../../../system/vscode/configuration'; +import type { IntegrationId } from '../providers/models'; +import { HostingIntegrationId, IssueIntegrationId, SelfHostedIntegrationId } from '../providers/models'; + +export interface ProviderAuthenticationSession extends AuthenticationSession { + readonly cloud: boolean; + readonly expiresAt?: Date; +} + +export interface CloudIntegrationAuthenticationSession { + type: CloudIntegrationAuthType; + accessToken: string; + domain: string; + expiresIn: number; + scopes: string; +} + +export interface CloudIntegrationAuthorization { + url: string; +} + +export interface CloudIntegrationConnection { + type: CloudIntegrationAuthType; + provider: CloudIntegrationType; + domain: string; +} + +export type CloudIntegrationType = 'jira' | 'trello' | 'gitlab' | 'github' | 'bitbucket' | 'azure'; + +export type CloudIntegrationAuthType = 'oauth' | 'pat'; + +export const CloudIntegrationAuthenticationUriPathPrefix = 'did-authenticate-cloud-integration'; + +const supportedCloudIntegrationIds = [IssueIntegrationId.Jira]; +const supportedCloudIntegrationIdsExperimental = [ + IssueIntegrationId.Jira, + HostingIntegrationId.GitHub, + HostingIntegrationId.GitLab, +]; + +export type SupportedCloudIntegrationIds = (typeof supportedCloudIntegrationIdsExperimental)[number]; + +export function getSupportedCloudIntegrationIds(): SupportedCloudIntegrationIds[] { + return configuration.get('cloudIntegrations.enabled', undefined, true) + ? supportedCloudIntegrationIdsExperimental + : supportedCloudIntegrationIds; +} + +export function isSupportedCloudIntegrationId(id: string): id is SupportedCloudIntegrationIds { + return getSupportedCloudIntegrationIds().includes(id as SupportedCloudIntegrationIds); +} + +export const toIntegrationId: { [key in CloudIntegrationType]: IntegrationId } = { + jira: IssueIntegrationId.Jira, + trello: IssueIntegrationId.Trello, + gitlab: HostingIntegrationId.GitLab, + github: HostingIntegrationId.GitHub, + bitbucket: HostingIntegrationId.Bitbucket, + azure: HostingIntegrationId.AzureDevOps, +}; + +export const toCloudIntegrationType: { [key in IntegrationId]: CloudIntegrationType | undefined } = { + [IssueIntegrationId.Jira]: 'jira', + [IssueIntegrationId.Trello]: 'trello', + [HostingIntegrationId.GitLab]: 'gitlab', + [HostingIntegrationId.GitHub]: 'github', + [HostingIntegrationId.Bitbucket]: 'bitbucket', + [HostingIntegrationId.AzureDevOps]: 'azure', + [SelfHostedIntegrationId.GitHubEnterprise]: undefined, + [SelfHostedIntegrationId.GitLabSelfHosted]: undefined, +}; diff --git a/src/plus/integrations/integration.ts b/src/plus/integrations/integration.ts new file mode 100644 index 0000000000000..136fd252e5900 --- /dev/null +++ b/src/plus/integrations/integration.ts @@ -0,0 +1,1274 @@ +import type { CancellationToken, Event, MessageItem } from 'vscode'; +import { EventEmitter, window } from 'vscode'; +import type { DynamicAutolinkReference } from '../../annotations/autolinks'; +import type { AutolinkReference } from '../../config'; +import type { Sources } from '../../constants.telemetry'; +import type { Container } from '../../container'; +import { AuthenticationError, CancellationError, RequestClientError } from '../../errors'; +import type { PagedResult } from '../../git/gitProvider'; +import type { Account, UnidentifiedAuthor } from '../../git/models/author'; +import type { DefaultBranch } from '../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../git/models/issue'; +import type { + PullRequest, + PullRequestMergeMethod, + PullRequestState, + SearchedPullRequest, +} from '../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../git/models/repositoryMetadata'; +import { showIntegrationDisconnectedTooManyFailedRequestsWarningMessage } from '../../messages'; +import { gate } from '../../system/decorators/gate'; +import { debug, log } from '../../system/decorators/log'; +import { first } from '../../system/iterable'; +import { Logger } from '../../system/logger'; +import type { LogScope } from '../../system/logger.scope'; +import { getLogScope } from '../../system/logger.scope'; +import { configuration } from '../../system/vscode/configuration'; +import type { + IntegrationAuthenticationProviderDescriptor, + IntegrationAuthenticationService, + IntegrationAuthenticationSessionDescriptor, +} from './authentication/integrationAuthentication'; +import type { ProviderAuthenticationSession } from './authentication/models'; +import type { + GetIssuesOptions, + GetPullRequestsOptions, + IntegrationId, + IssueIntegrationId, + PagedProjectInput, + PagedRepoInput, + ProviderAccount, + ProviderIssue, + ProviderPullRequest, + ProviderRepoInput, + ProviderReposInput, + SelfHostedIntegrationId, +} from './providers/models'; +import { HostingIntegrationId, IssueFilter, PagingMode, PullRequestFilter } from './providers/models'; +import type { ProvidersApi } from './providers/providersApi'; + +export type IntegrationResult = + | { value: T; duration?: number; error?: never } + | { error: Error; duration?: number; value?: never } + | undefined; + +export type SupportedIntegrationIds = IntegrationId; +export type SupportedHostingIntegrationIds = HostingIntegrationId; +export type SupportedIssueIntegrationIds = IssueIntegrationId; +export type SupportedSelfHostedIntegrationIds = SelfHostedIntegrationId; + +export type Integration = IssueIntegration | HostingIntegration; +export type IntegrationKey = + | `${SupportedHostingIntegrationIds}` + | `${SupportedIssueIntegrationIds}` + | `${SupportedSelfHostedIntegrationIds}:${string}`; +export type IntegrationKeyById = T extends SupportedIssueIntegrationIds + ? `${SupportedIssueIntegrationIds}` + : T extends SupportedHostingIntegrationIds + ? `${SupportedHostingIntegrationIds}` + : `${SupportedSelfHostedIntegrationIds}:${string}`; +export type IntegrationType = 'issues' | 'hosting'; + +export type ResourceDescriptor = { key: string } & Record; + +export function isHostingIntegration(integration: Integration): integration is HostingIntegration { + return integration.type === 'hosting'; +} +export function isIssueIntegration(integration: Integration): integration is IssueIntegration { + return integration.type === 'issues'; +} + +export abstract class IntegrationBase< + ID extends SupportedIntegrationIds = SupportedIntegrationIds, + T extends ResourceDescriptor = ResourceDescriptor, +> { + abstract readonly type: IntegrationType; + + private readonly _onDidChange = new EventEmitter(); + get onDidChange(): Event { + return this._onDidChange.event; + } + + constructor( + protected readonly container: Container, + protected readonly authenticationService: IntegrationAuthenticationService, + protected readonly getProvidersApi: () => Promise, + ) {} + + abstract get authProvider(): IntegrationAuthenticationProviderDescriptor; + abstract get id(): ID; + protected abstract get key(): IntegrationKeyById; + abstract get name(): string; + abstract get domain(): string; + + get authProviderDescriptor(): IntegrationAuthenticationSessionDescriptor { + return { domain: this.domain, scopes: this.authProvider.scopes }; + } + + get icon(): string { + return this.id; + } + + autolinks(): + | (AutolinkReference | DynamicAutolinkReference)[] + | Promise<(AutolinkReference | DynamicAutolinkReference)[]> { + return []; + } + + private get connectedKey(): `connected:${Integration['key']}` { + return `connected:${this.key}`; + } + + get maybeConnected(): boolean | undefined { + return this._session === undefined ? undefined : this._session !== null; + } + + get connectionExpired(): boolean | undefined { + if (this._session?.expiresAt == null) return undefined; + return new Date(this._session.expiresAt) < new Date(); + } + + protected _session: ProviderAuthenticationSession | null | undefined; + getSession(source: Sources) { + if (this._session === undefined) { + return this.ensureSession({ createIfNeeded: false, source: source }); + } + return this._session ?? undefined; + } + + @log() + async connect(source: Sources): Promise { + try { + return Boolean(await this.ensureSession({ createIfNeeded: true, source: source })); + } catch (_ex) { + return false; + } + } + + protected providerOnConnect?(): void | Promise; + + @gate() + @log() + async disconnect(options?: { silent?: boolean; currentSessionOnly?: boolean }): Promise { + if (options?.currentSessionOnly && this._session === null) return; + + const connected = this._session != null; + + let signOut = !options?.currentSessionOnly; + + if (connected && !options?.currentSessionOnly && !options?.silent) { + const disable = { title: 'Disable' }; + const disableAndSignOut = { title: 'Disable & Sign Out' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + + let result: MessageItem | undefined; + if (this.authenticationService.supports(this.authProvider.id)) { + result = await window.showWarningMessage( + `Are you sure you want to disable the rich integration with ${this.name}?\n\nNote: signing out clears the saved authentication.`, + { modal: true }, + disable, + disableAndSignOut, + cancel, + ); + } else { + result = await window.showWarningMessage( + `Are you sure you want to disable the rich integration with ${this.name}?`, + { modal: true }, + disable, + cancel, + ); + } + + if (result == null || result === cancel) return; + + signOut = result === disableAndSignOut; + } + + if (signOut) { + const authProvider = await this.authenticationService.get(this.authProvider.id); + void authProvider.deleteSession(this.authProviderDescriptor); + } + + this.resetRequestExceptionCount(); + this._session = null; + + if (connected) { + // Don't store the disconnected flag if silently disconnecting or disconnecting this only for + // this current VS Code session (will be re-connected on next restart) + if (!options?.currentSessionOnly && !options?.silent) { + void this.container.storage.storeWorkspace(this.connectedKey, false); + } + + this._onDidChange.fire(); + if (!options?.currentSessionOnly) { + this.container.integrations.disconnected(this, this.key); + } + } + + await this.providerOnDisconnect?.(); + } + + protected providerOnDisconnect?(): void | Promise; + + @log() + async reauthenticate(): Promise { + if (this._session === undefined) return; + + this._session = undefined; + void (await this.ensureSession({ createIfNeeded: true, forceNewSession: true })); + } + + refresh() { + void this.ensureSession({ createIfNeeded: false }); + } + + private requestExceptionCount = 0; + + resetRequestExceptionCount(): void { + this.requestExceptionCount = 0; + } + + async reset(): Promise { + await this.disconnect({ silent: true }); + await this.container.storage.deleteWorkspace(this.connectedKey); + } + + @log() + async syncCloudConnection(state: 'connected' | 'disconnected', forceSync: boolean): Promise { + if (this._session?.cloud === false) return; + + switch (state) { + case 'connected': + if (forceSync) { + // Reset our stored session so that we get a new one from the cloud + const authProvider = await this.authenticationService.get(this.authProvider.id); + await authProvider.deleteSession(this.authProviderDescriptor); + // Reset the session and clear our "stay disconnected" flag + this._session = undefined; + await this.container.storage.deleteWorkspace(this.connectedKey); + } else { + // Only sync if we're not connected and not disabled and don't have pending errors + if ( + this._session != null || + this.requestExceptionCount > 0 || + this.container.storage.getWorkspace(this.connectedKey) === false + ) { + return; + } + + forceSync = true; + } + + await this.ensureSession({ createIfNeeded: forceSync }); + break; + case 'disconnected': + await this.disconnect({ silent: true }); + break; + } + } + + protected handleProviderException(ex: Error, scope: LogScope | undefined, defaultValue: T): T { + if (ex instanceof CancellationError) return defaultValue; + + Logger.error(ex, scope); + + if (ex instanceof AuthenticationError || ex instanceof RequestClientError) { + this.trackRequestException(); + } + return defaultValue; + } + + @debug() + trackRequestException(): void { + this.requestExceptionCount++; + + if (this.requestExceptionCount >= 5 && this._session !== null) { + void showIntegrationDisconnectedTooManyFailedRequestsWarningMessage(this.name); + void this.disconnect({ currentSessionOnly: true }); + } + } + + @gate() + @debug({ exit: true }) + async isConnected(): Promise { + return (await this.getSession('integrations')) != null; + } + + @gate() + private async ensureSession(options: { + createIfNeeded?: boolean; + forceNewSession?: boolean; + source?: Sources; + }): Promise { + const { createIfNeeded, forceNewSession, source } = options; + if (this._session != null) return this._session; + if (!configuration.get('integrations.enabled')) return undefined; + + if (createIfNeeded) { + await this.container.storage.deleteWorkspace(this.connectedKey); + } else if (this.container.storage.getWorkspace(this.connectedKey) === false) { + return undefined; + } + + let session: ProviderAuthenticationSession | undefined | null; + try { + const authProvider = await this.authenticationService.get(this.authProvider.id); + session = await authProvider.getSession(this.authProviderDescriptor, { + createIfNeeded: createIfNeeded, + forceNewSession: forceNewSession, + source: source, + }); + } catch (ex) { + await this.container.storage.deleteWorkspace(this.connectedKey); + + if (ex instanceof Error && ex.message.includes('User did not consent')) { + return undefined; + } + + session = null; + } + + if (session === undefined && !createIfNeeded) { + await this.container.storage.deleteWorkspace(this.connectedKey); + } + + this._session = session ?? null; + this.resetRequestExceptionCount(); + + if (session != null) { + await this.container.storage.storeWorkspace(this.connectedKey, true); + + queueMicrotask(() => { + this._onDidChange.fire(); + this.container.integrations.connected(this, this.key); + void this.providerOnConnect?.(); + }); + } + + return session ?? undefined; + } + + getIgnoreSSLErrors(): boolean | 'force' { + return this.container.integrations.ignoreSSLErrors(this); + } + + async searchMyIssues( + resource?: ResourceDescriptor, + cancellation?: CancellationToken, + ): Promise; + async searchMyIssues( + resources?: ResourceDescriptor[], + cancellation?: CancellationToken, + ): Promise; + @debug() + async searchMyIssues( + resources?: ResourceDescriptor | ResourceDescriptor[], + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const issues = await this.searchProviderMyIssues( + this._session!, + resources != null ? (Array.isArray(resources) ? resources : [resources]) : undefined, + cancellation, + ); + this.resetRequestExceptionCount(); + return issues; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected abstract searchProviderMyIssues( + session: ProviderAuthenticationSession, + resources?: ResourceDescriptor[], + cancellation?: CancellationToken, + ): Promise; + + @debug() + async getIssueOrPullRequest( + resource: T, + id: string, + options?: { expiryOverride?: boolean | number }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const issueOrPR = this.container.cache.getIssueOrPullRequest( + id, + resource, + this, + () => ({ + value: (async () => { + try { + const result = await this.getProviderIssueOrPullRequest(this._session!, resource, id); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + }), + options, + ); + return issueOrPR; + } + + protected abstract getProviderIssueOrPullRequest( + session: ProviderAuthenticationSession, + resource: T, + id: string, + ): Promise; + + async getCurrentAccount(options?: { + avatarSize?: number; + expiryOverride?: boolean | number; + }): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const { expiryOverride, ...opts } = options ?? {}; + + const currentAccount = await this.container.cache.getCurrentAccount( + this, + () => ({ + value: (async () => { + try { + const account = await this.getProviderCurrentAccount?.(this._session!, opts); + this.resetRequestExceptionCount(); + return account; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + }), + { expiryOverride: expiryOverride }, + ); + return currentAccount; + } + + protected getProviderCurrentAccount?( + session: ProviderAuthenticationSession, + options?: { avatarSize?: number }, + ): Promise; + + @debug() + async getPullRequest(resource: T, id: string): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const pr = this.container.cache.getPullRequest(id, resource, this, () => ({ + value: (async () => { + try { + const result = await this.getProviderPullRequest?.(this._session!, resource, id); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + })); + return pr; + } + + protected getProviderPullRequest?( + session: ProviderAuthenticationSession, + resource: T, + id: string, + ): Promise; +} + +export abstract class IssueIntegration< + ID extends SupportedIntegrationIds = SupportedIntegrationIds, + T extends ResourceDescriptor = ResourceDescriptor, +> extends IntegrationBase { + readonly type: IntegrationType = 'issues'; + + @gate() + @debug() + async getAccountForResource(resource: T): Promise { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const account = await this.getProviderAccountForResource(this._session!, resource); + this.resetRequestExceptionCount(); + return account; + } catch (ex) { + return this.handleProviderException(ex, undefined, undefined); + } + } + + protected abstract getProviderAccountForResource( + session: ProviderAuthenticationSession, + resource: T, + ): Promise; + + @gate() + @debug() + async getResourcesForUser(): Promise { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const resources = await this.getProviderResourcesForUser(this._session!); + this.resetRequestExceptionCount(); + return resources; + } catch (ex) { + return this.handleProviderException(ex, undefined, undefined); + } + } + + protected abstract getProviderResourcesForUser(session: ProviderAuthenticationSession): Promise; + + @debug() + async getProjectsForResources(resources: T[]): Promise { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const projects = await this.getProviderProjectsForResources(this._session!, resources); + this.resetRequestExceptionCount(); + return projects; + } catch (ex) { + return this.handleProviderException(ex, undefined, undefined); + } + } + + async getProjectsForUser(): Promise { + const resources = await this.getResourcesForUser(); + if (resources == null) return undefined; + + return this.getProjectsForResources(resources); + } + + protected abstract getProviderProjectsForResources( + session: ProviderAuthenticationSession, + resources: T[], + ): Promise; + + @debug() + async getIssuesForProject( + project: T, + options?: { user?: string; filters?: IssueFilter[] }, + ): Promise { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const issues = await this.getProviderIssuesForProject(this._session!, project, options); + this.resetRequestExceptionCount(); + return issues; + } catch (ex) { + return this.handleProviderException(ex, undefined, undefined); + } + } + + protected abstract getProviderIssuesForProject( + session: ProviderAuthenticationSession, + project: T, + options?: { user?: string; filters?: IssueFilter[] }, + ): Promise; +} + +export abstract class HostingIntegration< + ID extends SupportedIntegrationIds = SupportedIntegrationIds, + T extends ResourceDescriptor = ResourceDescriptor, +> extends IntegrationBase { + readonly type: IntegrationType = 'hosting'; + + @gate() + @debug() + async getAccountForEmail( + repo: T, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const author = await this.getProviderAccountForEmail(this._session!, repo, email, options); + this.resetRequestExceptionCount(); + return author; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected abstract getProviderAccountForEmail( + session: ProviderAuthenticationSession, + repo: T, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise; + + @gate() + @debug() + async getAccountForCommit( + repo: T, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const author = await this.getProviderAccountForCommit(this._session!, repo, ref, options); + this.resetRequestExceptionCount(); + return author; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected abstract getProviderAccountForCommit( + session: ProviderAuthenticationSession, + repo: T, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise; + + @debug() + async getDefaultBranch( + repo: T, + options?: { cancellation?: CancellationToken; expiryOverride?: boolean | number }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const defaultBranch = this.container.cache.getRepositoryDefaultBranch( + repo, + this, + () => ({ + value: (async () => { + try { + const result = await this.getProviderDefaultBranch(this._session!, repo, options?.cancellation); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + }), + { expiryOverride: options?.expiryOverride }, + ); + return defaultBranch; + } + + protected abstract getProviderDefaultBranch( + { accessToken }: ProviderAuthenticationSession, + repo: T, + cancellation?: CancellationToken, + ): Promise; + + @debug() + async getRepositoryMetadata( + repo: T, + options?: { cancellation?: CancellationToken; expiryOverride?: boolean | number }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const metadata = this.container.cache.getRepositoryMetadata( + repo, + this, + () => ({ + value: (async () => { + try { + const result = await this.getProviderRepositoryMetadata( + this._session!, + repo, + options?.cancellation, + ); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + }), + { expiryOverride: options?.expiryOverride }, + ); + return metadata; + } + + protected abstract getProviderRepositoryMetadata( + session: ProviderAuthenticationSession, + repo: T, + cancellation?: CancellationToken, + ): Promise; + + async mergePullRequest( + pr: PullRequest, + options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return false; + + try { + const result = await this.mergeProviderPullRequest(this._session!, pr, options); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, false); + } + } + + protected abstract mergeProviderPullRequest( + session: ProviderAuthenticationSession, + pr: PullRequest, + options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise; + + @debug() + async getPullRequestForBranch( + repo: T, + branch: string, + options?: { + avatarSize?: number; + expiryOverride?: boolean | number; + include?: PullRequestState[]; + }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const { expiryOverride, ...opts } = options ?? {}; + + const pr = this.container.cache.getPullRequestForBranch( + branch, + repo, + this, + () => ({ + value: (async () => { + try { + const result = await this.getProviderPullRequestForBranch(this._session!, repo, branch, opts); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + }), + { expiryOverride: expiryOverride }, + ); + return pr; + } + + protected abstract getProviderPullRequestForBranch( + session: ProviderAuthenticationSession, + repo: T, + branch: string, + options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise; + + @debug() + async getPullRequestForCommit( + repo: T, + ref: string, + options?: { expiryOverride?: boolean | number }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const pr = this.container.cache.getPullRequestForSha( + ref, + repo, + this, + () => ({ + value: (async () => { + try { + const result = await this.getProviderPullRequestForCommit(this._session!, repo, ref); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + }), + options, + ); + return pr; + } + + protected abstract getProviderPullRequestForCommit( + session: ProviderAuthenticationSession, + repo: T, + ref: string, + ): Promise; + + async getMyIssuesForRepos( + reposOrRepoIds: ProviderReposInput, + options?: { + filters?: IssueFilter[]; + cursor?: string; + customUrl?: string; + }, + ): Promise | undefined> { + const providerId = this.authProvider.id; + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const api = await this.getProvidersApi(); + if ( + providerId !== HostingIntegrationId.GitLab && + (api.isRepoIdsInput(reposOrRepoIds) || + (providerId === HostingIntegrationId.AzureDevOps && + !reposOrRepoIds.every(repo => repo.project != null && repo.namespace != null))) + ) { + Logger.warn(`Unsupported input for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + let getIssuesOptions: GetIssuesOptions | undefined; + if (providerId === HostingIntegrationId.AzureDevOps) { + const organizations = new Set(); + const projects = new Set(); + for (const repo of reposOrRepoIds as ProviderRepoInput[]) { + organizations.add(repo.namespace); + projects.add(repo.project!); + } + + if (organizations.size > 1) { + Logger.warn(`Multiple organizations not supported for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } else if (organizations.size === 0) { + Logger.warn(`No organizations found for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + const organization: string = first(organizations.values())!; + + if (options?.filters != null) { + if (!api.providerSupportsIssueFilters(providerId, options.filters)) { + Logger.warn(`Unsupported filters for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + let userAccount: ProviderAccount | undefined; + try { + userAccount = await api.getCurrentUserForInstance(providerId, organization); + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + + if (userAccount == null) { + Logger.warn(`Unable to get current user for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + const userFilterProperty = userAccount.name; + + if (userFilterProperty == null) { + Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + getIssuesOptions = { + authorLogin: options.filters.includes(IssueFilter.Author) ? userFilterProperty : undefined, + assigneeLogins: options.filters.includes(IssueFilter.Assignee) ? [userFilterProperty] : undefined, + mentionLogin: options.filters.includes(IssueFilter.Mention) ? userFilterProperty : undefined, + }; + } + + const cursorInfo = JSON.parse(options?.cursor ?? '{}'); + const cursors: PagedProjectInput[] = cursorInfo.cursors ?? []; + let projectInputs: PagedProjectInput[] = Array.from(projects.values()).map(project => ({ + namespace: organization, + project: project, + cursor: undefined, + })); + if (cursors.length > 0) { + projectInputs = cursors; + } + + try { + const cursor: { cursors: PagedProjectInput[] } = { cursors: [] }; + let hasMore = false; + const data: ProviderIssue[] = []; + await Promise.all( + projectInputs.map(async projectInput => { + const results = await api.getIssuesForAzureProject( + projectInput.namespace, + projectInput.project, + { + ...getIssuesOptions, + cursor: projectInput.cursor, + }, + ); + data.push(...results.values); + if (results.paging?.more) { + hasMore = true; + cursor.cursors.push({ + namespace: projectInput.namespace, + project: projectInput.project, + cursor: results.paging.cursor, + }); + } + }), + ); + + return { + values: data, + paging: { + more: hasMore, + cursor: JSON.stringify(cursor), + }, + }; + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + } + if (options?.filters != null) { + let userAccount: ProviderAccount | undefined; + try { + userAccount = await api.getCurrentUser(providerId); + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + + if (userAccount == null) { + Logger.warn(`Unable to get current user for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + const userFilterProperty = userAccount.username; + if (userFilterProperty == null) { + Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + getIssuesOptions = { + authorLogin: options.filters.includes(IssueFilter.Author) ? userFilterProperty : undefined, + assigneeLogins: options.filters.includes(IssueFilter.Assignee) ? [userFilterProperty] : undefined, + mentionLogin: options.filters.includes(IssueFilter.Mention) ? userFilterProperty : undefined, + }; + } + + if (api.getProviderIssuesPagingMode(providerId) === PagingMode.Repo && !api.isRepoIdsInput(reposOrRepoIds)) { + const cursorInfo = JSON.parse(options?.cursor ?? '{}'); + const cursors: PagedRepoInput[] = cursorInfo.cursors ?? []; + let repoInputs: PagedRepoInput[] = reposOrRepoIds.map(repo => ({ repo: repo, cursor: undefined })); + if (cursors.length > 0) { + repoInputs = cursors; + } + + try { + const cursor: { cursors: PagedRepoInput[] } = { cursors: [] }; + let hasMore = false; + const data: ProviderIssue[] = []; + await Promise.all( + repoInputs.map(async repoInput => { + const results = await api.getIssuesForRepo(providerId, repoInput.repo, { + ...getIssuesOptions, + cursor: repoInput.cursor, + baseUrl: options?.customUrl, + }); + data.push(...results.values); + if (results.paging?.more) { + hasMore = true; + cursor.cursors.push({ repo: repoInput.repo, cursor: results.paging.cursor }); + } + }), + ); + + return { + values: data, + paging: { + more: hasMore, + cursor: JSON.stringify(cursor), + }, + }; + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + } + + try { + return await api.getIssuesForRepos(providerId, reposOrRepoIds, { + ...getIssuesOptions, + cursor: options?.cursor, + baseUrl: options?.customUrl, + }); + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + } + + async getMyPullRequestsForRepos( + reposOrRepoIds: ProviderReposInput, + options?: { + filters?: PullRequestFilter[]; + cursor?: string; + customUrl?: string; + }, + ): Promise | undefined> { + const providerId = this.authProvider.id; + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const api = await this.getProvidersApi(); + if ( + providerId !== HostingIntegrationId.GitLab && + (api.isRepoIdsInput(reposOrRepoIds) || + (providerId === HostingIntegrationId.AzureDevOps && + !reposOrRepoIds.every(repo => repo.project != null && repo.namespace != null))) + ) { + Logger.warn(`Unsupported input for provider ${providerId}`); + return undefined; + } + + let getPullRequestsOptions: GetPullRequestsOptions | undefined; + if (options?.filters != null) { + if (!api.providerSupportsPullRequestFilters(providerId, options.filters)) { + Logger.warn(`Unsupported filters for provider ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + let userAccount: ProviderAccount | undefined; + if (providerId === HostingIntegrationId.AzureDevOps) { + const organizations = new Set(); + for (const repo of reposOrRepoIds as ProviderRepoInput[]) { + organizations.add(repo.namespace); + } + + if (organizations.size > 1) { + Logger.warn( + `Multiple organizations not supported for provider ${providerId}`, + 'getPullRequestsForRepos', + ); + return undefined; + } else if (organizations.size === 0) { + Logger.warn(`No organizations found for provider ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + const organization: string = first(organizations.values())!; + try { + userAccount = await api.getCurrentUserForInstance(providerId, organization); + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } else { + try { + userAccount = await api.getCurrentUser(providerId); + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } + + if (userAccount == null) { + Logger.warn(`Unable to get current user for ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + let userFilterProperty: string | null; + switch (providerId) { + case HostingIntegrationId.Bitbucket: + case HostingIntegrationId.AzureDevOps: + userFilterProperty = userAccount.id; + break; + default: + userFilterProperty = userAccount.username; + break; + } + + if (userFilterProperty == null) { + Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + getPullRequestsOptions = { + authorLogin: options.filters.includes(PullRequestFilter.Author) ? userFilterProperty : undefined, + assigneeLogins: options.filters.includes(PullRequestFilter.Assignee) ? [userFilterProperty] : undefined, + reviewRequestedLogin: options.filters.includes(PullRequestFilter.ReviewRequested) + ? userFilterProperty + : undefined, + mentionLogin: options.filters.includes(PullRequestFilter.Mention) ? userFilterProperty : undefined, + }; + } + + if ( + api.getProviderPullRequestsPagingMode(providerId) === PagingMode.Repo && + !api.isRepoIdsInput(reposOrRepoIds) + ) { + const cursorInfo = JSON.parse(options?.cursor ?? '{}'); + const cursors: PagedRepoInput[] = cursorInfo.cursors ?? []; + let repoInputs: PagedRepoInput[] = reposOrRepoIds.map(repo => ({ repo: repo, cursor: undefined })); + if (cursors.length > 0) { + repoInputs = cursors; + } + + try { + const cursor: { cursors: PagedRepoInput[] } = { cursors: [] }; + let hasMore = false; + const data: ProviderPullRequest[] = []; + await Promise.all( + repoInputs.map(async repoInput => { + const results = await api.getPullRequestsForRepo(providerId, repoInput.repo, { + ...getPullRequestsOptions, + cursor: repoInput.cursor, + baseUrl: options?.customUrl, + }); + data.push(...results.values); + if (results.paging?.more) { + hasMore = true; + cursor.cursors.push({ repo: repoInput.repo, cursor: results.paging.cursor }); + } + }), + ); + + return { + values: data, + paging: { + more: hasMore, + cursor: JSON.stringify(cursor), + }, + }; + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } + + try { + return await api.getPullRequestsForRepos(providerId, reposOrRepoIds, { + ...getPullRequestsOptions, + cursor: options?.cursor, + baseUrl: options?.customUrl, + }); + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } + + async searchMyPullRequests( + repo?: T, + cancellation?: CancellationToken, + silent?: boolean, + ): Promise>; + async searchMyPullRequests( + repos?: T[], + cancellation?: CancellationToken, + silent?: boolean, + ): Promise>; + @debug() + async searchMyPullRequests( + repos?: T | T[], + cancellation?: CancellationToken, + silent?: boolean, + ): Promise> { + const scope = getLogScope(); + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const start = Date.now(); + try { + const pullRequests = await this.searchProviderMyPullRequests( + this._session!, + repos != null ? (Array.isArray(repos) ? repos : [repos]) : undefined, + cancellation, + silent, + ); + return { value: pullRequests, duration: Date.now() - start }; + } catch (ex) { + Logger.error(ex, scope); + return { error: ex, duration: Date.now() - start }; + } + } + + protected abstract searchProviderMyPullRequests( + session: ProviderAuthenticationSession, + repos?: T[], + cancellation?: CancellationToken, + silent?: boolean, + ): Promise; + + async searchPullRequests( + searchQuery: string, + repo?: T, + cancellation?: CancellationToken, + ): Promise; + async searchPullRequests( + searchQuery: string, + repos?: T[], + cancellation?: CancellationToken, + ): Promise; + @debug() + async searchPullRequests( + searchQuery: string, + repos?: T | T[], + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const prs = await this.searchProviderPullRequests?.( + this._session!, + searchQuery, + repos != null ? (Array.isArray(repos) ? repos : [repos]) : undefined, + cancellation, + ); + this.resetRequestExceptionCount(); + return prs; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected searchProviderPullRequests?( + session: ProviderAuthenticationSession, + searchQuery: string, + repos?: T[], + cancellation?: CancellationToken, + ): Promise; +} diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts new file mode 100644 index 0000000000000..6fc65d9b70276 --- /dev/null +++ b/src/plus/integrations/integrationService.ts @@ -0,0 +1,802 @@ +import { isWeb } from '@env/platform'; +import type { AuthenticationSessionsChangeEvent, CancellationToken, Event } from 'vscode'; +import { authentication, Disposable, env, EventEmitter, ProgressLocation, Uri, window } from 'vscode'; +import type { Source } from '../../constants.telemetry'; +import { sourceToContext } from '../../constants.telemetry'; +import type { Container } from '../../container'; +import type { Account } from '../../git/models/author'; +import type { SearchedIssue } from '../../git/models/issue'; +import type { SearchedPullRequest } from '../../git/models/pullRequest'; +import type { GitRemote } from '../../git/models/remote'; +import type { RemoteProvider, RemoteProviderId } from '../../git/remotes/remoteProvider'; +import { gate } from '../../system/decorators/gate'; +import { debug, log } from '../../system/decorators/log'; +import { promisifyDeferred, take } from '../../system/event'; +import { filterMap, flatten, join } from '../../system/iterable'; +import { Logger } from '../../system/logger'; +import { getLogScope } from '../../system/logger.scope'; +import { configuration } from '../../system/vscode/configuration'; +import { openUrl } from '../../system/vscode/utils'; +import type { SubscriptionChangeEvent } from '../gk/account/subscriptionService'; +import type { IntegrationAuthenticationService } from './authentication/integrationAuthentication'; +import type { SupportedCloudIntegrationIds } from './authentication/models'; +import { + CloudIntegrationAuthenticationUriPathPrefix, + getSupportedCloudIntegrationIds, + isSupportedCloudIntegrationId, + toCloudIntegrationType, + toIntegrationId, +} from './authentication/models'; +import type { + HostingIntegration, + Integration, + IntegrationBase, + IntegrationKey, + IntegrationResult, + IntegrationType, + IssueIntegration, + ResourceDescriptor, + SupportedHostingIntegrationIds, + SupportedIntegrationIds, + SupportedIssueIntegrationIds, + SupportedSelfHostedIntegrationIds, +} from './integration'; +import type { IntegrationId } from './providers/models'; +import { + HostingIntegrationId, + isSelfHostedIntegrationId, + IssueIntegrationId, + SelfHostedIntegrationId, +} from './providers/models'; +import type { ProvidersApi } from './providers/providersApi'; + +export interface ConnectionStateChangeEvent { + key: string; + reason: 'connected' | 'disconnected'; +} + +export class IntegrationService implements Disposable { + private readonly _onDidChangeConnectionState = new EventEmitter(); + get onDidChangeConnectionState(): Event { + return this._onDidChangeConnectionState.event; + } + + private readonly _connectedCache = new Set(); + private readonly _disposable: Disposable; + private _integrations = new Map(); + + constructor( + private readonly container: Container, + private readonly authenticationService: IntegrationAuthenticationService, + ) { + this._disposable = Disposable.from( + configuration.onDidChange(e => { + if (configuration.changed(e, 'remotes')) { + this._ignoreSSLErrors.clear(); + } + }), + authentication.onDidChangeSessions(this.onAuthenticationSessionsChanged, this), + container.subscription.onDidCheckIn(this.onUserCheckedIn, this), + container.subscription.onDidChange(this.onDidChangeSubscription, this), + ); + } + + dispose() { + this._disposable?.dispose(); + } + + @gate() + @debug() + private async syncCloudIntegrations(forceConnect: boolean) { + const connectedIntegrations = new Set(); + const loggedIn = await this.container.subscription.getAuthenticationSession(); + if (loggedIn) { + const cloudIntegrations = await this.container.cloudIntegrations; + const connections = await cloudIntegrations?.getConnections(); + if (connections == null) return; + + connections.map(p => { + const integrationId = toIntegrationId[p.provider]; + // GKDev includes some integrations like "google" that we don't support + if (integrationId == null) return; + connectedIntegrations.add(toIntegrationId[p.provider]); + }); + } + + for await (const integration of this.getSupportedCloudIntegrations()) { + await integration.syncCloudConnection( + connectedIntegrations.has(integration.id) ? 'connected' : 'disconnected', + forceConnect, + ); + } + + if (this.container.telemetry.enabled) { + this.container.telemetry.setGlobalAttributes({ + 'cloudIntegrations.connected.count': connectedIntegrations.size, + 'cloudIntegrations.connected.ids': join(connectedIntegrations.values(), ','), + }); + } + + return connectedIntegrations; + } + + private *getSupportedCloudIntegrations() { + for (const id of getSupportedCloudIntegrationIds()) { + yield this.get(id); + } + } + + private onUserCheckedIn() { + void this.syncCloudIntegrations(false); + } + + private onDidChangeSubscription(e: SubscriptionChangeEvent) { + if (e.current?.account == null) { + void this.syncCloudIntegrations(false); + } + } + + async manageCloudIntegrations(source: Source | undefined) { + const scope = getLogScope(); + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent('cloudIntegrations/settingsOpened', undefined, source); + } + + const account = (await this.container.subscription.getSubscription()).account; + if (account == null) { + if (!(await this.container.subscription.loginOrSignUp(true, source))) { + return; + } + } + + try { + const exchangeToken = await this.container.accountAuthentication.getExchangeToken(); + await openUrl( + this.container + .getGkDevExchangeUri(exchangeToken, `settings/integrations?source=gitlens`) + .toString(true), + ); + } catch (ex) { + Logger.error(ex, scope); + await env.openExternal(this.container.getGkDevUri('settings/integrations', 'source=gitlens')); + } + take( + window.onDidChangeWindowState, + 2, + )(async e => { + if (e.focused) { + const connected = await this.syncCloudIntegrations(true); + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + 'cloudIntegrations/connected', + { + 'integration.ids': undefined, + 'integration.connected.ids': connected ? join(connected.values(), ',') : undefined, + }, + source, + ); + } + } + }); + } + + async connectCloudIntegrations( + connect?: { integrationIds: SupportedCloudIntegrationIds[]; skipIfConnected?: boolean; skipPreSync?: boolean }, + source?: Source, + ): Promise { + const scope = getLogScope(); + const integrationIds = connect?.integrationIds; + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + 'cloudIntegrations/connecting', + { 'integration.ids': integrationIds?.join(',') }, + source, + ); + } + + let account = (await this.container.subscription.getSubscription()).account; + const connectedIntegrations = new Set(); + if (integrationIds?.length) { + if (connect?.skipIfConnected && !connect?.skipPreSync) { + await this.syncCloudIntegrations(true); + } + + for (const integrationId of integrationIds) { + const integration = await this.get(integrationId); + if (integration.maybeConnected ?? (await integration.isConnected())) { + connectedIntegrations.add(integrationId); + } + } + + if (connect?.skipIfConnected && connectedIntegrations.size === integrationIds.length) { + return true; + } + } + + let query = 'source=gitlens'; + + if (source?.source != null && sourceToContext[source.source] != null) { + query += `&context=${sourceToContext[source.source]}`; + } + + if (integrationIds != null) { + const cloudIntegrationTypes = []; + for (const integrationId of integrationIds) { + const cloudIntegrationType = toCloudIntegrationType[integrationId]; + if (cloudIntegrationType == null) { + Logger.error( + undefined, + scope, + `Attempting to connect unsupported cloud integration type: ${integrationId}`, + ); + } else { + cloudIntegrationTypes.push(cloudIntegrationType); + } + } + if (cloudIntegrationTypes.length > 0) { + query += `&provider=${cloudIntegrationTypes.join(',')}`; + } + } + + const callbackUri = await env.asExternalUri( + Uri.parse( + `${env.uriScheme}://${this.container.context.extension.id}/${CloudIntegrationAuthenticationUriPathPrefix}`, + ), + ); + query += `&redirect_uri=${encodeURIComponent(callbackUri.toString(true))}`; + + if (account != null) { + try { + const exchangeToken = await this.container.accountAuthentication.getExchangeToken(); + await openUrl(this.container.getGkDevExchangeUri(exchangeToken, `connect?${query}`).toString(true)); + } catch (ex) { + Logger.error(ex, scope); + if (!(await openUrl(this.container.getGkDevUri('connect', query).toString(true)))) { + return false; + } + } + } else if (!(await openUrl(this.container.getGkDevUri('connect', query).toString(true)))) { + return false; + } + + const deferredCallback = promisifyDeferred( + this.container.uri.onDidReceiveCloudIntegrationAuthenticationUri, + (uri: Uri, resolve) => { + const queryParams: URLSearchParams = new URLSearchParams(uri.query); + resolve(queryParams.get('code') ?? undefined); + }, + ); + + let code: string | undefined; + try { + code = await window.withProgress( + { + location: ProgressLocation.Notification, + title: 'Connecting integrations...', + cancellable: true, + }, + (_, token) => { + return Promise.race([ + deferredCallback.promise, + new Promise((_, reject) => + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + token.onCancellationRequested(() => reject('Cancelled')), + ), + new Promise((_, reject) => setTimeout(reject, 5 * 60 * 1000, 'Cancelled')), + ]); + }, + ); + } catch { + return false; + } finally { + deferredCallback.cancel(); + } + + if (account == null) { + if (code == null) return false; + await this.container.subscription.loginWithCode({ code: code }, source); + account = (await this.container.subscription.getSubscription()).account; + if (account == null) return false; + } + + const connected = await this.syncCloudIntegrations(true); + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + 'cloudIntegrations/connected', + { + 'integration.ids': integrationIds?.join(','), + 'integration.connected.ids': connected ? join(connected.values(), ',') : undefined, + }, + source, + ); + } + + if (integrationIds != null) { + for (const integrationId of integrationIds) { + const integration = await this.get(integrationId); + const connected = integration.maybeConnected ?? (await integration.isConnected()); + if (connected && !connectedIntegrations.has(integrationId)) { + return true; + } + } + + return false; + } + + return true; + } + + private onAuthenticationSessionsChanged(e: AuthenticationSessionsChangeEvent) { + for (const integration of this._integrations.values()) { + if (e.provider.id === integration.authProvider.id) { + integration.refresh(); + } + } + } + + connected(integration: IntegrationBase, key: string): void { + // Only fire events if the key is being connected for the first time + if (this._connectedCache.has(key)) return; + + this._connectedCache.add(key); + if (this.container.telemetry.enabled) { + if (integration.type === 'hosting') { + if (isSupportedCloudIntegrationId(integration.id)) { + this.container.telemetry.sendEvent('cloudIntegrations/hosting/connected', { + 'hostingProvider.provider': integration.id, + 'hostingProvider.key': key, + }); + } else { + this.container.telemetry.sendEvent('remoteProviders/connected', { + 'hostingProvider.provider': integration.id, + 'hostingProvider.key': key, + + // Deprecated + 'remoteProviders.key': key, + }); + } + } else { + this.container.telemetry.sendEvent('cloudIntegrations/issue/connected', { + 'issueProvider.provider': integration.id, + 'issueProvider.key': key, + }); + } + } + + setTimeout(() => this._onDidChangeConnectionState.fire({ key: key, reason: 'connected' }), 250); + } + + disconnected(integration: IntegrationBase, key: string): void { + // Probably shouldn't bother to fire the event if we don't already think we are connected, but better to be safe + // if (!_connectedCache.has(key)) return; + this._connectedCache.delete(key); + if (this.container.telemetry.enabled) { + if (integration.type === 'hosting') { + if (isSupportedCloudIntegrationId(integration.id)) { + this.container.telemetry.sendEvent('cloudIntegrations/hosting/disconnected', { + 'hostingProvider.provider': integration.id, + 'hostingProvider.key': key, + }); + } else { + this.container.telemetry.sendEvent('remoteProviders/disconnected', { + 'hostingProvider.provider': integration.id, + 'hostingProvider.key': key, + + // Deprecated + 'remoteProviders.key': key, + }); + } + } else { + this.container.telemetry.sendEvent('cloudIntegrations/issue/disconnected', { + 'issueProvider.provider': integration.id, + 'issueProvider.key': key, + }); + } + } + + setTimeout(() => this._onDidChangeConnectionState.fire({ key: key, reason: 'disconnected' }), 250); + } + + isConnected(key?: string): boolean { + return key == null ? this._connectedCache.size !== 0 : this._connectedCache.has(key); + } + + get(id: SupportedHostingIntegrationIds): Promise; + get(id: SupportedIssueIntegrationIds): Promise; + get(id: SupportedSelfHostedIntegrationIds, domain: string): Promise; + get(id: SupportedIntegrationIds, domain?: string): Promise; + async get( + id: SupportedHostingIntegrationIds | SupportedIssueIntegrationIds | SupportedSelfHostedIntegrationIds, + domain?: string, + ): Promise { + let integration = this.getCached(id, domain); + if (integration == null) { + switch (id) { + case HostingIntegrationId.GitHub: + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/github') + ).GitHubIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); + break; + case SelfHostedIntegrationId.GitHubEnterprise: + if (domain == null) throw new Error(`Domain is required for '${id}' integration`); + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/github') + ).GitHubEnterpriseIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + domain, + ); + break; + case HostingIntegrationId.GitLab: + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/gitlab') + ).GitLabIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); + break; + case SelfHostedIntegrationId.GitLabSelfHosted: + if (domain == null) throw new Error(`Domain is required for '${id}' integration`); + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/gitlab') + ).GitLabSelfHostedIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + domain, + ); + break; + case HostingIntegrationId.Bitbucket: + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/bitbucket') + ).BitbucketIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); + break; + case HostingIntegrationId.AzureDevOps: + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/azureDevOps') + ).AzureDevOpsIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + ); + break; + case IssueIntegrationId.Jira: + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/jira') + ).JiraIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); + break; + default: + throw new Error(`Integration with '${id}' is not supported`); + } + this._integrations.set(this.getCacheKey(id, domain), integration); + } + + return integration; + } + + private _providersApi: Promise | undefined; + private async getProvidersApi() { + if (this._providersApi == null) { + const container = this.container; + const authenticationService = this.authenticationService; + async function load() { + return new ( + await import(/* webpackChunkName: "integrations-api" */ './providers/providersApi') + ).ProvidersApi(container, authenticationService); + } + + this._providersApi = load(); + } + + return this._providersApi; + } + + getByRemote(remote: GitRemote): Promise { + if (remote?.provider == null) return Promise.resolve(undefined); + + return this.getByRemoteCore(remote as GitRemote, this.get); + } + + getByRemoteCached(remote: GitRemote): HostingIntegration | undefined { + if (remote?.provider == null) return undefined; + + return this.getByRemoteCore(remote as GitRemote, this.getCached); + } + + private getByRemoteCore( + remote: GitRemote, + getOrGetCached: F, + ): F extends typeof this.get ? Promise : HostingIntegration | undefined { + type RT = F extends typeof this.get ? Promise : HostingIntegration | undefined; + + const get = getOrGetCached.bind(this); + + switch (remote.provider.id) { + case 'azure-devops': + return get(HostingIntegrationId.AzureDevOps) as RT; + case 'bitbucket': + return get(HostingIntegrationId.Bitbucket) as RT; + case 'github': + if (remote.provider.custom && remote.provider.domain != null) { + return get(SelfHostedIntegrationId.GitHubEnterprise, remote.provider.domain) as RT; + } + return get(HostingIntegrationId.GitHub) as RT; + case 'gitlab': + if (remote.provider.custom && remote.provider.domain != null) { + return get(SelfHostedIntegrationId.GitLabSelfHosted, remote.provider.domain) as RT; + } + return get(HostingIntegrationId.GitLab) as RT; + case 'bitbucket-server': + default: + return (getOrGetCached === this.get ? Promise.resolve(undefined) : undefined) as RT; + } + } + + getConnected(type: 'issues'): IssueIntegration[]; + getConnected(type: 'hosting'): HostingIntegration[]; + getConnected(type: IntegrationType): Integration[] { + return [...this._integrations.values()].filter(p => p.maybeConnected && p.type === type); + } + + @log({ + args: { 0: integrationIds => (integrationIds?.length ? integrationIds.join(',') : ''), 1: false }, + }) + async getMyIssues( + integrationIds?: HostingIntegrationId[], + cancellation?: CancellationToken, + ): Promise { + const integrations: Map = new Map(); + for (const integrationId of integrationIds?.length ? integrationIds : Object.values(HostingIntegrationId)) { + const integration = await this.get(integrationId); + if (integration == null) continue; + + integrations.set(integration, undefined); + } + if (integrations.size === 0) return undefined; + + return this.getMyIssuesCore(integrations, cancellation); + } + + private async getMyIssuesCore( + integrations: Map, + cancellation?: CancellationToken, + ): Promise { + const promises: Promise[] = []; + for (const [integration, repos] of integrations) { + if (integration == null) continue; + + promises.push(integration.searchMyIssues(repos, cancellation)); + } + + const results = await Promise.allSettled(promises); + return [...flatten(filterMap(results, r => (r.status === 'fulfilled' ? r.value : undefined)))]; + } + + async getMyIssuesForRemotes(remote: GitRemote): Promise; + async getMyIssuesForRemotes(remotes: GitRemote[]): Promise; + @debug({ + args: { 0: (r: GitRemote | GitRemote[]) => (Array.isArray(r) ? r.map(rp => rp.name) : r.name) }, + }) + async getMyIssuesForRemotes(remoteOrRemotes: GitRemote | GitRemote[]): Promise { + if (!Array.isArray(remoteOrRemotes)) { + remoteOrRemotes = [remoteOrRemotes]; + } + + if (!remoteOrRemotes.length) return undefined; + if (remoteOrRemotes.length === 1) { + const [remote] = remoteOrRemotes; + if (remote?.provider == null) return undefined; + + const integration = await this.getByRemote(remote); + return integration?.searchMyIssues(remote.provider.repoDesc); + } + + const integrations = new Map(); + + for (const remote of remoteOrRemotes) { + if (remote?.provider == null) continue; + + const integration = await remote.getIntegration(); + if (integration == null) continue; + + let repos = integrations.get(integration); + if (repos == null) { + repos = []; + integrations.set(integration, repos); + } + repos.push(remote.provider.repoDesc); + } + + return this.getMyIssuesCore(integrations); + } + + @log({ + args: { 0: integrationIds => (integrationIds?.length ? integrationIds.join(',') : '') }, + }) + async getMyCurrentAccounts(integrationIds: HostingIntegrationId[]): Promise> { + const accounts = new Map(); + await Promise.allSettled( + integrationIds.map(async integrationId => { + const integration = await this.get(integrationId); + if (integration == null) return; + + const account = await integration.getCurrentAccount(); + if (account) { + accounts.set(integrationId, account); + } + }), + ); + return accounts; + } + + @log({ + args: { 0: integrationIds => (integrationIds?.length ? integrationIds.join(',') : ''), 1: false }, + }) + async getMyPullRequests( + integrationIds?: HostingIntegrationId[], + cancellation?: CancellationToken, + silent?: boolean, + ): Promise> { + const integrations: Map = new Map(); + for (const integrationId of integrationIds?.length ? integrationIds : Object.values(HostingIntegrationId)) { + const integration = await this.get(integrationId); + if (integration == null) continue; + + integrations.set(integration, undefined); + } + if (integrations.size === 0) return undefined; + + return this.getMyPullRequestsCore(integrations, cancellation, silent); + } + + private async getMyPullRequestsCore( + integrations: Map, + cancellation?: CancellationToken, + silent?: boolean, + ): Promise> { + const start = Date.now(); + + const promises: Promise>[] = []; + for (const [integration, repos] of integrations) { + if (integration == null) continue; + + promises.push(integration.searchMyPullRequests(repos, cancellation, silent)); + } + + const results = await Promise.allSettled(promises); + + const errors = [ + ...filterMap(results, r => + r.status === 'fulfilled' && r.value?.error != null ? r.value.error : undefined, + ), + ]; + if (errors.length) { + return { + error: errors.length === 1 ? errors[0] : new AggregateError(errors), + duration: Date.now() - start, + }; + } + + return { + value: [ + ...flatten( + filterMap(results, r => + r.status === 'fulfilled' && r.value != null && r.value?.error == null + ? r.value.value + : undefined, + ), + ), + ], + duration: Date.now() - start, + }; + } + + async getMyPullRequestsForRemotes(remote: GitRemote): Promise>; + async getMyPullRequestsForRemotes( + remotes: GitRemote[], + ): Promise>; + @debug({ + args: { 0: (r: GitRemote | GitRemote[]) => (Array.isArray(r) ? r.map(rp => rp.name) : r.name) }, + }) + async getMyPullRequestsForRemotes( + remoteOrRemotes: GitRemote | GitRemote[], + ): Promise> { + if (!Array.isArray(remoteOrRemotes)) { + remoteOrRemotes = [remoteOrRemotes]; + } + + if (!remoteOrRemotes.length) return undefined; + if (remoteOrRemotes.length === 1) { + const [remote] = remoteOrRemotes; + if (remote?.provider == null) return undefined; + + const provider = await this.getByRemote(remote); + return provider?.searchMyPullRequests(remote.provider.repoDesc); + } + + const integrations = new Map(); + + for (const remote of remoteOrRemotes) { + if (remote?.provider == null) continue; + + const integration = await remote.getIntegration(); + if (integration == null) continue; + + let repos = integrations.get(integration); + if (repos == null) { + repos = []; + integrations.set(integration, repos); + } + repos.push(remote.provider.repoDesc); + } + + return this.getMyPullRequestsCore(integrations); + } + + isMaybeConnected(remote: GitRemote): boolean { + if (remote.provider?.id != null && this.supports(remote.provider.id)) { + return this.getByRemoteCached(remote)?.maybeConnected ?? false; + } + return false; + } + + @log() + async reset(): Promise { + for (const integration of this._integrations.values()) { + await integration.reset(); + } + + await this.authenticationService.reset(); + await this.container.storage.deleteWithPrefix('provider:authentication:skip'); + } + + supports(remoteId: RemoteProviderId): boolean { + switch (remoteId) { + case 'azure-devops': + case 'bitbucket': + case 'github': + case 'gitlab': + return true; + case 'bitbucket-server': + default: + return false; + } + } + + private _ignoreSSLErrors = new Map(); + ignoreSSLErrors( + integration: HostingIntegration | { id: SupportedIntegrationIds; domain?: string }, + ): boolean | 'force' { + if (isWeb) return false; + + let ignoreSSLErrors = this._ignoreSSLErrors.get(integration.id); + if (ignoreSSLErrors === undefined) { + const cfg = configuration + .get('remotes') + ?.find(remote => remote.type.toLowerCase() === integration.id && remote.domain === integration.domain); + ignoreSSLErrors = cfg?.ignoreSSLErrors ?? false; + this._ignoreSSLErrors.set(integration.id, ignoreSSLErrors); + } + + return ignoreSSLErrors; + } + + private getCached(id: SupportedHostingIntegrationIds): HostingIntegration | undefined; + private getCached(id: SupportedIssueIntegrationIds): IssueIntegration | undefined; + private getCached(id: SupportedSelfHostedIntegrationIds, domain: string): HostingIntegration | undefined; + private getCached( + id: SupportedHostingIntegrationIds | SupportedIssueIntegrationIds | SupportedSelfHostedIntegrationIds, + domain?: string, + ): Integration | undefined; + private getCached( + id: SupportedHostingIntegrationIds | SupportedIssueIntegrationIds | SupportedSelfHostedIntegrationIds, + domain?: string, + ): Integration | undefined { + return this._integrations.get(this.getCacheKey(id, domain)); + } + + private getCacheKey( + id: SupportedHostingIntegrationIds | SupportedIssueIntegrationIds | SupportedSelfHostedIntegrationIds, + domain?: string, + ): IntegrationKey { + return isSelfHostedIntegrationId(id) ? (`${id}:${domain}` as const) : id; + } +} diff --git a/src/plus/integrations/providers/azureDevOps.ts b/src/plus/integrations/providers/azureDevOps.ts new file mode 100644 index 0000000000000..1f36c0e83f47c --- /dev/null +++ b/src/plus/integrations/providers/azureDevOps.ts @@ -0,0 +1,150 @@ +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import type { PagedResult } from '../../../git/gitProvider'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { + PullRequest, + PullRequestMergeMethod, + PullRequestState, + SearchedPullRequest, +} from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import { Logger } from '../../../system/logger'; +import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; +import type { ResourceDescriptor } from '../integration'; +import { HostingIntegration } from '../integration'; +import type { ProviderRepository } from './models'; +import { HostingIntegrationId, providersMetadata } from './models'; + +const metadata = providersMetadata[HostingIntegrationId.AzureDevOps]; +const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); + +interface AzureRepositoryDescriptor extends ResourceDescriptor { + owner: string; + name: string; +} + +export class AzureDevOpsIntegration extends HostingIntegration< + HostingIntegrationId.AzureDevOps, + AzureRepositoryDescriptor +> { + readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; + readonly id = HostingIntegrationId.AzureDevOps; + protected readonly key = this.id; + readonly name: string = 'Azure DevOps'; + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://dev.azure.com'; + } + + async getReposForAzureProject( + namespace: string, + project: string, + options?: { cursor?: string }, + ): Promise | undefined> { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + return await ( + await this.getProvidersApi() + ).getReposForAzureProject(namespace, project, { cursor: options?.cursor }); + } catch (ex) { + Logger.error(ex, 'getReposForAzureProject'); + return undefined; + } + } + + protected override async mergeProviderPullRequest( + _session: AuthenticationSession, + _pr: PullRequest, + _options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + return Promise.resolve(false); + } + + protected override async getProviderAccountForCommit( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _ref: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderAccountForEmail( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _email: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderDefaultBranch( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderIssueOrPullRequest( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _id: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForBranch( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _branch: string, + _options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForCommit( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _ref: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderRepositoryMetadata( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _cancellation?: CancellationToken, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyPullRequests( + _session: AuthenticationSession, + _repos?: AzureRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyIssues( + _session: AuthenticationSession, + _repos?: AzureRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } +} diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts new file mode 100644 index 0000000000000..4700d93785f03 --- /dev/null +++ b/src/plus/integrations/providers/bitbucket.ts @@ -0,0 +1,129 @@ +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { + PullRequest, + PullRequestMergeMethod, + PullRequestState, + SearchedPullRequest, +} from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; +import type { ResourceDescriptor } from '../integration'; +import { HostingIntegration } from '../integration'; +import { HostingIntegrationId, providersMetadata } from './models'; + +const metadata = providersMetadata[HostingIntegrationId.Bitbucket]; +const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); + +interface BitbucketRepositoryDescriptor extends ResourceDescriptor { + owner: string; + name: string; +} + +export class BitbucketIntegration extends HostingIntegration< + HostingIntegrationId.Bitbucket, + BitbucketRepositoryDescriptor +> { + readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; + readonly id = HostingIntegrationId.Bitbucket; + protected readonly key = this.id; + readonly name: string = 'Bitbucket'; + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://api.bitbucket.org/2.0'; + } + + protected override async mergeProviderPullRequest( + _session: AuthenticationSession, + _pr: PullRequest, + _options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + return Promise.resolve(false); + } + + protected override async getProviderAccountForCommit( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _ref: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderAccountForEmail( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _email: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderDefaultBranch( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderIssueOrPullRequest( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _id: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForBranch( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _branch: string, + _options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForCommit( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _ref: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderRepositoryMetadata( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _cancellation?: CancellationToken, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyPullRequests( + _session: AuthenticationSession, + _repos?: BitbucketRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyIssues( + _session: AuthenticationSession, + _repos?: BitbucketRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } +} diff --git a/src/plus/integrations/providers/github.ts b/src/plus/integrations/providers/github.ts new file mode 100644 index 0000000000000..c71fa9115da23 --- /dev/null +++ b/src/plus/integrations/providers/github.ts @@ -0,0 +1,318 @@ +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import type { Sources } from '../../../constants.telemetry'; +import type { Container } from '../../../container'; +import type { Account, UnidentifiedAuthor } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { + PullRequest, + PullRequestMergeMethod, + PullRequestState, + SearchedPullRequest, +} from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import { log } from '../../../system/decorators/log'; +import { ensurePaidPlan } from '../../utils'; +import type { + IntegrationAuthenticationProviderDescriptor, + IntegrationAuthenticationService, +} from '../authentication/integrationAuthentication'; +import type { SupportedIntegrationIds } from '../integration'; +import { HostingIntegration } from '../integration'; +import { HostingIntegrationId, providersMetadata, SelfHostedIntegrationId } from './models'; +import type { ProvidersApi } from './providersApi'; + +const metadata = providersMetadata[HostingIntegrationId.GitHub]; +const authProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: metadata.id, + scopes: metadata.scopes, +}); + +const enterpriseMetadata = providersMetadata[SelfHostedIntegrationId.GitHubEnterprise]; +const enterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: enterpriseMetadata.id, + scopes: enterpriseMetadata.scopes, +}); + +export type GitHubRepositoryDescriptor = { + key: string; + owner: string; + name: string; +}; + +abstract class GitHubIntegrationBase extends HostingIntegration< + ID, + GitHubRepositoryDescriptor +> { + protected abstract get apiBaseUrl(): string; + + protected override async getProviderAccountForCommit( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.github)?.getAccountForCommit(this, accessToken, repo.owner, repo.name, ref, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderAccountForEmail( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.github)?.getAccountForEmail(this, accessToken, repo.owner, repo.name, email, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderDefaultBranch( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + ): Promise { + return (await this.container.github)?.getDefaultBranch(this, accessToken, repo.owner, repo.name, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderIssueOrPullRequest( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + id: string, + ): Promise { + return (await this.container.github)?.getIssueOrPullRequest( + this, + accessToken, + repo.owner, + repo.name, + Number(id), + { + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequest( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + id: string, + ): Promise { + return (await this.container.github)?.getPullRequest( + this, + accessToken, + repo.owner, + repo.name, + parseInt(id, 10), + { + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForBranch( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + branch: string, + options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + const { include, ...opts } = options ?? {}; + + const toGitHubPullRequestState = (await import(/* webpackChunkName: "integrations" */ './github/models')) + .toGitHubPullRequestState; + return (await this.container.github)?.getPullRequestForBranch( + this, + accessToken, + repo.owner, + repo.name, + branch, + { + ...opts, + include: include?.map(s => toGitHubPullRequestState(s)), + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForCommit( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + ref: string, + ): Promise { + return (await this.container.github)?.getPullRequestForCommit(this, accessToken, repo.owner, repo.name, ref, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderRepositoryMetadata( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + cancellation?: CancellationToken, + ): Promise { + return (await this.container.github)?.getRepositoryMetadata( + this, + accessToken, + repo.owner, + repo.name, + { + baseUrl: this.apiBaseUrl, + }, + cancellation, + ); + } + + protected override async searchProviderMyPullRequests( + { accessToken }: AuthenticationSession, + repos?: GitHubRepositoryDescriptor[], + cancellation?: CancellationToken, + silent?: boolean, + ): Promise { + return (await this.container.github)?.searchMyPullRequests( + this, + accessToken, + { + repos: repos?.map(r => `${r.owner}/${r.name}`), + baseUrl: this.apiBaseUrl, + silent: silent, + }, + cancellation, + ); + } + + protected override async searchProviderMyIssues( + { accessToken }: AuthenticationSession, + repos?: GitHubRepositoryDescriptor[], + cancellation?: CancellationToken, + ): Promise { + return (await this.container.github)?.searchMyIssues( + this, + accessToken, + { + repos: repos?.map(r => `${r.owner}/${r.name}`), + baseUrl: this.apiBaseUrl, + }, + cancellation, + ); + } + + protected override async searchProviderPullRequests( + { accessToken }: AuthenticationSession, + searchQuery: string, + repos?: GitHubRepositoryDescriptor[], + cancellation?: CancellationToken, + ): Promise { + return (await this.container.github)?.searchPullRequests( + this, + accessToken, + { + search: searchQuery, + repos: repos?.map(r => `${r.owner}/${r.name}`), + baseUrl: this.apiBaseUrl, + }, + cancellation, + ); + } + + protected override async mergeProviderPullRequest( + { accessToken }: AuthenticationSession, + pr: PullRequest, + options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + const id = pr.nodeId; + const headRefSha = pr.refs?.head?.sha; + if (id == null || headRefSha == null) return false; + return ( + (await this.container.github)?.mergePullRequest(this, accessToken, id, headRefSha, { + mergeMethod: options?.mergeMethod, + }) ?? false + ); + } + + protected override async getProviderCurrentAccount( + { accessToken }: AuthenticationSession, + options?: { avatarSize?: number }, + ): Promise { + return (await this.container.github)?.getCurrentAccount(this, accessToken, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } +} + +export class GitHubIntegration extends GitHubIntegrationBase { + readonly authProvider = authProvider; + readonly id = HostingIntegrationId.GitHub; + protected readonly key = this.id; + readonly name: string = 'GitHub'; + get domain(): string { + return metadata.domain; + } + + protected override get apiBaseUrl(): string { + return 'https://api.github.com'; + } + + // This is a special case for GitHub because we use VSCode's GitHub session, and it can be disconnected + // outside of the extension. + override async refresh() { + const authProvider = await this.authenticationService.get(this.authProvider.id); + const session = await authProvider.getSession(this.authProviderDescriptor); + if (session == null && this.maybeConnected) { + void this.disconnect(); + } else { + if (session?.accessToken !== this._session?.accessToken) { + this._session = undefined; + } + super.refresh(); + } + } +} + +export class GitHubEnterpriseIntegration extends GitHubIntegrationBase { + readonly authProvider = enterpriseAuthProvider; + readonly id = SelfHostedIntegrationId.GitHubEnterprise; + protected readonly key = `${this.id}:${this.domain}` as const; + readonly name = 'GitHub Enterprise'; + get domain(): string { + return this._domain; + } + + protected override get apiBaseUrl(): string { + return `https://${this._domain}/api/v3`; + } + + constructor( + container: Container, + authenticationService: IntegrationAuthenticationService, + getProvidersApi: () => Promise, + private readonly _domain: string, + ) { + super(container, authenticationService, getProvidersApi); + } + + @log() + override async connect(source: Sources): Promise { + if ( + !(await ensurePaidPlan(this.container, `Rich integration with ${this.name} is a Pro feature.`, { + source: 'integrations', + detail: { action: 'connect', integration: this.id }, + })) + ) { + return false; + } + + return super.connect(source); + } +} diff --git a/src/plus/github/github.ts b/src/plus/integrations/providers/github/github.ts similarity index 64% rename from src/plus/github/github.ts rename to src/plus/integrations/providers/github/github.ts index c9c005242e015..ed9a6b6eaed86 100644 --- a/src/plus/github/github.ts +++ b/src/plus/integrations/providers/github/github.ts @@ -1,45 +1,47 @@ -import { Octokit } from '@octokit/core'; -import { GraphqlResponseError } from '@octokit/graphql'; +import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch'; +import { isWeb } from '@env/platform'; +import { graphql, GraphqlResponseError } from '@octokit/graphql'; +import { request } from '@octokit/request'; import { RequestError } from '@octokit/request-error'; import type { Endpoints, OctokitResponse, RequestParameters } from '@octokit/types'; import type { HttpsProxyAgent } from 'https-proxy-agent'; -import type { Event } from 'vscode'; -import { Disposable, EventEmitter, Uri, window } from 'vscode'; -import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch'; -import { isWeb } from '@env/platform'; -import { configuration } from '../../configuration'; -import { LogLevel } from '../../constants'; -import type { Container } from '../../container'; +import type { CancellationToken, Disposable, Event } from 'vscode'; +import { EventEmitter, Uri, window } from 'vscode'; +import type { Container } from '../../../../container'; import { AuthenticationError, AuthenticationErrorReason, - ProviderRequestClientError, - ProviderRequestNotFoundError, - ProviderRequestRateLimitError, -} from '../../errors'; -import type { PagedResult } from '../../git/gitProvider'; -import { RepositoryVisibility } from '../../git/gitProvider'; -import type { Account } from '../../git/models/author'; -import type { DefaultBranch } from '../../git/models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../../git/models/issue'; -import type { PullRequest, SearchedPullRequest } from '../../git/models/pullRequest'; -import { GitRevision } from '../../git/models/reference'; -import type { GitUser } from '../../git/models/user'; -import { getGitHubNoReplyAddressParts } from '../../git/remotes/github'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; -import { Logger } from '../../logger'; -import type { LogScope } from '../../logScope'; -import { getLogScope } from '../../logScope'; + CancellationError, + RequestClientError, + RequestNotFoundError, + RequestRateLimitError, +} from '../../../../errors'; +import type { PagedResult, RepositoryVisibility } from '../../../../git/gitProvider'; +import type { Account, UnidentifiedAuthor } from '../../../../git/models/author'; +import type { DefaultBranch } from '../../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../../git/models/issue'; +import type { PullRequest, SearchedPullRequest } from '../../../../git/models/pullRequest'; +import { PullRequestMergeMethod } from '../../../../git/models/pullRequest'; +import type { GitRevisionRange } from '../../../../git/models/reference'; +import { createRevisionRange, getRevisionRangeParts, isRevisionRange, isSha } from '../../../../git/models/reference'; +import type { Provider } from '../../../../git/models/remoteProvider'; +import type { RepositoryMetadata } from '../../../../git/models/repositoryMetadata'; +import type { GitUser } from '../../../../git/models/user'; +import { getGitHubNoReplyAddressParts } from '../../../../git/remotes/github'; import { showIntegrationRequestFailed500WarningMessage, showIntegrationRequestTimedOutWarningMessage, -} from '../../messages'; -import { uniqueBy } from '../../system/array'; -import { debug } from '../../system/decorators/log'; -import { Stopwatch } from '../../system/stopwatch'; -import { base64 } from '../../system/string'; -import type { Version } from '../../system/version'; -import { fromString, satisfies } from '../../system/version'; +} from '../../../../messages'; +import { debug } from '../../../../system/decorators/log'; +import { uniqueBy } from '../../../../system/iterable'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getLogScope } from '../../../../system/logger.scope'; +import { maybeStopWatch } from '../../../../system/stopwatch'; +import { base64 } from '../../../../system/string'; +import type { Version } from '../../../../system/version'; +import { fromString, satisfies } from '../../../../system/version'; +import { configuration } from '../../../../system/vscode/configuration'; import type { GitHubBlame, GitHubBlameRange, @@ -47,48 +49,176 @@ import type { GitHubCommit, GitHubCommitRef, GitHubContributor, - GitHubDetailedPullRequest, + GitHubIssue, GitHubIssueOrPullRequest, GitHubPagedResult, GitHubPageInfo, + GitHubPullRequest, + GitHubPullRequestLite, GitHubPullRequestState, GitHubTag, } from './models'; -import { GitHubDetailedIssue, GitHubPullRequest } from './models'; +import { + fromGitHubIssue, + fromGitHubIssueOrPullRequestState, + fromGitHubPullRequest, + fromGitHubPullRequestLite, +} from './models'; const emptyPagedResult: PagedResult = Object.freeze({ values: [] }); const emptyBlameResult: GitHubBlame = Object.freeze({ ranges: [] }); +const gqlIssueOrPullRequestFragment = ` +closed +closedAt +createdAt +id +number +state +title +updatedAt +url +`; +const gqlPullRequestLiteFragment = ` +${gqlIssueOrPullRequestFragment} +author { + login + avatarUrl(size: $avatarSize) + url +} +baseRefName +baseRefOid +headRefName +headRefOid +headRepository { + name + owner { + login + } + url +} +isCrossRepository +mergedAt +permalink +repository { + isFork + name + owner { + login + } + url + viewerPermission +} +`; +const gqlPullRequestFragment = ` +${gqlPullRequestLiteFragment} +additions +assignees(first: 10) { + nodes { + login + avatarUrl(size: $avatarSize) + url + } +} +checksUrl +deletions +isDraft +mergeable +mergedBy { + login +} +reviewDecision +latestReviews(first: 10) { + nodes { + author { + login + avatarUrl(size: $avatarSize) + url + } + state + } +} +reviewRequests(first: 10) { + nodes { + asCodeOwner + id + requestedReviewer { + ... on User { + login + avatarUrl(size: $avatarSize) + url + } + } + } +} +statusCheckRollup { + state +} +totalCommentsCount +viewerCanUpdate +`; + +const gqIssueFragment = ` +${gqlIssueOrPullRequestFragment} +assignees(first: 100) { + nodes { + login + url + avatarUrl(size: $avatarSize) + } +} +author { + login + avatarUrl + url +} +comments { + totalCount +} +labels(first: 20) { + nodes { + color + name + } +} +reactions(content: THUMBS_UP) { + totalCount +} +repository { + name + owner { + login + } + viewerPermission +} +`; + export class GitHubApi implements Disposable { private readonly _onDidReauthenticate = new EventEmitter(); get onDidReauthenticate(): Event { return this._onDidReauthenticate.event; } - private _disposable: Disposable | undefined; + private readonly _disposable: Disposable; constructor(_container: Container) { - this._disposable = Disposable.from( - configuration.onDidChange(e => { - if (configuration.changed(e, 'proxy') || configuration.changed(e, 'outputLevel')) { - this.resetCaches(); - } - }), - configuration.onDidChangeAny(e => { - if (e.affectsConfiguration('http.proxy') || e.affectsConfiguration('http.proxyStrictSSL')) { - this.resetCaches(); - } - }), - ); + this._disposable = configuration.onDidChangeAny(e => { + if ( + configuration.changedCore(e, ['http.proxy', 'http.proxyStrictSSL']) || + configuration.changed(e, ['outputLevel', 'proxy']) + ) { + this.resetCaches(); + } + }); } dispose(): void { - this._disposable?.dispose(); + this._disposable.dispose(); } private resetCaches(): void { this._proxyAgent = null; - this._octokits.clear(); + this._defaults.clear(); this._enterpriseVersions.clear(); } @@ -102,9 +232,68 @@ export class GitHubApi implements Disposable { return this._proxyAgent; } + async getCurrentAccount( + provider: Provider, + token: string, + options?: { + baseUrl?: string; + avatarSize?: number; + }, + ): Promise { + const scope = getLogScope(); + + interface QueryResult { + viewer: { + name: string | null; + email: string | null; + login: string | null; + avatarUrl: string | null; + }; + } + + try { + const query = `query getCurrentAccount($avatarSize: Int) { + viewer { + name + email + login + avatarUrl(size: $avatarSize) + } +}`; + + const rsp = await this.graphql(provider, token, query, { ...options }, scope); + if (rsp?.viewer?.login == null) return undefined; + + return { + provider: provider, + id: rsp.viewer.login, + name: rsp.viewer.name ?? undefined, + email: rsp.viewer.email ?? undefined, + // If we are GitHub Enterprise, we may need to convert the avatar URL since it might require authentication + avatarUrl: + !rsp.viewer.avatarUrl || isGitHubDotCom(options) + ? rsp.viewer.avatarUrl ?? undefined + : rsp.viewer.email && options?.baseUrl != null + ? await this.createEnterpriseAvatarUrl( + provider, + token, + options.baseUrl, + rsp.viewer.email, + options.avatarSize, + ) + : undefined, + username: rsp.viewer.login ?? undefined, + }; + } catch (ex) { + if (ex instanceof RequestNotFoundError) return undefined; + + throw this.handleException(ex, provider, scope); + } + } + @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -113,7 +302,7 @@ export class GitHubApi implements Disposable { baseUrl?: string; avatarSize?: number; }, - ): Promise { + ): Promise { const scope = getLogScope(); interface QueryResult { @@ -125,6 +314,9 @@ export class GitHubApi implements Disposable { name: string | null; email: string | null; avatarUrl: string; + user: { + login: string | null; + } | null; }; } | null @@ -148,6 +340,9 @@ export class GitHubApi implements Disposable { name email avatarUrl(size: $avatarSize) + user { + login + } } } } @@ -172,6 +367,15 @@ export class GitHubApi implements Disposable { return { provider: provider, + ...(author?.user?.login != null + ? { + id: author.user.login, + username: author.user.login, + } + : { + id: undefined, + username: undefined, + }), name: author.name ?? undefined, email: author.email ?? undefined, // If we are GitHub Enterprise, we may need to convert the avatar URL since it might require authentication @@ -179,17 +383,17 @@ export class GitHubApi implements Disposable { !author.avatarUrl || isGitHubDotCom(options) ? author.avatarUrl ?? undefined : author.email && options?.baseUrl != null - ? await this.createEnterpriseAvatarUrl( - provider, - token, - options.baseUrl, - author.email, - options.avatarSize, - ) - : undefined, + ? await this.createEnterpriseAvatarUrl( + provider, + token, + options.baseUrl, + author.email, + options.avatarSize, + ) + : undefined, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } @@ -197,7 +401,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForEmail( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -217,6 +421,7 @@ export class GitHubApi implements Disposable { name: string | null; email: string | null; avatarUrl: string; + login: string | null; }[] | null | undefined; @@ -236,6 +441,7 @@ export class GitHubApi implements Disposable { name email avatarUrl(size: $avatarSize) + login } } } @@ -255,10 +461,11 @@ export class GitHubApi implements Disposable { ); const author = rsp?.search?.nodes?.[0]; - if (author == null) return undefined; + if (author?.login == null) return undefined; return { provider: provider, + id: author.login, name: author.name ?? undefined, email: author.email ?? undefined, // If we are GitHub Enterprise, we may need to convert the avatar URL since it might require authentication @@ -266,17 +473,18 @@ export class GitHubApi implements Disposable { !author.avatarUrl || isGitHubDotCom(options) ? author.avatarUrl ?? undefined : author.email && options?.baseUrl != null - ? await this.createEnterpriseAvatarUrl( - provider, - token, - options.baseUrl, - author.email, - options.avatarSize, - ) - : undefined, + ? await this.createEnterpriseAvatarUrl( + provider, + token, + options.baseUrl, + author.email, + options.avatarSize, + ) + : undefined, + username: author.login ?? undefined, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } @@ -284,7 +492,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getDefaultBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -335,7 +543,7 @@ export class GitHubApi implements Disposable { name: defaultBranch, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } @@ -343,7 +551,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getIssueOrPullRequest( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -368,18 +576,10 @@ export class GitHubApi implements Disposable { issueOrPullRequest(number: $number) { __typename ... on Issue { - createdAt - closed - closedAt - title - url + ${gqlIssueOrPullRequestFragment} } ... on PullRequest { - createdAt - closed - closedAt - title - url + ${gqlIssueOrPullRequestFragment} } } } @@ -403,16 +603,79 @@ export class GitHubApi implements Disposable { return { provider: provider, - type: issue.type, - id: String(number), - date: new Date(issue.createdAt), + type: issue.__typename === 'PullRequest' ? 'pullrequest' : 'issue', + id: String(issue.number), + nodeId: issue.id, + createdDate: new Date(issue.createdAt), + updatedDate: new Date(issue.updatedAt), title: issue.title, closed: issue.closed, closedDate: issue.closedAt == null ? undefined : new Date(issue.closedAt), url: issue.url, + state: fromGitHubIssueOrPullRequestState(issue.state), }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; + + throw this.handleException(ex, provider, scope); + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getPullRequest( + provider: Provider, + token: string, + owner: string, + repo: string, + number: number, + options?: { + baseUrl?: string; + avatarSize?: number; + }, + ): Promise { + const scope = getLogScope(); + + interface QueryResult { + repository: + | { + pullRequest: GitHubPullRequestLite | null | undefined; + } + | null + | undefined; + } + + try { + const query = `query getPullRequest( + $owner: String! + $repo: String! + $number: Int! + $avatarSize: Int +) { + repository(name: $repo, owner: $owner) { + pullRequest(number: $number) { + ${gqlPullRequestFragment} + } + } +}`; + + const rsp = await this.graphql( + provider, + token, + query, + { + ...options, + owner: owner, + repo: repo, + number: number, + }, + scope, + ); + + if (rsp?.repository?.pullRequest == null) return undefined; + + return fromGitHubPullRequestLite(rsp.repository.pullRequest, provider); + } catch (ex) { + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } @@ -420,7 +683,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -436,13 +699,14 @@ export class GitHubApi implements Disposable { interface QueryResult { repository: | { - refs: { - nodes: { - associatedPullRequests?: { - nodes?: GitHubPullRequest[]; - }; - }[]; - }; + ref: + | { + associatedPullRequests?: { + nodes?: GitHubPullRequestLite[]; + }; + } + | null + | undefined; } | null | undefined; @@ -458,29 +722,10 @@ export class GitHubApi implements Disposable { $avatarSize: Int ) { repository(name: $repo, owner: $owner) { - refs(query: $branch, refPrefix: "refs/heads/", first: 1) { - nodes { - associatedPullRequests(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}, states: $include) { - nodes { - author { - login - avatarUrl(size: $avatarSize) - url - } - permalink - number - title - state - updatedAt - closedAt - mergedAt - repository { - isFork - owner { - login - } - } - } + ref(qualifiedName: $branch) { + associatedPullRequests(first: $limit, orderBy: {field: UPDATED_AT, direction: DESC}, states: $include) { + nodes { + ${gqlPullRequestLiteFragment} } } } @@ -495,7 +740,7 @@ export class GitHubApi implements Disposable { ...options, owner: owner, repo: repo, - branch: branch, + branch: `refs/heads/${branch}`, // Since GitHub sort doesn't seem to really work, look for a max of 10 PRs and then sort them ourselves limit: 10, }, @@ -503,7 +748,7 @@ export class GitHubApi implements Disposable { ); // If the pr is not from a fork, keep it e.g. show root pr's on forks, otherwise, ensure the repo owners match - const prs = rsp?.repository?.refs.nodes[0]?.associatedPullRequests?.nodes?.filter( + const prs = rsp?.repository?.ref?.associatedPullRequests?.nodes?.filter( pr => pr != null && (!pr.repository.isFork || pr.repository.owner.login === owner), ); if (prs == null || prs.length === 0) return undefined; @@ -517,9 +762,9 @@ export class GitHubApi implements Disposable { ); } - return GitHubPullRequest.from(prs[0], provider); + return fromGitHubPullRequestLite(prs[0], provider); } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } @@ -527,7 +772,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -536,6 +781,7 @@ export class GitHubApi implements Disposable { baseUrl?: string; avatarSize?: number; }, + cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); @@ -544,7 +790,7 @@ export class GitHubApi implements Disposable { | { object?: { associatedPullRequests?: { - nodes?: GitHubPullRequest[]; + nodes?: GitHubPullRequestLite[]; }; }; } @@ -564,24 +810,7 @@ export class GitHubApi implements Disposable { ... on Commit { associatedPullRequests(first: 2, orderBy: {field: UPDATED_AT, direction: DESC}) { nodes { - author { - login - avatarUrl(size: $avatarSize) - url - } - permalink - number - title - state - updatedAt - closedAt - mergedAt - repository { - isFork - owner { - login - } - } + ${gqlPullRequestLiteFragment} } } } @@ -600,6 +829,7 @@ export class GitHubApi implements Disposable { ref: ref, }, scope, + cancellation, ); // If the pr is not from a fork, keep it e.g. show root pr's on forks, otherwise, ensure the repo owners match @@ -617,9 +847,98 @@ export class GitHubApi implements Disposable { ); } - return GitHubPullRequest.from(prs[0], provider); + return fromGitHubPullRequestLite(prs[0], provider); } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; + + throw this.handleException(ex, provider, scope); + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getRepositoryMetadata( + provider: Provider, + token: string, + owner: string, + repo: string, + options?: { + baseUrl?: string; + }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + + interface QueryResult { + repository: + | { + owner: { + login: string; + }; + name: string; + parent: + | { + owner: { + login: string; + }; + name: string; + } + | null + | undefined; + } + | null + | undefined; + } + + try { + const query = `query getRepositoryMetadata( + $owner: String! + $repo: String! +) { + repository(name: $repo, owner: $owner) { + owner { + login + } + name + parent { + owner { + login + } + name + } + } +}`; + + const rsp = await this.graphql( + provider, + token, + query, + { + ...options, + owner: owner, + repo: repo, + }, + scope, + cancellation, + ); + + const r = rsp?.repository ?? undefined; + if (r == null) return undefined; + + return { + provider: provider, + owner: r.owner.login, + name: r.name, + isFork: r.parent != null, + parent: + r.parent != null + ? { + owner: r.parent.owner.login, + name: r.parent.name, + } + : undefined, + }; + } catch (ex) { + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } @@ -702,7 +1021,7 @@ export class GitHubApi implements Disposable { return { ranges: ranges, viewer: rsp.viewer?.name }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return emptyBlameResult; + if (ex instanceof RequestNotFoundError) return emptyBlameResult; throw this.handleException(ex, undefined, scope); } @@ -741,7 +1060,7 @@ export class GitHubApi implements Disposable { $limit: Int = 100 ) { repository(owner: $owner, name: $repo) { - refs(query: $branchQuery, refPrefix: "refs/heads/", first: $limit, after: $cursor, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) { + refs(query: $branchQuery, refPrefix: "refs/heads/", first: $limit, after: $cursor) { pageInfo { endCursor hasNextPage @@ -750,7 +1069,6 @@ export class GitHubApi implements Disposable { name target { oid - commitUrl ...on Commit { authoredDate committedDate @@ -787,7 +1105,7 @@ export class GitHubApi implements Disposable { values: refs.nodes, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return emptyPagedResult; + if (ex instanceof RequestNotFoundError) return emptyPagedResult; throw this.handleException(ex, undefined, scope); } @@ -840,7 +1158,7 @@ export class GitHubApi implements Disposable { files: result.files, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, undefined, scope); } @@ -859,7 +1177,7 @@ export class GitHubApi implements Disposable { ref: string, path: string, ): Promise<(GitHubCommit & { viewer?: string }) | undefined> { - if (GitRevision.isSha(ref)) return this.getCommit(token, owner, repo, ref); + if (isSha(ref)) return this.getCommit(token, owner, repo, ref); // TODO: optimize this -- only need to get the sha for the ref const results = await this.getCommits(token, owner, repo, ref, { limit: 1, path: path }); @@ -870,7 +1188,14 @@ export class GitHubApi implements Disposable { } @debug({ args: { 0: '' } }) - async getCommitBranches(token: string, owner: string, repo: string, ref: string, date: Date): Promise { + async getCommitBranches( + token: string, + owner: string, + repo: string, + refs: string[], + mode: 'contains' | 'pointsAt', + date?: Date, + ): Promise { const scope = getLogScope(); interface QueryResult { @@ -888,6 +1213,8 @@ export class GitHubApi implements Disposable { }; } + const limit = mode === 'contains' ? 10 : 1; + try { const query = `query getCommitBranches( $owner: String! @@ -896,12 +1223,12 @@ export class GitHubApi implements Disposable { $until: GitTimestamp! ) { repository(owner: $owner, name: $repo) { - refs(first: 20, refPrefix: "refs/heads/", orderBy: { field: TAG_COMMIT_DATE, direction: DESC }) { + refs(first: 20, refPrefix: "refs/heads/") { nodes { name target { ... on Commit { - history(first: 3, since: $since until: $until) { + history(first: ${limit}, since: $since until: $until) { nodes { oid } } } @@ -917,8 +1244,8 @@ export class GitHubApi implements Disposable { { owner: owner, repo: repo, - since: date.toISOString(), - until: date.toISOString(), + since: date?.toISOString(), + until: date?.toISOString(), }, scope, ); @@ -930,7 +1257,7 @@ export class GitHubApi implements Disposable { for (const branch of nodes) { for (const commit of branch.target.history.nodes) { - if (commit.oid === ref) { + if (refs.includes(commit.oid)) { branches.push(branch.name); break; } @@ -939,7 +1266,7 @@ export class GitHubApi implements Disposable { return branches; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return []; + if (ex instanceof RequestNotFoundError) return []; throw this.handleException(ex, undefined, scope); } @@ -993,7 +1320,7 @@ export class GitHubApi implements Disposable { const count = rsp?.repository?.ref?.target.history.totalCount; return count; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, undefined, scope); } @@ -1005,8 +1332,9 @@ export class GitHubApi implements Disposable { owner: string, repo: string, branch: string, - ref: string, - date: Date, + refs: string[], + mode: 'contains' | 'pointsAt', + date?: Date, ): Promise { const scope = getLogScope(); @@ -1021,6 +1349,9 @@ export class GitHubApi implements Disposable { }; }; } + + const limit = mode === 'contains' ? 100 : 1; + try { const query = `query getCommitOnBranch( $owner: String! @@ -1033,7 +1364,7 @@ export class GitHubApi implements Disposable { ref(qualifiedName: $ref) { target { ... on Commit { - history(first: 3, since: $since until: $until) { + history(first: ${limit}, since: $since until: $until) { nodes { oid } } } @@ -1049,8 +1380,8 @@ export class GitHubApi implements Disposable { owner: owner, repo: repo, ref: `refs/heads/${branch}`, - since: date.toISOString(), - until: date.toISOString(), + since: date?.toISOString(), + until: date?.toISOString(), }, scope, ); @@ -1061,7 +1392,7 @@ export class GitHubApi implements Disposable { const branches = []; for (const commit of nodes) { - if (commit.oid === ref) { + if (refs.includes(commit.oid)) { branches.push(branch); break; } @@ -1069,7 +1400,7 @@ export class GitHubApi implements Disposable { return branches; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return []; + if (ex instanceof RequestNotFoundError) return []; throw this.handleException(ex, undefined, scope); } @@ -1098,6 +1429,10 @@ export class GitHubApi implements Disposable { return this.getCommitsCoreSingle(token, owner, repo, ref); } + if (isRevisionRange(ref)) { + return this.getCommitsCoreRange(token, owner, repo, ref); + } + interface QueryResult { viewer: { name: string }; repository: @@ -1214,7 +1549,46 @@ export class GitHubApi implements Disposable { viewer: rsp?.viewer.name, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return emptyPagedResult; + if (ex instanceof RequestNotFoundError) return emptyPagedResult; + + throw this.handleException(ex, undefined, scope); + } + } + + private async getCommitsCoreRange( + token: string, + owner: string, + repo: string, + range: GitRevisionRange, + ): Promise & { viewer?: string }> { + const scope = getLogScope(); + + try { + const result = await this.getComparison(token, owner, repo, range); + if (result == null) return emptyPagedResult; + + return { + values: result.commits + ?.map(r => ({ + oid: r.sha, + parents: { nodes: r.parents.map(p => ({ oid: p.sha })) }, + message: r.commit.message, + author: { + avatarUrl: r.author?.avatar_url ?? undefined, + date: r.commit.author?.date ?? r.commit.author?.date ?? new Date().toString(), + email: r.author?.email ?? r.commit.author?.email ?? undefined, + name: r.author?.name ?? r.commit.author?.name ?? '', + }, + committer: { + date: r.commit.committer?.date ?? new Date().toString(), + email: r.committer?.email ?? r.commit.committer?.email ?? undefined, + name: r.committer?.name ?? r.commit.committer?.name ?? '', + }, + })) + .reverse(), + }; + } catch (ex) { + if (ex instanceof RequestNotFoundError) return emptyPagedResult; throw this.handleException(ex, undefined, scope); } @@ -1281,7 +1655,7 @@ export class GitHubApi implements Disposable { const commit = rsp.repository?.object; return commit != null ? { values: [commit], viewer: rsp.viewer.name } : emptyPagedResult; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return emptyPagedResult; + if (ex instanceof RequestNotFoundError) return emptyPagedResult; throw this.handleException(ex, undefined, scope); } @@ -1376,7 +1750,83 @@ export class GitHubApi implements Disposable { values: history.nodes, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; + + throw this.handleException(ex, undefined, scope); + } + } + + @debug({ args: { 0: '' } }) + async getCommitTags(token: string, owner: string, repo: string, ref: string, date: Date): Promise { + const scope = getLogScope(); + + interface QueryResult { + repository: { + refs: { + nodes: { + name: string; + target: { + history: { + nodes: { oid: string }[]; + }; + }; + }[]; + }; + }; + } + + try { + const query = `query getCommitTags( + $owner: String! + $repo: String! + $since: GitTimestamp! + $until: GitTimestamp! +) { + repository(owner: $owner, name: $repo) { + refs(first: 20, refPrefix: "refs/tags/") { + nodes { + name + target { + ... on Commit { + history(first: 3, since: $since until: $until) { + nodes { oid } + } + } + } + } + } + } +}`; + const rsp = await this.graphql( + undefined, + token, + query, + { + owner: owner, + repo: repo, + since: date.toISOString(), + until: date.toISOString(), + }, + scope, + ); + + const nodes = rsp?.repository?.refs?.nodes; + if (nodes == null) return []; + + const tags = []; + + for (const tag of nodes) { + for (const commit of tag.target.history.nodes) { + if (commit.oid === ref) { + tags.push(tag.name); + break; + } + } + } + + return tags; + } catch (ex) { + if (ex instanceof RequestNotFoundError) return []; throw this.handleException(ex, undefined, scope); } @@ -1461,7 +1911,7 @@ export class GitHubApi implements Disposable { const date = rsp?.repository?.object?.committer.date; return date; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, undefined, scope); } @@ -1491,7 +1941,7 @@ export class GitHubApi implements Disposable { return rsp.data; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return []; + if (ex instanceof RequestNotFoundError) return []; throw this.handleException(ex, undefined, scope); } @@ -1536,7 +1986,7 @@ export class GitHubApi implements Disposable { return rsp.repository?.defaultBranchRef?.name ?? undefined; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, undefined, scope); } @@ -1577,14 +2027,53 @@ export class GitHubApi implements Disposable { ); if (rsp == null) return undefined; - return { - name: rsp.viewer?.name, - email: rsp.viewer?.email, - username: rsp.viewer?.login, - id: rsp.viewer?.id, - }; + return { + name: rsp.viewer?.name, + email: rsp.viewer?.email, + username: rsp.viewer?.login, + id: rsp.viewer?.id, + }; + } catch (ex) { + if (ex instanceof RequestNotFoundError) return undefined; + + throw this.handleException(ex, undefined, scope); + } + } + + @debug({ args: { 0: '' } }) + async getComparison( + token: string, + owner: string, + repo: string, + range: GitRevisionRange, + ): Promise { + const scope = getLogScope(); + + if (!isRevisionRange(range, 'qualified-triple-dot')) { + // GitHub doesn't support the `..` range notation, so convert it to `...` since it will work for many of our usages + const parts = getRevisionRangeParts(range); + range = createRevisionRange(parts?.left || 'HEAD', parts?.right || 'HEAD', '...'); + } + + try { + const rsp = await this.request( + undefined, + token, + 'GET /repos/{owner}/{repo}/compare/{basehead}', + { + owner: owner, + repo: repo, + basehead: range, + }, + scope, + ); + + const result = rsp?.data; + if (result == null) return undefined; + + return result; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, undefined, scope); } @@ -1629,9 +2118,9 @@ export class GitHubApi implements Disposable { ); if (rsp?.repository?.visibility == null) return undefined; - return rsp.repository.visibility === 'PUBLIC' ? RepositoryVisibility.Public : RepositoryVisibility.Private; + return rsp.repository.visibility === 'PUBLIC' ? 'public' : 'private'; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, undefined, scope); } @@ -1679,15 +2168,17 @@ export class GitHubApi implements Disposable { name target { oid - commitUrl + ...on Tag { + message + tagger { date } + target { ...on Commit { + oid authoredDate committedDate message } - ...on Tag { - message - tagger { date } + } } } } @@ -1721,7 +2212,7 @@ export class GitHubApi implements Disposable { values: refs.nodes, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return emptyPagedResult; + if (ex instanceof RequestNotFoundError) return emptyPagedResult; throw this.handleException(ex, undefined, scope); } @@ -1813,7 +2304,7 @@ export class GitHubApi implements Disposable { ); return rsp?.repository?.object?.history.nodes?.[0]?.oid ?? undefined; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, undefined, scope); } @@ -1898,7 +2389,7 @@ export class GitHubApi implements Disposable { values: commits, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, undefined, scope); } @@ -1970,7 +2461,7 @@ export class GitHubApi implements Disposable { })), }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, undefined, scope); } @@ -1978,9 +2469,9 @@ export class GitHubApi implements Disposable { private _enterpriseVersions = new Map(); - @debug({ args: { 0: '' } }) + @debug({ args: { 0: p => p?.name, 1: '' } }) private async getEnterpriseVersion( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, options?: { baseUrl?: string }, ): Promise { @@ -1992,9 +2483,9 @@ export class GitHubApi implements Disposable { try { const rsp = await this.request(provider, token, 'GET /meta', options, scope); - const v = (rsp?.data as any)?.installed_version as string | null | undefined; + const v = (rsp?.data as unknown as { installed_version: string | null | undefined })?.installed_version; version = v ? fromString(v) : null; - } catch (ex) { + } catch (_ex) { debugger; version = null; } @@ -2003,72 +2494,36 @@ export class GitHubApi implements Disposable { return version ?? undefined; } - private _octokits = new Map(); - private octokit(token: string, options?: ConstructorParameters[0]): Octokit { - let octokit = this._octokits.get(token); - if (octokit == null) { - let defaults; - if (isWeb) { - function fetchCore(url: string, options: { headers?: Record }) { - if (options.headers != null) { - // Strip out the user-agent (since it causes warnings in a webworker) - const { 'user-agent': userAgent, ...headers } = options.headers; - if (userAgent) { - options.headers = headers; - } - } - return fetch(url, options); - } - - defaults = Octokit.defaults({ - auth: `token ${token}`, - request: { fetch: fetchCore }, - }); - } else { - defaults = Octokit.defaults({ auth: `token ${token}`, request: { agent: this.proxyAgent } }); - } - - octokit = new defaults(options); - this._octokits.set(token, octokit); - - if (Logger.logLevel === LogLevel.Debug || Logger.isDebugging) { - octokit.hook.wrap('request', async (request, options) => { - const stopwatch = new Stopwatch(`[GITHUB] ${options.method} ${options.url}`, { log: false }); - try { - return await request(options); - } finally { - let message; - try { - if (typeof options.query === 'string') { - const match = /(^[^({\n]+)/.exec(options.query); - message = ` ${match?.[1].trim() ?? options.query}`; - } - } catch {} - stopwatch.stop({ message: message }); - } - }); - } - } - - return octokit; - } - private async graphql( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, query: string, - variables: { [key: string]: any }, + variables: RequestParameters, scope: LogScope | undefined, + cancellation?: CancellationToken | undefined, ): Promise { try { + let aborter: AbortController | undefined; + if (cancellation != null) { + if (cancellation.isCancellationRequested) throw new CancellationError(); + + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter!.abort()); + + variables = { + ...variables, + request: { ...variables?.request, signal: aborter.signal }, + }; + } + return await wrapForForcedInsecureSSL(provider?.getIgnoreSSLErrors() ?? false, () => - this.octokit(token).graphql(query, variables), + this.getDefaults(token, graphql)(query, variables), ); } catch (ex) { if (ex instanceof GraphqlResponseError) { switch (ex.errors?.[0]?.type) { case 'NOT_FOUND': - throw new ProviderRequestNotFoundError(ex); + throw new RequestNotFoundError(ex); case 'FORBIDDEN': throw new AuthenticationError('github', AuthenticationErrorReason.Forbidden, ex); case 'RATE_LIMITED': { @@ -2082,14 +2537,14 @@ export class GitHubApi implements Disposable { } } - throw new ProviderRequestRateLimitError(ex, token, resetAt); + throw new RequestRateLimitError(ex, token, resetAt); } } if (Logger.isDebugging) { void window.showErrorMessage(`GitHub request failed: ${ex.errors?.[0]?.message ?? ex.message}`); } - } else if (ex instanceof RequestError) { + } else if (ex instanceof RequestError || ex.name === 'AbortError') { this.handleRequestError(provider, token, ex, scope); } else if (Logger.isDebugging) { void window.showErrorMessage(`GitHub request failed: ${ex.message}`); @@ -2100,20 +2555,37 @@ export class GitHubApi implements Disposable { } private async request( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, route: keyof Endpoints | R, options: | (R extends keyof Endpoints ? Endpoints[R]['parameters'] & RequestParameters : RequestParameters) | undefined, scope: LogScope | undefined, - ): Promise> { + cancellation?: CancellationToken | undefined, + ): Promise> { try { - return (await wrapForForcedInsecureSSL(provider?.getIgnoreSSLErrors() ?? false, () => - this.octokit(token).request(route, options), - )) as R extends keyof Endpoints ? Endpoints[R]['response'] : OctokitResponse; + let aborter: AbortController | undefined; + if (cancellation != null) { + if (cancellation.isCancellationRequested) throw new CancellationError(); + + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter!.abort()); + options = { + ...options, + request: { ...options?.request, signal: aborter.signal }, + } as unknown as typeof options; + } + + return await wrapForForcedInsecureSSL( + provider?.getIgnoreSSLErrors() ?? false, + () => + this.getDefaults(token, request)(route as R, options) as unknown as Promise< + R extends keyof Endpoints ? Endpoints[R]['response'] : OctokitResponse + >, + ); } catch (ex) { - if (ex instanceof RequestError) { + if (ex instanceof RequestError || ex.name === 'AbortError') { this.handleRequestError(provider, token, ex, scope); } else if (Logger.isDebugging) { void window.showErrorMessage(`GitHub request failed: ${ex.message}`); @@ -2123,17 +2595,80 @@ export class GitHubApi implements Disposable { } } + private _defaults = new Map>(); + private getDefaults(token: string, rqst: typeof request): typeof request; + private getDefaults(token: string, gql: typeof graphql): typeof graphql; + private getDefaults( + token: string, + requestOrGraphQL: typeof request | typeof graphql, + ): typeof request | typeof graphql { + let map = this._defaults.get(requestOrGraphQL); + if (map == null) { + map = new Map(); + this._defaults.set(requestOrGraphQL, map); + } + + let defaults = map.get(token); + if (defaults == null) { + defaults = requestOrGraphQL.defaults({ + headers: { + authorization: `token ${token}`, + }, + request: { + agent: this.proxyAgent, + fetch: isWeb + ? (url: string, options: { headers?: Record }) => { + if (options.headers != null) { + // Strip out the user-agent (since it causes warnings in a webworker) + const { 'user-agent': userAgent, ...headers } = options.headers; + if (userAgent) { + options.headers = headers; + } + } + return fetch(url, options); + } + : fetch, + hook: + Logger.logLevel === 'debug' || Logger.isDebugging + ? async (rqst: typeof request, options: any) => { + const sw = maybeStopWatch(`[GITHUB] ${options.method} ${options.url}`, { + log: false, + }); + try { + return await rqst(options); + } finally { + let message; + try { + if (typeof options.query === 'string') { + const match = /(^[^({\n]+)/.exec(options.query); + message = ` ${match?.[1].trim() ?? options.query}`; + } + } catch {} + sw?.stop({ message: message }); + } + } + : undefined, + }, + }); + map.set(token, defaults); + } + + return defaults; + } + private handleRequestError( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, - ex: RequestError, + ex: RequestError | (Error & { name: 'AbortError' }), scope: LogScope | undefined, ): void { + if (ex.name === 'AbortError') throw new CancellationError(ex); + switch (ex.status) { case 404: // Not found case 410: // Gone case 422: // Unprocessable Entity - throw new ProviderRequestNotFoundError(ex); + throw new RequestNotFoundError(ex); // case 429: //Too Many Requests case 401: // Unauthorized throw new AuthenticationError('github', AuthenticationErrorReason.Unauthorized, ex); @@ -2149,7 +2684,7 @@ export class GitHubApi implements Disposable { } } - throw new ProviderRequestRateLimitError(ex, token, resetAt); + throw new RequestRateLimitError(ex, token, resetAt); } throw new AuthenticationError('github', AuthenticationErrorReason.Forbidden, ex); case 500: // Internal Server Error @@ -2158,7 +2693,7 @@ export class GitHubApi implements Disposable { provider?.trackRequestException(); void showIntegrationRequestFailed500WarningMessage( `${provider?.name ?? 'GitHub'} failed to respond and might be experiencing issues.${ - !provider?.custom + provider == null || provider.id === 'github' ? ' Please visit the [GitHub status page](https://githubstatus.com) for more information.' : '' }`, @@ -2174,8 +2709,19 @@ export class GitHubApi implements Disposable { return; } break; + case 503: // Service Unavailable + Logger.error(ex, scope); + provider?.trackRequestException(); + void showIntegrationRequestFailed500WarningMessage( + `${provider?.name ?? 'GitHub'} failed to respond and might be experiencing issues.${ + provider == null || provider.id === 'github' + ? ' Please visit the [GitHub status page](https://githubstatus.com) for more information.' + : '' + }`, + ); + return; default: - if (ex.status >= 400 && ex.status < 500) throw new ProviderRequestClientError(ex); + if (ex.status >= 400 && ex.status < 500) throw new RequestClientError(ex); break; } @@ -2187,17 +2733,22 @@ export class GitHubApi implements Disposable { } } - private handleException(ex: Error, provider: RichRemoteProvider | undefined, scope: LogScope | undefined): Error { + private handleException( + ex: Error, + provider: Provider | undefined, + scope: LogScope | undefined, + silent?: boolean, + ): Error { Logger.error(ex, scope); // debugger; - if (ex instanceof AuthenticationError) { + if (ex instanceof AuthenticationError && !silent) { void this.showAuthenticationErrorMessage(ex, provider); } return ex; } - private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: RichRemoteProvider | undefined) { + private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: Provider | undefined) { if (ex.reason === AuthenticationErrorReason.Unauthorized || ex.reason === AuthenticationErrorReason.Forbidden) { const confirm = 'Reauthenticate'; const result = await window.showErrorMessage( @@ -2209,7 +2760,7 @@ export class GitHubApi implements Disposable { if (result === confirm) { await provider?.reauthenticate(); - + this.resetCaches(); this._onDidReauthenticate.fire(); } } else { @@ -2218,7 +2769,7 @@ export class GitHubApi implements Disposable { } private async createEnterpriseAvatarUrl( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, baseUrl: string, email: string, @@ -2246,7 +2797,7 @@ export class GitHubApi implements Disposable { } const rsp = await wrapForForcedInsecureSSL(provider?.getIgnoreSSLErrors() ?? false, () => - fetch(url!, { method: 'GET', headers: { Authorization: `Bearer ${token}` } }), + fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${token}` } }), ); if (rsp.ok) { @@ -2262,63 +2813,47 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async searchMyPullRequests( - provider: RichRemoteProvider, + provider: Provider, token: string, - options?: { search?: string; user?: string; repos?: string[] }, + options?: { + search?: string; + user?: string; + repos?: string[]; + baseUrl?: string; + avatarSize?: number; + silent?: boolean; + }, + cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); - interface SearchResult { - related: { - nodes: GitHubDetailedPullRequest[]; - }; - authored: { - nodes: GitHubDetailedPullRequest[]; - }; - assigned: { - nodes: GitHubDetailedPullRequest[]; - }; - reviewRequested: { - nodes: GitHubDetailedPullRequest[]; - }; - mentioned: { - nodes: GitHubDetailedPullRequest[]; - }; - } + const limit = Math.min(100, configuration.get('launchpad.experimental.queryLimit') ?? 100); + try { - const query = `query searchPullRequests( - $authored: String! - $assigned: String! - $reviewRequested: String! - $mentioned: String! -) { - authored: search(first: 100, query: $authored, type: ISSUE) { - nodes { - ...on PullRequest { - ${prNodeProperties} - } - } - } - assigned: search(first: 100, query: $assigned, type: ISSUE) { - nodes { - ...on PullRequest { - ${prNodeProperties} + interface SearchResult { + search: { + issueCount: number; + nodes: GitHubPullRequest[]; + }; + viewer: { + login: string; + }; } - } - } - reviewRequested: search(first: 100, query: $reviewRequested, type: ISSUE) { + + const query = `query searchMyPullRequests( + $search: String! + $avatarSize: Int +) { + search(first: ${limit}, query: $search, type: ISSUE) { + issueCount nodes { ...on PullRequest { - ${prNodeProperties} + ${gqlPullRequestFragment} } } } - mentioned: search(first: 100, query: $mentioned, type: ISSUE) { - nodes { - ...on PullRequest { - ${prNodeProperties} - } - } + viewer { + login } }`; @@ -2328,88 +2863,112 @@ export class GitHubApi implements Disposable { search += ` user:${options.user}`; } - if (options?.repos != null && options.repos.length > 0) { - const repo = ' repo:'; - search += `${repo}${options.repos.join(repo)}`; + if (options?.repos?.length) { + search += ` repo:${options.repos.join(' repo:')}`; } - const baseFilters = 'is:pr is:open archived:false'; - const resp = await this.graphql( - undefined, + // Hack for now, ultimately this should be passed in + const ignoredRepos = configuration.get('launchpad.ignoredRepositories') ?? []; + if (ignoredRepos.length) { + search += ` -repo:${ignoredRepos.join(' -repo:')}`; + } + + // Hack for now, ultimately this should be passed in + const ignoredOrgs = configuration.get('launchpad.ignoredOrganizations') ?? []; + if (ignoredOrgs.length) { + search += ` -org:${ignoredOrgs.join(' -org:')}`; + } + + const rsp = await this.graphql( + provider, token, query, { - authored: `${search} ${baseFilters} author:@me`.trim(), - assigned: `${search} ${baseFilters} assignee:@me`.trim(), - reviewRequested: `${search} ${baseFilters} review-requested:@me`.trim(), - mentioned: `${search} ${baseFilters} mentions:@me`.trim(), + search: `is:open is:pr involves:@me archived:false ${search}`.trim(), + baseUrl: options?.baseUrl, + avatarSize: options?.avatarSize, }, scope, + cancellation, ); - if (resp === undefined) return []; + if (rsp == null) return []; + + const viewer = rsp.viewer.login; + + function toQueryResult(pr: GitHubPullRequest): SearchedPullRequest { + const reasons = []; + if (pr.author.login === viewer) { + reasons.push('authored'); + } + if (pr.assignees.nodes.some(a => a.login === viewer)) { + reasons.push('assigned'); + } + if (pr.reviewRequests.nodes.some(r => r.requestedReviewer?.login === viewer)) { + reasons.push('review-requested'); + } + if (reasons.length === 0) { + reasons.push('mentioned'); + } - function toQueryResult(pr: GitHubDetailedPullRequest, reason?: string): SearchedPullRequest { return { - pullRequest: GitHubPullRequest.fromDetailed(pr, provider), - reasons: reason ? [reason] : [], + pullRequest: fromGitHubPullRequest(pr, provider), + reasons: reasons, }; } - const results: SearchedPullRequest[] = uniqueWithReasons( - [ - ...resp.assigned.nodes.map(pr => toQueryResult(pr, 'assigned')), - ...resp.reviewRequested.nodes.map(pr => toQueryResult(pr, 'review-requested')), - ...resp.mentioned.nodes.map(pr => toQueryResult(pr, 'mentioned')), - ...resp.authored.nodes.map(pr => toQueryResult(pr, 'authored')), - ], - r => r.pullRequest.url, - ); + const results: SearchedPullRequest[] = rsp.search.nodes.map(pr => toQueryResult(pr)); return results; } catch (ex) { - throw this.handleException(ex, undefined, scope); + throw this.handleException(ex, provider, scope, options?.silent); } } - @debug({ args: { 0: '' } }) + @debug({ args: { 0: p => p.name, 1: '' } }) async searchMyIssues( - provider: RichRemoteProvider, + provider: Provider, token: string, - options?: { search?: string; user?: string; repos?: string[] }, + options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string; avatarSize?: number }, + cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); + interface SearchResult { - related: { - nodes: GitHubDetailedIssue[]; - }; authored: { - nodes: GitHubDetailedIssue[]; + nodes: GitHubIssue[]; }; assigned: { - nodes: GitHubDetailedIssue[]; + nodes: GitHubIssue[]; }; mentioned: { - nodes: GitHubDetailedIssue[]; + nodes: GitHubIssue[]; }; } - const query = `query searchIssues( + const query = `query searchMyIssues( $authored: String! $assigned: String! $mentioned: String! + $avatarSize: Int ) { authored: search(first: 100, query: $authored, type: ISSUE) { nodes { - ${issueNodeProperties} + ... on Issue { + ${gqIssueFragment} + } } } assigned: search(first: 100, query: $assigned, type: ISSUE) { nodes { - ${issueNodeProperties} + ... on Issue { + ${gqIssueFragment} + } } } mentioned: search(first: 100, query: $mentioned, type: ISSUE) { nodes { - ${issueNodeProperties} + ... on Issue { + ${gqIssueFragment} + } } } }`; @@ -2427,168 +2986,180 @@ export class GitHubApi implements Disposable { const baseFilters = 'type:issue is:open archived:false'; try { - const resp = await this.graphql( - undefined, + const rsp = await this.graphql( + provider, token, query, { authored: `${search} ${baseFilters} author:@me`.trim(), assigned: `${search} ${baseFilters} assignee:@me`.trim(), mentioned: `${search} ${baseFilters} mentions:@me`.trim(), + baseUrl: options?.baseUrl, + avatarSize: options?.avatarSize, }, scope, + cancellation, ); - function toQueryResult(issue: GitHubDetailedIssue, reason?: string): SearchedIssue { + function toQueryResult(issue: GitHubIssue, reason?: string): SearchedIssue { return { - issue: GitHubDetailedIssue.from(issue, provider), + issue: fromGitHubIssue(issue, provider), reasons: reason ? [reason] : [], }; } - if (resp === undefined) return []; + if (rsp == null) return []; const results: SearchedIssue[] = uniqueWithReasons( [ - ...resp.assigned.nodes.map(pr => toQueryResult(pr, 'assigned')), - ...resp.mentioned.nodes.map(pr => toQueryResult(pr, 'mentioned')), - ...resp.authored.nodes.map(pr => toQueryResult(pr, 'authored')), + ...rsp.assigned.nodes.map(pr => toQueryResult(pr, 'assigned')), + ...rsp.mentioned.nodes.map(pr => toQueryResult(pr, 'mentioned')), + ...rsp.authored.nodes.map(pr => toQueryResult(pr, 'authored')), ], r => r.issue.url, ); return results; } catch (ex) { - throw this.handleException(ex, undefined, scope); + throw this.handleException(ex, provider, scope); } } -} -function isGitHubDotCom(options?: { baseUrl?: string }) { - return options?.baseUrl == null || options.baseUrl === 'https://api.github.com'; -} + @debug({ args: { 0: p => p.name, 1: '' } }) + async searchPullRequests( + provider: Provider, + token: string, + options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string; avatarSize?: number }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); -function uniqueWithReasons(items: T[], lookup: (item: T) => unknown): T[] { - return uniqueBy(items, lookup, (original, current) => { - if (current.reasons.length !== 0) { - original.reasons.push(...current.reasons); + interface SearchResult { + nodes: GitHubPullRequest[]; } - return original; - }); -} -const prNodeProperties = ` -assignees(first: 10) { - nodes { - login - avatarUrl - url - } -} -author { - login - avatarUrl - url -} -baseRefName -baseRefOid -baseRepository { - name - owner { - login - } -} -checksUrl -isDraft -isCrossRepository -isReadByViewer -headRefName -headRefOid -headRepository { - name - owner { - login - } -} -permalink -number -title -state -additions -deletions -updatedAt -closedAt -mergeable -mergedAt -mergedBy { - login -} -repository { - isFork - owner { - login - } -} -repository { - isFork - owner { - login - } -} -reviewDecision -reviewRequests(first: 10) { - nodes { - asCodeOwner - id - requestedReviewer { - ... on User { - login - avatarUrl - url - } - } - } -} -totalCommentsCount -`; - -const issueNodeProperties = ` -... on Issue { - assignees(first: 100) { + try { + const query = `query searchPullRequests( + $searchQuery: String! + $avatarSize: Int +) { + search(first: 100, query: $searchQuery, type: ISSUE) { nodes { - login - url - avatarUrl + ...on PullRequest { + ${gqlPullRequestFragment} + } } } - author { - login - avatarUrl - url - } - comments { - totalCount - } - number - title - url - createdAt - closedAt - closed - updatedAt - labels(first: 20) { - nodes { - color - name +}`; + + let search = options?.search?.trim() ?? ''; + + if (options?.user) { + search += ` user:${options.user}`; + } + + if (options?.repos != null && options.repos.length > 0) { + const repo = ' repo:'; + search += `${repo}${options.repos.join(repo)}`; + } + + const rsp = await this.graphql( + provider, + token, + query, + { + searchQuery: `is:pr is:open archived:false ${search.trim()}`, + baseUrl: options?.baseUrl, + avatarSize: options?.avatarSize, + }, + scope, + cancellation, + ); + if (rsp == null) return []; + + const results = rsp.nodes.map(pr => fromGitHubPullRequest(pr, provider)); + return results; + } catch (ex) { + throw this.handleException(ex, provider, scope); } } - reactions(content: THUMBS_UP) { - totalCount + + @debug({ args: { 0: p => p.name, 1: '' } }) + async mergePullRequest( + provider: Provider, + token: string, + nodeId: string, + expectedSourceSha: string, + options?: { mergeMethod?: PullRequestMergeMethod; baseUrl?: string }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + interface QueryResult { + pullRequest: GitHubPullRequestLite | null | undefined; + } + + let githubMergeStrategy; + switch (options?.mergeMethod) { + case PullRequestMergeMethod.Merge: { + githubMergeStrategy = 'MERGE'; + break; + } + + case PullRequestMergeMethod.Rebase: { + githubMergeStrategy = 'REBASE'; + break; + } + + case PullRequestMergeMethod.Squash: { + githubMergeStrategy = 'SQUASH'; + break; + } + } + + try { + const query = `mutation mergePullRequest( + $id: ID! + $expectedSourceSha: GitObjectID! + $mergeMethod: PullRequestMergeMethod +) { + mergePullRequest(input: { pullRequestId: $id, expectedHeadOid: $expectedSourceSha, mergeMethod: $mergeMethod }) { + pullRequest { + id + } } - repository { - name - owner { - login +}`; + + const rsp = await this.graphql( + provider, + token, + query, + { + id: nodeId, + expectedSourceSha: expectedSourceSha, + mergeMethod: githubMergeStrategy, + baseUrl: options?.baseUrl, + }, + scope, + cancellation, + ); + + return rsp?.pullRequest?.id === nodeId; + } catch (ex) { + throw this.handleException(ex, provider, scope); } } } -`; + +function isGitHubDotCom(options?: { baseUrl?: string }) { + return options?.baseUrl == null || options.baseUrl === 'https://api.github.com'; +} + +function uniqueWithReasons(items: T[], lookup: (item: T) => unknown): T[] { + return [ + ...uniqueBy(items, lookup, (original, current) => { + if (current.reasons.length !== 0) { + original.reasons.push(...current.reasons); + } + return original; + }), + ]; +} diff --git a/src/plus/github/githubGitProvider.ts b/src/plus/integrations/providers/github/githubGitProvider.ts similarity index 73% rename from src/plus/github/githubGitProvider.ts rename to src/plus/integrations/providers/github/githubGitProvider.ts index 1af339a75e887..2280e2804d92e 100644 --- a/src/plus/github/githubGitProvider.ts +++ b/src/plus/integrations/providers/github/githubGitProvider.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/require-await */ +import { encodeUtf8Hex } from '@env/hex'; +import { isWeb } from '@env/platform'; import type { AuthenticationSession, - AuthenticationSessionsChangeEvent, CancellationToken, Disposable, Event, @@ -9,110 +10,144 @@ import type { TextDocument, WorkspaceFolder, } from 'vscode'; -import { authentication, EventEmitter, FileType, Uri, window, workspace } from 'vscode'; -import { encodeUtf8Hex } from '@env/hex'; -import { configuration } from '../../configuration'; -import { CharCode, ContextKeys, Schemes } from '../../constants'; -import type { Container } from '../../container'; -import { setContext } from '../../context'; -import { emojify } from '../../emojis'; +import { EventEmitter, FileType, Uri, window, workspace } from 'vscode'; +import { CharCode, Schemes } from '../../../../constants'; +import type { SearchOperators, SearchQuery } from '../../../../constants.search'; +import type { Container } from '../../../../container'; +import { emojify } from '../../../../emojis'; import { AuthenticationError, AuthenticationErrorReason, ExtensionNotFoundError, OpenVirtualRepositoryError, OpenVirtualRepositoryErrorReason, -} from '../../errors'; -import { Features } from '../../features'; -import { GitSearchError } from '../../git/errors'; +} from '../../../../errors'; +import { Features } from '../../../../features'; +import { GitSearchError } from '../../../../git/errors'; import type { GitCaches, GitProvider, + LeftRightCommitCountResult, NextComparisonUrisResult, PagedResult, + PagingOptions, PreviousComparisonUrisResult, PreviousLineComparisonUrisResult, RepositoryCloseEvent, RepositoryOpenEvent, + RepositoryVisibility, ScmRepository, -} from '../../git/gitProvider'; -import { GitProviderId, RepositoryVisibility } from '../../git/gitProvider'; -import { GitUri } from '../../git/gitUri'; -import type { GitBlame, GitBlameAuthor, GitBlameLine, GitBlameLines } from '../../git/models/blame'; -import type { BranchSortOptions } from '../../git/models/branch'; -import { getBranchId, GitBranch, sortBranches } from '../../git/models/branch'; -import type { GitCommitLine, GitCommitStats } from '../../git/models/commit'; -import { getChangedFilesCount, GitCommit, GitCommitIdentity } from '../../git/models/commit'; -import { GitContributor } from '../../git/models/contributor'; -import type { GitDiff, GitDiffFilter, GitDiffHunkLine, GitDiffShortStat } from '../../git/models/diff'; -import type { GitFile } from '../../git/models/file'; -import { GitFileChange, GitFileIndexStatus } from '../../git/models/file'; +} from '../../../../git/gitProvider'; +import { GitUri } from '../../../../git/gitUri'; +import { decodeRemoteHubAuthority } from '../../../../git/gitUri.authority'; +import type { GitBlame, GitBlameAuthor, GitBlameLine, GitBlameLines } from '../../../../git/models/blame'; +import type { BranchSortOptions } from '../../../../git/models/branch'; +import { getBranchId, getBranchNameWithoutRemote, GitBranch, sortBranches } from '../../../../git/models/branch'; +import type { GitCommitLine, GitStashCommit } from '../../../../git/models/commit'; +import { getChangedFilesCount, GitCommit, GitCommitIdentity } from '../../../../git/models/commit'; +import { deletedOrMissing, uncommitted } from '../../../../git/models/constants'; +import { GitContributor } from '../../../../git/models/contributor'; +import type { GitDiffFile, GitDiffFilter, GitDiffLine, GitDiffShortStat } from '../../../../git/models/diff'; +import type { GitFile } from '../../../../git/models/file'; +import { GitFileChange, GitFileIndexStatus } from '../../../../git/models/file'; import type { GitGraph, GitGraphRow, GitGraphRowContexts, GitGraphRowHead, GitGraphRowRemoteHead, + GitGraphRowsStats, + GitGraphRowStats, GitGraphRowTag, -} from '../../git/models/graph'; -import { GitGraphRowType } from '../../git/models/graph'; -import type { GitLog } from '../../git/models/log'; -import type { GitMergeStatus } from '../../git/models/merge'; -import type { GitRebaseStatus } from '../../git/models/rebase'; -import type { GitBranchReference } from '../../git/models/reference'; -import { GitReference, GitRevision } from '../../git/models/reference'; -import type { GitReflog } from '../../git/models/reflog'; -import { getRemoteIconUri, GitRemote, GitRemoteType } from '../../git/models/remote'; -import type { RepositoryChangeEvent } from '../../git/models/repository'; -import { Repository } from '../../git/models/repository'; -import type { GitStash } from '../../git/models/stash'; -import type { GitStatus, GitStatusFile } from '../../git/models/status'; -import type { TagSortOptions } from '../../git/models/tag'; -import { GitTag, sortTags } from '../../git/models/tag'; -import type { GitTreeEntry } from '../../git/models/tree'; -import type { GitUser } from '../../git/models/user'; -import { isUserMatch } from '../../git/models/user'; -import type { RemoteProvider } from '../../git/remotes/remoteProvider'; -import type { RemoteProviders } from '../../git/remotes/remoteProviders'; -import { getRemoteProviderMatcher, loadRemoteProviders } from '../../git/remotes/remoteProviders'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; -import type { GitSearch, GitSearchResultData, GitSearchResults, SearchQuery } from '../../git/search'; -import { getSearchQueryComparisonKey, parseSearchQuery } from '../../git/search'; -import { Logger } from '../../logger'; -import type { LogScope } from '../../logScope'; -import { getLogScope } from '../../logScope'; -import { gate } from '../../system/decorators/gate'; -import { debug, log } from '../../system/decorators/log'; -import { filterMap, first, last, some } from '../../system/iterable'; -import { isAbsolute, isFolderGlob, maybeUri, normalizePath, relative } from '../../system/path'; -import { fastestSettled, getSettledValue } from '../../system/promise'; -import { serializeWebviewItemContext } from '../../system/webview'; -import type { CachedBlame, CachedLog } from '../../trackers/gitDocumentTracker'; -import { GitDocumentState } from '../../trackers/gitDocumentTracker'; -import type { TrackedDocument } from '../../trackers/trackedDocument'; -import type { GitHubAuthorityMetadata, Metadata, RemoteHubApi } from '../remotehub'; -import { getRemoteHubApi } from '../remotehub'; -import type { GraphItemContext, GraphItemRefContext } from '../webviews/graph/graphWebview'; +} from '../../../../git/models/graph'; +import type { GitLog } from '../../../../git/models/log'; +import type { GitMergeStatus } from '../../../../git/models/merge'; +import type { GitRebaseStatus } from '../../../../git/models/rebase'; +import type { GitBranchReference, GitReference, GitRevisionRange } from '../../../../git/models/reference'; +import { + createReference, + createRevisionRange, + getRevisionRangeParts, + isRevisionRange, + isSha, + isShaLike, + isUncommitted, +} from '../../../../git/models/reference'; +import type { GitReflog } from '../../../../git/models/reflog'; +import { getRemoteIconUri, getVisibilityCacheKey, GitRemote } from '../../../../git/models/remote'; +import type { RepositoryChangeEvent } from '../../../../git/models/repository'; +import { Repository } from '../../../../git/models/repository'; +import type { GitStash } from '../../../../git/models/stash'; +import type { GitStatusFile } from '../../../../git/models/status'; +import { GitStatus } from '../../../../git/models/status'; +import type { TagSortOptions } from '../../../../git/models/tag'; +import { getTagId, GitTag, sortTags } from '../../../../git/models/tag'; +import type { GitTreeEntry } from '../../../../git/models/tree'; +import type { GitUser } from '../../../../git/models/user'; +import { isUserMatch } from '../../../../git/models/user'; +import type { GitWorktree } from '../../../../git/models/worktree'; +import { getRemoteProviderMatcher, loadRemoteProviders } from '../../../../git/remotes/remoteProviders'; +import type { GitSearch, GitSearchResultData, GitSearchResults } from '../../../../git/search'; +import { getSearchQueryComparisonKey, parseSearchQuery } from '../../../../git/search'; +import { gate } from '../../../../system/decorators/gate'; +import { debug, log } from '../../../../system/decorators/log'; +import { filterMap, first, last, map, some, union } from '../../../../system/iterable'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getLogScope } from '../../../../system/logger.scope'; +import { isAbsolute, isFolderGlob, maybeUri, normalizePath } from '../../../../system/path'; +import { asSettled, getSettledValue } from '../../../../system/promise'; +import { configuration } from '../../../../system/vscode/configuration'; +import { setContext } from '../../../../system/vscode/context'; +import { relative } from '../../../../system/vscode/path'; +import { serializeWebviewItemContext } from '../../../../system/webview'; +import type { CachedBlame, CachedLog, TrackedGitDocument } from '../../../../trackers/trackedDocument'; +import { GitDocumentState } from '../../../../trackers/trackedDocument'; +import type { GitHubAuthorityMetadata, Metadata, RemoteHubApi } from '../../../remotehub'; +import { getRemoteHubApi, HeadType, RepositoryRefType } from '../../../remotehub'; +import type { + GraphBranchContextValue, + GraphItemContext, + GraphItemRefContext, + GraphTagContextValue, +} from '../../../webviews/graph/protocol'; +import type { + IntegrationAuthenticationService, + IntegrationAuthenticationSessionDescriptor, +} from '../../authentication/integrationAuthentication'; +import { HostingIntegrationId } from '../models'; import type { GitHubApi } from './github'; +import type { GitHubBranch } from './models'; import { fromCommitFileStatus } from './models'; const doubleQuoteRegex = /"/g; const emptyArray = Object.freeze([]) as unknown as any[]; const emptyPagedResult: PagedResult = Object.freeze({ values: [] }); -const emptyPromise: Promise = Promise.resolve(undefined); +const emptyPromise: Promise = Promise.resolve(undefined); const githubAuthenticationScopes = ['repo', 'read:user', 'user:email']; // Since negative lookbehind isn't supported in all browsers, this leaves out the negative lookbehind condition `(? = new Set([Schemes.Virtual, Schemes.GitHub, Schemes.PRs]); + descriptor = { id: 'github' as const, name: 'GitHub', virtual: true }; + readonly authenticationDescriptor: IntegrationAuthenticationSessionDescriptor = { + domain: 'github.com', + scopes: githubAuthenticationScopes, + }; + readonly authenticationProviderId = HostingIntegrationId.GitHub; + readonly supportedSchemes = new Set([Schemes.Virtual, Schemes.GitHub, Schemes.PRs]); + + private _onDidChange = new EventEmitter(); + get onDidChange(): Event { + return this._onDidChange.event; + } private _onDidChangeRepository = new EventEmitter(); get onDidChangeRepository(): Event { @@ -135,28 +170,29 @@ export class GitHubGitProvider implements GitProvider, Disposable { private readonly _disposables: Disposable[] = []; - constructor(private readonly container: Container) { + constructor( + private readonly container: Container, + private readonly authenticationService: IntegrationAuthenticationService, + ) { this._disposables.push( - this.container.events.on( - 'git:cache:reset', - e => - e.data.repoPath - ? this.resetCache(e.data.repoPath, ...(e.data.caches ?? emptyArray)) - : this.resetCaches(...(e.data.caches ?? emptyArray)), - authentication.onDidChangeSessions(this.onAuthenticationSessionsChanged, this), + this.container.events.on('git:cache:reset', e => + e.data.repoPath + ? this.resetCache(e.data.repoPath, ...(e.data.caches ?? emptyArray)) + : this.resetCaches(...(e.data.caches ?? emptyArray)), ), ); + void authenticationService.get(this.authenticationProviderId).then(authProvider => { + this._disposables.push(authProvider.onDidChange(this.onAuthenticationSessionsChanged, this)); + }); } dispose() { this._disposables.forEach(d => void d.dispose()); } - private onAuthenticationSessionsChanged(e: AuthenticationSessionsChangeEvent) { - if (e.provider.id === 'github') { - this._sessionPromise = undefined; - void this.ensureSession(false, true); - } + private onAuthenticationSessionsChanged() { + this._sessionPromise = undefined; + void this.ensureSession(false, true); } private onRepositoryChanged(repo: Repository, e: RepositoryChangeEvent) { @@ -175,7 +211,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { this._onDidChangeRepository.fire(e); } - async discoverRepositories(uri: Uri): Promise { + async discoverRepositories( + uri: Uri, + options?: { cancellation?: CancellationToken; depth?: number; silent?: boolean }, + ): Promise { if (!this.supportedSchemes.has(uri.scheme)) return []; try { @@ -183,14 +222,56 @@ export class GitHubGitProvider implements GitProvider, Disposable { const workspaceUri = remotehub.getVirtualWorkspaceUri(uri); if (workspaceUri == null) return []; - return this.openRepository(undefined, workspaceUri, true); - } catch { + return this.openRepository(undefined, workspaceUri, true, undefined, options?.silent); + } catch (ex) { + if (ex.message.startsWith('No provider registered with')) { + Logger.error( + ex, + 'No GitHub provider registered with Remote Repositories (yet); queuing pending discovery', + ); + this._pendingDiscovery.add(uri); + this.ensurePendingRepositoryDiscovery(); + } return []; } } + private _pendingDiscovery = new Set(); + private _pendingTimer: ReturnType | undefined; + private ensurePendingRepositoryDiscovery() { + if (this._pendingTimer != null || this._pendingDiscovery.size === 0) return; + + this._pendingTimer = setTimeout(async () => { + try { + const remotehub = await getRemoteHubApi(); + + for (const uri of this._pendingDiscovery) { + if (remotehub.getProvider(uri) == null) { + this._pendingTimer = undefined; + this.ensurePendingRepositoryDiscovery(); + return; + } + + this._pendingDiscovery.delete(uri); + } + + this._pendingTimer = undefined; + + setTimeout(() => this._onDidChange.fire(), 1); + + if (this._pendingDiscovery.size !== 0) { + this.ensurePendingRepositoryDiscovery(); + } + } catch { + debugger; + this._pendingTimer = undefined; + this.ensurePendingRepositoryDiscovery(); + } + }, 250); + } + updateContext(): void { - void setContext(ContextKeys.HasVirtualFolders, this.container.git.hasOpenRepositories(this.descriptor.id)); + void setContext('gitlens:hasVirtualFolders', this.container.git.hasOpenRepositories(this.descriptor.id)); } openRepository( @@ -205,7 +286,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { this.container, this.onRepositoryChanged.bind(this), this.descriptor, - folder, + folder ?? workspace.getWorkspaceFolder(uri), uri, root, suspended ?? !window.state.focused, @@ -222,28 +303,31 @@ export class GitHubGitProvider implements GitProvider, Disposable { switch (feature) { case Features.Stashes: case Features.Worktrees: + case Features.StashOnlyStaged: return false; default: return true; } } - async visibility(repoPath: string): Promise { + async visibility(repoPath: string): Promise<[visibility: RepositoryVisibility, cacheKey: string | undefined]> { const remotes = await this.getRemotes(repoPath, { sort: true }); - if (remotes.length === 0) return RepositoryVisibility.Local; + if (remotes.length === 0) return ['local', undefined]; - for await (const result of fastestSettled(remotes.map(r => this.getRemoteVisibility(r)))) { + for await (const result of asSettled(remotes.map(r => this.getRemoteVisibility(r)))) { if (result.status !== 'fulfilled') continue; - if (result.value === RepositoryVisibility.Public) return RepositoryVisibility.Public; + if (result.value[0] === 'public') { + return ['public', getVisibilityCacheKey(result.value[1])]; + } } - return RepositoryVisibility.Private; + return ['private', getVisibilityCacheKey(remotes)]; } private async getRemoteVisibility( - remote: GitRemote, - ): Promise { + remote: GitRemote, + ): Promise<[visibility: RepositoryVisibility, remote: GitRemote]> { switch (remote.provider?.id) { case 'github': { const { github, metadata, session } = await this.ensureRepositoryContext(remote.repoPath); @@ -253,10 +337,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { metadata.repo.name, ); - return visibility ?? RepositoryVisibility.Private; + return [visibility ?? 'private', remote]; } default: - return RepositoryVisibility.Private; + return ['private', remote]; } } @@ -350,7 +434,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { getRevisionUri(repoPath: string, path: string, ref: string): Uri { const uri = this.createProviderUri(repoPath, ref, path); - return ref === GitRevision.deletedOrMissing ? uri.with({ query: '~' }) : uri; + return ref === deletedOrMissing ? uri.with({ query: '~' }) : uri; } @log() @@ -427,6 +511,22 @@ export class GitHubGitProvider implements GitProvider, Disposable { _options?: { all?: boolean; branch?: GitBranchReference; prune?: boolean; pull?: boolean; remote?: string }, ): Promise {} + @log() + async pull( + _repoPath: string, + _options?: { branch?: GitBranchReference; rebase?: boolean; tags?: boolean }, + ): Promise {} + + @log() + async push( + _repoPath: string, + _options?: { + reference?: GitReference; + force?: boolean; + publish?: { remote: string }; + }, + ): Promise {} + @gate() @debug() async findRepositoryUri(uri: Uri, _isDirectory?: boolean): Promise { @@ -434,8 +534,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { try { const remotehub = await this.ensureRemoteHubApi(); - const rootUri = remotehub.getProviderRootUri(uri).with({ scheme: Schemes.Virtual }); - return rootUri; + + return await ensureProviderLoaded(uri, remotehub, uri => + remotehub.getProviderRootUri(uri).with({ scheme: Schemes.Virtual }), + ); } catch (ex) { if (!(ex instanceof ExtensionNotFoundError)) { debugger; @@ -446,12 +548,37 @@ export class GitHubGitProvider implements GitProvider, Disposable { } } - @log({ args: { 1: refs => refs.join(',') } }) - async getAheadBehindCommitCount( - _repoPath: string, - _refs: string[], - ): Promise<{ ahead: number; behind: number } | undefined> { - return undefined; + @log() + async getLeftRightCommitCount( + repoPath: string, + range: GitRevisionRange, + _options?: { authors?: GitUser[] | undefined; excludeMerges?: boolean }, + ): Promise { + if (repoPath == null) return undefined; + + const scope = getLogScope(); + + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + try { + const result = await github.getComparison( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + stripOrigin(range), + ); + + if (result == null) return undefined; + + return { + left: result.behind_by, + right: result.ahead_by, + }; + } catch (ex) { + Logger.error(ex, scope); + debugger; + return undefined; + } } @gate((u, d) => `${u.toString()}|${d?.isDirty}`) @@ -467,7 +594,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { key += `:${uri.sha}`; } - const doc = await this.container.tracker.getOrAdd(uri); + const doc = await this.container.documentTracker.getOrAdd(uri); if (doc.state != null) { const cachedBlame = doc.state.getBlame(key); if (cachedBlame != null) { @@ -498,7 +625,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { private async getBlameCore( uri: GitUri, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, ): Promise { @@ -604,7 +731,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { }; document.state.setBlame(key, value); - document.setBlameFailure(); + document.setBlameFailure(ex); return emptyPromise as Promise; } @@ -714,7 +841,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { }; } catch (ex) { debugger; - Logger.error(scope, ex); + Logger.error(ex, scope); return undefined; } } @@ -799,15 +926,44 @@ export class GitHubGitProvider implements GitProvider, Disposable { const { values: [branch], } = await this.getBranches(repoPath, { filter: b => b.current }); - return branch; + if (branch != null) return branch; + + try { + const { metadata } = await this.ensureRepositoryContext(repoPath!); + + const revision = await metadata.getRevision(); + switch (revision.type) { + case HeadType.Tag: + case HeadType.Commit: + return new GitBranch( + this.container, + repoPath!, + revision.name, + false, + true, + undefined, + revision.revision, + undefined, + undefined, + undefined, + true, + ); + } + + return undefined; + } catch (ex) { + debugger; + Logger.error(ex, getLogScope()); + return undefined; + } } @log({ args: { 1: false } }) async getBranches( repoPath: string | undefined, options?: { - cursor?: string; filter?: (b: GitBranch) => boolean; + paging?: PagingOptions; sort?: boolean | BranchSortOptions; }, ): Promise> { @@ -815,18 +971,46 @@ export class GitHubGitProvider implements GitProvider, Disposable { const scope = getLogScope(); - let branchesPromise = options?.cursor ? undefined : this._branchesCache.get(repoPath); + let branchesPromise = options?.paging?.cursor ? undefined : this._branchesCache.get(repoPath); if (branchesPromise == null) { async function load(this: GitHubGitProvider): Promise> { try { const { metadata, github, session } = await this.ensureRepositoryContext(repoPath!); - const revision = await metadata.getRevision(); - const current = revision.type === 0 /* HeadType.Branch */ ? revision.name : undefined; - const branches: GitBranch[] = []; - let cursor = options?.cursor; + function addBranches(container: Container, branch: GitHubBranch, current: boolean) { + const date = new Date( + configuration.get('advanced.commitOrdering') === 'author-date' + ? branch.target.authoredDate + : branch.target.committedDate, + ); + const ref = branch.target.oid; + + branches.push( + new GitBranch(container, repoPath!, branch.name, false, current, date, ref, { + name: `origin/${branch.name}`, + missing: false, + }), + new GitBranch(container, repoPath!, `origin/${branch.name}`, true, false, date, ref), + ); + } + + let currentBranch: string | undefined; + + const revision = await metadata.getRevision(); + switch (revision.type) { + case HeadType.Branch: + currentBranch = revision.name; + break; + case HeadType.RemoteBranch: { + const index = revision.name.indexOf(':'); + currentBranch = index === -1 ? revision.name : revision.name.substring(index + 1); + break; + } + } + + let cursor = options?.paging?.cursor; const loadAll = cursor == null; while (true) { @@ -838,20 +1022,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { ); for (const branch of result.values) { - const date = new Date( - configuration.get('advanced.commitOrdering') === 'author-date' - ? branch.target.authoredDate - : branch.target.committedDate, - ); - const ref = branch.target.oid; - - branches.push( - new GitBranch(repoPath!, branch.name, false, branch.name === current, date, ref, { - name: `origin/${branch.name}`, - missing: false, - }), - new GitBranch(repoPath!, `origin/${branch.name}`, true, false, date, ref), - ); + addBranches(this.container, branch, branch.name === currentBranch); } if (!result.paging?.more || !loadAll) return { ...result, values: branches }; @@ -868,7 +1039,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { } branchesPromise = load.call(this); - if (options?.cursor == null) { + if (options?.paging?.cursor == null) { this._branchesCache.set(repoPath, branchesPromise); } } @@ -911,7 +1082,12 @@ export class GitHubGitProvider implements GitProvider, Disposable { try { const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); - const commit = await github.getCommit(session.accessToken, metadata.repo.owner, metadata.repo.name, ref); + const commit = await github.getCommit( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + stripOrigin(ref), + ); if (commit == null) return undefined; const { viewer = session.account.label } = commit; @@ -960,8 +1136,11 @@ export class GitHubGitProvider implements GitProvider, Disposable { @log() async getCommitBranches( repoPath: string, - ref: string, - options?: { branch?: string; commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, + refs: string[], + branch?: string | undefined, + options?: + | { all?: boolean; commitDate?: Date; mode?: 'contains' | 'pointsAt' } + | { commitDate?: Date; mode?: 'contains' | 'pointsAt'; remotes?: boolean }, ): Promise { if (repoPath == null || options?.commitDate == null) return []; @@ -972,13 +1151,14 @@ export class GitHubGitProvider implements GitProvider, Disposable { let branches; - if (options?.branch) { + if (branch) { branches = await github.getCommitOnBranch( session.accessToken, metadata.repo.owner, metadata.repo.name, - options?.branch, - ref, + branch, + refs.map(stripOrigin), + options?.mode ?? 'contains', options?.commitDate, ); } else { @@ -986,7 +1166,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { session.accessToken, metadata.repo.owner, metadata.repo.name, - ref, + refs.map(stripOrigin), + options?.mode ?? 'contains', options?.commitDate, ); } @@ -1012,7 +1193,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { session?.accessToken, metadata.repo.owner, metadata.repo.name, - ref, + stripOrigin(ref), ); return count; @@ -1043,7 +1224,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { session.accessToken, metadata.repo.owner, metadata.repo.name, - ref, + stripOrigin(ref), file, ); if (commit == null) return undefined; @@ -1099,7 +1280,6 @@ export class GitHubGitProvider implements GitProvider, Disposable { repoPath: string, asWebviewUri: (uri: Uri) => Uri, options?: { - branch?: string; include?: { stats?: boolean }; limit?: number; ref?: string; @@ -1110,27 +1290,78 @@ export class GitHubGitProvider implements GitProvider, Disposable { const ordering = configuration.get('graph.commitOrdering', undefined, 'date'); const useAvatars = configuration.get('graph.avatars', undefined, true); - const [logResult, branchResult, remotesResult, tagsResult, currentUserResult] = await Promise.allSettled([ - this.getLog(repoPath, { all: true, ordering: ordering, limit: defaultLimit }), - this.getBranch(repoPath), - this.getRemotes(repoPath), - this.getTags(repoPath), - this.getCurrentUser(repoPath), - ]); + const [logResult, headBranchResult, branchesResult, remotesResult, tagsResult, currentUserResult] = + await Promise.allSettled([ + this.getLog(repoPath, { all: true, ordering: ordering, limit: defaultLimit }), + this.getBranch(repoPath), + this.getBranches(repoPath, { filter: b => b.remote }), + this.getRemotes(repoPath), + this.getTags(repoPath), + this.getCurrentUser(repoPath), + ]); const avatars = new Map(); + const headBranch = getSettledValue(headBranchResult)!; + + const branchMap = new Map(); + const branchTips = new Map(); + if (headBranch != null) { + branchMap.set(headBranch.name, headBranch); + if (headBranch.sha != null) { + branchTips.set(headBranch.sha, [headBranch.name]); + } + } + + const branches = getSettledValue(branchesResult)?.values; + if (branches != null) { + for (const branch of branches) { + branchMap.set(branch.name, branch); + if (branch.sha == null) continue; + + const bts = branchTips.get(branch.sha); + if (bts == null) { + branchTips.set(branch.sha, [branch.name]); + } else { + bts.push(branch.name); + } + } + } + const ids = new Set(); + const remote = getSettledValue(remotesResult)![0]; + const remoteMap = remote != null ? new Map([[remote.name, remote]]) : new Map(); + + const tagTips = new Map(); + const tags = getSettledValue(tagsResult)?.values; + if (tags != null) { + for (const tag of tags) { + if (tag.sha == null) continue; + + const tts = tagTips.get(tag.sha); + if (tts == null) { + tagTips.set(tag.sha, [tag.name]); + } else { + tts.push(tag.name); + } + } + } return this.getCommitsForGraphCore( repoPath, asWebviewUri, getSettledValue(logResult), - getSettledValue(branchResult), - getSettledValue(remotesResult)?.[0], - getSettledValue(tagsResult)?.values, + headBranch, + branchMap, + branchTips, + remote, + remoteMap, + tagTips, getSettledValue(currentUserResult), avatars, ids, + undefined, + undefined, + undefined, { ...options, useAvatars: useAvatars }, ); } @@ -1139,12 +1370,18 @@ export class GitHubGitProvider implements GitProvider, Disposable { repoPath: string, asWebviewUri: (uri: Uri) => Uri, log: GitLog | undefined, - branch: GitBranch | undefined, - remote: GitRemote | undefined, - tags: GitTag[] | undefined, + headBranch: GitBranch, + branchMap: Map, + branchTips: Map, + remote: GitRemote, + remoteMap: Map, + tagTips: Map, currentUser: GitUser | undefined, avatars: Map, ids: Set, + stashes: Map | undefined, + worktrees: GitWorktree[] | undefined, + worktreesByBranch: Map | undefined, options?: { branch?: string; include?: { stats?: boolean }; @@ -1154,8 +1391,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { }, ): Promise { const includes = { ...options?.include, stats: true }; // stats are always available, so force it - const branchMap = branch != null ? new Map([[branch.name, branch]]) : new Map(); - const remoteMap = remote != null ? new Map([[remote.name, remote]]) : new Map(); + const downstreamMap = new Map(); if (log == null) { return { repoPath: repoPath, @@ -1164,6 +1400,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { includes: includes, branches: branchMap, remotes: remoteMap, + downstreams: downstreamMap, + stashes: stashes, + worktrees: worktrees, + worktreesByBranch: worktreesByBranch, rows: [], }; } @@ -1177,103 +1417,184 @@ export class GitHubGitProvider implements GitProvider, Disposable { includes: includes, branches: branchMap, remotes: remoteMap, + downstreams: downstreamMap, + stashes: stashes, + worktrees: worktrees, + worktreesByBranch: worktreesByBranch, rows: [], }; } const rows: GitGraphRow[] = []; + let avatarUrl: string | undefined; + let branchName: string; + let context: + | GraphItemRefContext + | GraphItemRefContext + | undefined; + let contexts: GitGraphRowContexts | undefined; let head = false; let isCurrentUser = false; let refHeads: GitGraphRowHead[]; let refRemoteHeads: GitGraphRowRemoteHead[]; let refTags: GitGraphRowTag[]; - let contexts: GitGraphRowContexts | undefined; - let stats: GitCommitStats | undefined; + let remoteBranchId: string; + let stats: GitGraphRowsStats | undefined; + let tagId: string; - const hasHeadShaAndRemote = branch?.sha != null && remote != null; + const headRefUpstreamName = headBranch.upstream?.name; for (const commit of commits) { ids.add(commit.sha); - head = commit.sha === branch?.sha; - if (hasHeadShaAndRemote && head) { + head = commit.sha === headBranch.sha; + if (head) { + context = { + webviewItem: `gitlens:branch${head ? '+current' : ''}${ + headBranch?.upstream != null ? '+tracking' : '' + }`, + webviewItemValue: { + type: 'branch', + ref: createReference(headBranch.name, repoPath, { + id: headBranch.id, + refType: 'branch', + name: headBranch.name, + remote: false, + upstream: headBranch.upstream, + }), + }, + }; + refHeads = [ { - id: getBranchId(repoPath, false, branch.name), - name: branch.name, + id: headBranch.id, + name: headBranch.name, isCurrentHead: true, - context: serializeWebviewItemContext({ - webviewItem: `gitlens:branch${head ? '+current' : ''}${ - branch?.upstream != null ? '+tracking' : '' - }`, - webviewItemValue: { - type: 'branch', - ref: GitReference.create(branch.name, repoPath, { - refType: 'branch', - name: branch.name, - remote: false, - upstream: branch.upstream, - }), - }, - }), - upstream: branch.upstream?.name, + context: serializeWebviewItemContext(context), + upstream: + headBranch.upstream != null + ? { + name: headBranch.upstream.name, + id: getBranchId(repoPath, true, headBranch.upstream.name), + } + : undefined, }, ]; - refRemoteHeads = [ - { - id: getBranchId(repoPath, true, branch.name), - name: branch.name, - owner: remote.name, - url: remote.url, - avatarUrl: ( + + if (headBranch.upstream != null) { + remoteBranchId = getBranchId(repoPath, true, headBranch.name); + avatarUrl = ( + (options?.useAvatars ? remote.provider?.avatarUri : undefined) ?? + getRemoteIconUri(this.container, remote, asWebviewUri) + )?.toString(true); + context = { + webviewItem: 'gitlens:branch+remote', + webviewItemValue: { + type: 'branch', + ref: createReference(headBranch.name, repoPath, { + id: remoteBranchId, + refType: 'branch', + name: headBranch.name, + remote: true, + upstream: { name: remote.name, missing: false }, + }), + }, + }; + + refRemoteHeads = [ + { + id: remoteBranchId, + name: headBranch.name, + owner: remote.name, + url: remote.url, + avatarUrl: avatarUrl, + context: serializeWebviewItemContext(context), + current: true, + hostingServiceType: remote.provider?.gkProviderId, + }, + ]; + + if (headRefUpstreamName != null) { + // Add the branch name (tip) to the upstream name entry in the downstreams map + let downstreams = downstreamMap.get(headRefUpstreamName); + if (downstreams == null) { + downstreams = []; + downstreamMap.set(headRefUpstreamName, downstreams); + } + + downstreams.push(headBranch.name); + } + } else { + refRemoteHeads = []; + } + } else { + refHeads = []; + refRemoteHeads = []; + + const bts = branchTips.get(commit.sha); + if (bts != null) { + for (const b of bts) { + remoteBranchId = getBranchId(repoPath, true, b); + branchName = getBranchNameWithoutRemote(b); + + avatarUrl = ( (options?.useAvatars ? remote.provider?.avatarUri : undefined) ?? getRemoteIconUri(this.container, remote, asWebviewUri) - )?.toString(true), - context: serializeWebviewItemContext({ + )?.toString(true); + context = { webviewItem: 'gitlens:branch+remote', webviewItemValue: { type: 'branch', - ref: GitReference.create(branch.name, repoPath, { + ref: createReference(b, repoPath, { + id: remoteBranchId, refType: 'branch', - name: branch.name, + name: b, remote: true, upstream: { name: remote.name, missing: false }, }), }, - }), - current: true, - }, - ]; - } else { - refHeads = []; - refRemoteHeads = []; + }; + + refRemoteHeads.push({ + id: remoteBranchId, + name: branchName, + owner: remote.name, + url: remote.url, + avatarUrl: avatarUrl, + context: serializeWebviewItemContext(context), + hostingServiceType: remote.provider?.gkProviderId, + }); + } + } } - if (tags != null) { - refTags = [ - ...filterMap(tags, t => { - if (t.sha !== commit.sha) return undefined; - - return { - id: t.id, - name: t.name, - annotated: Boolean(t.message), - context: serializeWebviewItemContext({ - webviewItem: 'gitlens:tag', - webviewItemValue: { - type: 'tag', - ref: GitReference.create(t.name, repoPath, { - refType: 'tag', - name: t.name, - }), - }, + refTags = []; + + const tts = tagTips.get(commit.sha); + if (tts != null) { + for (const t of tts) { + tagId = getTagId(repoPath, t); + context = { + webviewItem: 'gitlens:tag', + webviewItemValue: { + type: 'tag', + ref: createReference(t, repoPath, { + id: tagId, + refType: 'tag', + name: t, }), - }; - }), - ]; - } else { - refTags = []; + }, + }; + + refTags.push({ + id: tagId, + name: t, + // Not currently used, so don't bother looking it up + annotated: true, + context: serializeWebviewItemContext(context), + }); + } } if (commit.author.email && !avatars.has(commit.author.email)) { @@ -1286,12 +1607,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { isCurrentUser = commit.author.name === 'You'; contexts = { row: serializeWebviewItemContext({ - webviewItem: `gitlens:commit${ - hasHeadShaAndRemote && commit.sha === branch.sha ? '+HEAD' : '' - }+current`, + webviewItem: `gitlens:commit${head ? '+HEAD' : ''}+current`, webviewItemValue: { type: 'commit', - ref: GitReference.create(commit.sha, repoPath, { + ref: createReference(commit.sha, repoPath, { refType: 'revision', message: commit.message, }), @@ -1309,7 +1628,6 @@ export class GitHubGitProvider implements GitProvider, Disposable { }), }; - stats = commit.stats; rows.push({ sha: commit.sha, parents: commit.parents, @@ -1318,20 +1636,23 @@ export class GitHubGitProvider implements GitProvider, Disposable { date: commit.committer.date.getTime(), message: emojify(commit.message && String(commit.message).length ? commit.message : commit.summary), // TODO: review logic for stash, wip, etc - type: commit.parents.length > 1 ? GitGraphRowType.MergeCommit : GitGraphRowType.Commit, + type: commit.parents.length > 1 ? 'merge-node' : 'commit-node', heads: refHeads, remotes: refRemoteHeads, tags: refTags, contexts: contexts, - stats: - stats != null - ? { - files: getChangedFilesCount(stats.changedFiles), - additions: stats.additions, - deletions: stats.deletions, - } - : undefined, }); + + if (commit.stats != null) { + if (stats == null) { + stats = new Map(); + } + stats.set(commit.sha, { + files: getChangedFilesCount(commit.stats.changedFiles), + additions: commit.stats.additions, + deletions: commit.stats.deletions, + }); + } } if (options?.ref === 'HEAD') { @@ -1347,6 +1668,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { includes: includes, branches: branchMap, remotes: remoteMap, + downstreams: downstreamMap, + stashes: stashes, + worktrees: worktrees, + worktreesByBranch: worktreesByBranch, rows: rows, id: options?.ref, @@ -1361,22 +1686,57 @@ export class GitHubGitProvider implements GitProvider, Disposable { repoPath, asWebviewUri, moreLog, - branch, + headBranch, + branchMap, + branchTips, remote, - tags, + remoteMap, + tagTips, currentUser, avatars, ids, + stashes, + worktrees, + worktreesByBranch, options, ); }, }; } + @log() + async getCommitTags( + repoPath: string, + ref: string, + options?: { commitDate?: Date; mode?: 'contains' | 'pointsAt' }, + ): Promise { + if (repoPath == null || options?.commitDate == null) return []; + + const scope = getLogScope(); + + try { + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + const tags = await github.getCommitTags( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + stripOrigin(ref), + options?.commitDate, + ); + + return tags; + } catch (ex) { + Logger.error(ex, scope); + debugger; + return []; + } + } + @log() async getContributors( repoPath: string, - _options?: { all?: boolean; ref?: string; stats?: boolean }, + _options?: { all?: boolean; merges?: boolean | 'first-parent'; ref?: string; stats?: boolean }, ): Promise { if (repoPath == null) return []; @@ -1463,7 +1823,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { } @log() - async getDiffForFile(_uri: GitUri, _ref1: string | undefined, _ref2?: string): Promise { + async getDiffForFile(_uri: GitUri, _ref1: string | undefined, _ref2?: string): Promise { return undefined; } @@ -1472,7 +1832,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { 1: _contents => '', }, }) - async getDiffForFileContents(_uri: GitUri, _ref: string, _contents: string): Promise { + async getDiffForFileContents(_uri: GitUri, _ref: string, _contents: string): Promise { return undefined; } @@ -1482,23 +1842,96 @@ export class GitHubGitProvider implements GitProvider, Disposable { _editorLine: number, // 0-based, Git is 1-based _ref1: string | undefined, _ref2?: string, - ): Promise { + ): Promise { return undefined; } @log() async getDiffStatus( - _repoPath: string, - _ref1?: string, - _ref2?: string, - _options?: { filters?: GitDiffFilter[]; similarityThreshold?: number }, + repoPath: string, + ref1OrRange: string | GitRevisionRange, + ref2?: string, + _options?: { filters?: GitDiffFilter[]; path?: string; similarityThreshold?: number }, ): Promise { - return undefined; + if (repoPath == null) return undefined; + + const scope = getLogScope(); + + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + let range: GitRevisionRange; + if (isRevisionRange(ref1OrRange)) { + range = ref1OrRange; + + if (!isRevisionRange(ref1OrRange, 'qualified')) { + const parts = getRevisionRangeParts(ref1OrRange); + range = createRevisionRange(parts?.left || 'HEAD', parts?.right || 'HEAD', parts?.notation ?? '...'); + } + } else { + range = createRevisionRange(ref1OrRange || 'HEAD', ref2 || 'HEAD', '...'); + } + + let range2: GitRevisionRange | undefined; + // GitHub doesn't support the `..` range notation, so we will need to do some extra work + if (isRevisionRange(range, 'qualified-double-dot')) { + const parts = getRevisionRangeParts(range)!; + + range = createRevisionRange(parts.left, parts.right, '...'); + range2 = createRevisionRange(parts.right, parts.left, '...'); + } + + try { + let result = await github.getComparison( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + stripOrigin(range), + ); + + const files1 = result?.files; + + let files = files1; + if (range2) { + result = await github.getComparison( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + stripOrigin(range2), + ); + + const files2 = result?.files; + + files = [...new Set(union(files1, files2))]; + } + + return files?.map( + f => + new GitFileChange( + repoPath, + f.filename ?? '', + fromCommitFileStatus(f.status) ?? GitFileIndexStatus.Modified, + f.previous_filename, + undefined, + // If we need to get a 2nd range, don't include the stats because they won't be correct (for files that overlap) + range2 + ? undefined + : { + additions: f.additions ?? 0, + deletions: f.deletions ?? 0, + changes: f.changes ?? 0, + }, + ), + ); + } catch (ex) { + Logger.error(ex, scope); + debugger; + return undefined; + } } @log() async getFileStatusForCommit(repoPath: string, uri: Uri, ref: string): Promise { - if (ref === GitRevision.deletedOrMissing || GitRevision.isUncommitted(ref)) return undefined; + if (ref === deletedOrMissing || isUncommitted(ref)) return undefined; const commit = await this.getCommitForFile(repoPath, uri, { ref: ref }); if (commit == null) return undefined; @@ -1518,7 +1951,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { authors?: GitUser[]; cursor?: string; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; since?: string; @@ -1534,13 +1967,19 @@ export class GitHubGitProvider implements GitProvider, Disposable { const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); const ref = !options?.ref || options.ref === 'HEAD' ? (await metadata.getRevision()).revision : options.ref; - const result = await github.getCommits(session.accessToken, metadata.repo.owner, metadata.repo.name, ref, { - all: options?.all, - authors: options?.authors, - after: options?.cursor, - limit: limit, - since: options?.since ? new Date(options.since) : undefined, - }); + const result = await github.getCommits( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + stripOrigin(ref), + { + all: options?.all, + authors: options?.authors, + after: options?.cursor, + limit: limit, + since: options?.since ? new Date(options.since) : undefined, + }, + ); const commits = new Map(); @@ -1623,7 +2062,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { authors?: GitUser[]; cursor?: string; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; since?: string; @@ -1641,7 +2080,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { options?: { authors?: GitUser[]; limit?: number; - merges?: boolean; + merges?: boolean | 'first-parent'; ordering?: 'date' | 'author-date' | 'topo' | null; ref?: string; }, @@ -1657,7 +2096,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { moreLimit = this.getPagingLimit(moreLimit); // // If the log is for a range, then just get everything prior + more - // if (GitRevision.isRange(log.sha)) { + // if (isRange(log.sha)) { // const moreLog = await this.getLog(log.repoPath, { // ...options, // limit: moreLimit === 0 ? 0 : (options?.limit ?? 0) + moreLimit, @@ -1789,7 +2228,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { key += `:cursor=${options.cursor}`; } - const doc = await this.container.tracker.getOrAdd(GitUri.fromFile(relativePath, repoPath, options.ref)); + const doc = await this.container.documentTracker.getOrAdd(GitUri.fromFile(relativePath, repoPath, options.ref)); if (!options.force && options.range == null) { if (doc.state != null) { const cachedLog = doc.state.getLog(key); @@ -1876,7 +2315,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { private async getLogForFileCore( repoPath: string | undefined, path: string, - document: TrackedDocument, + document: TrackedGitDocument, key: string, scope: LogScope | undefined, options?: { @@ -1909,13 +2348,19 @@ export class GitHubGitProvider implements GitProvider, Disposable { // } const ref = !options?.ref || options.ref === 'HEAD' ? (await metadata.getRevision()).revision : options.ref; - const result = await github.getCommits(session.accessToken, metadata.repo.owner, metadata.repo.name, ref, { - all: options?.all, - after: options?.cursor, - path: relativePath, - limit: limit, - since: options?.since ? new Date(options.since) : undefined, - }); + const result = await github.getCommits( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + stripOrigin(ref), + { + all: options?.all, + after: options?.cursor, + path: relativePath, + limit: limit, + since: options?.since ? new Date(options.since) : undefined, + }, + ); const commits = new Map(); @@ -2080,12 +2525,30 @@ export class GitHubGitProvider implements GitProvider, Disposable { @log() async getMergeBase( - _repoPath: string, - _ref1: string, - _ref2: string, + repoPath: string, + ref1: string, + ref2: string, _options: { forkPoint?: boolean }, ): Promise { - return undefined; + if (repoPath == null) return undefined; + + const scope = getLogScope(); + + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + try { + const result = await github.getComparison( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + createRevisionRange(stripOrigin(ref1), stripOrigin(ref2), '...'), + ); + return result?.merge_base_commit?.sha; + } catch (ex) { + Logger.error(ex, scope); + debugger; + return undefined; + } } // @gate() @@ -2130,7 +2593,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { metadata.repo.name, revision, relativePath, - ref, + stripOrigin(ref), ); return { @@ -2160,13 +2623,12 @@ export class GitHubGitProvider implements GitProvider, Disposable { uri: Uri, ref: string | undefined, skip: number = 0, - _firstParent: boolean = false, ): Promise { - if (ref === GitRevision.deletedOrMissing) return undefined; + if (ref === deletedOrMissing) return undefined; const scope = getLogScope(); - if (ref === GitRevision.uncommitted) { + if (ref === uncommitted) { ref = undefined; } @@ -2183,7 +2645,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { session.accessToken, metadata.repo.owner, metadata.repo.name, - !ref || ref === 'HEAD' ? (await metadata.getRevision()).revision : ref, + stripOrigin(!ref || ref === 'HEAD' ? (await metadata.getRevision()).revision : ref), { path: relativePath, first: offset + skip + 1, @@ -2199,10 +2661,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { await this.getBestRevisionUri( repoPath, relativePath, - result.values[offset + skip - 1]?.oid ?? GitRevision.deletedOrMissing, + result.values[offset + skip - 1]?.oid ?? deletedOrMissing, ), ); - if (current == null || current.sha === GitRevision.deletedOrMissing) return undefined; + if (current == null || current.sha === deletedOrMissing) return undefined; return { current: current, @@ -2210,7 +2672,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { await this.getBestRevisionUri( repoPath, relativePath, - result.values[offset + skip]?.oid ?? GitRevision.deletedOrMissing, + result.values[offset + skip]?.oid ?? deletedOrMissing, ), ), }; @@ -2230,7 +2692,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { ref: string | undefined, skip: number = 0, ): Promise { - if (ref === GitRevision.deletedOrMissing) return undefined; + if (ref === deletedOrMissing) return undefined; const scope = getLogScope(); @@ -2303,13 +2765,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { } @log({ args: { 1: false } }) - async getRemotes( - repoPath: string | undefined, - options?: { providers?: RemoteProviders; sort?: boolean }, - ): Promise[]> { + async getRemotes(repoPath: string | undefined, _options?: { sort?: boolean }): Promise { if (repoPath == null) return []; - const providers = options?.providers ?? loadRemoteProviders(configuration.get('remotes', null)); + const providers = loadRemoteProviders(configuration.get('remotes', null)); const uri = Uri.parse(repoPath, true); const [, owner, repo] = uri.path.split('/', 3); @@ -2320,16 +2779,16 @@ export class GitHubGitProvider implements GitProvider, Disposable { return [ new GitRemote( + this.container, repoPath, - `${domain}/${path}`, 'origin', 'https', domain, path, getRemoteProviderMatcher(this.container, providers)(url, domain, path), [ - { type: GitRemoteType.Fetch, url: url }, - { type: GitRemoteType.Push, url: url }, + { type: 'fetch', url: url }, + { type: 'push', url: url }, ], ), ]; @@ -2358,20 +2817,41 @@ export class GitHubGitProvider implements GitProvider, Disposable { } @log() - async getStatusForRepo(_repoPath: string | undefined): Promise { - return undefined; + async getStatusForRepo(repoPath: string | undefined): Promise { + if (repoPath == null) return undefined; + + const context = await this.ensureRepositoryContext(repoPath); + if (context == null) return undefined; + + const revision = await context.metadata.getRevision(); + if (revision == null) return undefined; + + return new GitStatus( + repoPath, + revision.name, + revision.revision, + [], + { ahead: 0, behind: 0 }, + revision.type === HeadType.Branch || revision.type === HeadType.RemoteBranch + ? { name: `origin/${revision.name}`, missing: false } + : undefined, + ); } @log({ args: { 1: false } }) async getTags( repoPath: string | undefined, - options?: { cursor?: string; filter?: (t: GitTag) => boolean; sort?: boolean | TagSortOptions }, + options?: { + filter?: (t: GitTag) => boolean; + paging?: PagingOptions; + sort?: boolean | TagSortOptions; + }, ): Promise> { if (repoPath == null) return emptyPagedResult; const scope = getLogScope(); - let tagsPromise = options?.cursor ? undefined : this._tagsCache.get(repoPath); + let tagsPromise = options?.paging?.cursor ? undefined : this._tagsCache.get(repoPath); if (tagsPromise == null) { async function load(this: GitHubGitProvider): Promise> { try { @@ -2379,9 +2859,12 @@ export class GitHubGitProvider implements GitProvider, Disposable { const tags: GitTag[] = []; - let cursor = options?.cursor; + let cursor = options?.paging?.cursor; const loadAll = cursor == null; + let authoredDate; + let committedDate; + while (true) { const result = await github.getTags( session.accessToken, @@ -2391,14 +2874,19 @@ export class GitHubGitProvider implements GitProvider, Disposable { ); for (const tag of result.values) { + authoredDate = + tag.target.authoredDate ?? tag.target.target?.authoredDate ?? tag.target.tagger?.date; + committedDate = + tag.target.committedDate ?? tag.target.target?.committedDate ?? tag.target.tagger?.date; + tags.push( new GitTag( repoPath!, tag.name, - tag.target.oid, - tag.target.message ?? '', - new Date(tag.target.authoredDate ?? tag.target.tagger?.date), - new Date(tag.target.committedDate ?? tag.target.tagger?.date), + tag.target.target?.oid ?? tag.target.oid, + tag.target.message ?? tag.target.target?.message ?? '', + authoredDate != null ? new Date(authoredDate) : undefined, + committedDate != null ? new Date(committedDate) : undefined, ), ); } @@ -2417,7 +2905,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { } tagsPromise = load.call(this); - if (options?.cursor == null) { + if (options?.paging?.cursor == null) { this._tagsCache.set(repoPath, tagsPromise); } } @@ -2455,8 +2943,9 @@ export class GitHubGitProvider implements GitProvider, Disposable { if (stats == null) return undefined; return { + ref: ref, + oid: '', path: this.getRelativePath(uri, repoPath), - commitSha: ref, size: stats.size, type: (stats.type & FileType.Directory) === FileType.Directory ? 'tree' : 'blob', }; @@ -2487,8 +2976,9 @@ export class GitHubGitProvider implements GitProvider, Disposable { // const stats = await workspace.fs.stat(uri); result.push({ + ref: ref, + oid: '', path: this.getRelativePath(path, uri), - commitSha: ref, size: 0, // stats?.size, type: (type & FileType.Directory) === FileType.Directory ? 'tree' : 'blob', }); @@ -2498,11 +2988,6 @@ export class GitHubGitProvider implements GitProvider, Disposable { return []; } - async getUniqueRepositoryId(_repoPath: string): Promise { - // TODO@ramint implement this if there is a way. - return undefined; - } - @log() async hasBranchOrTag( repoPath: string | undefined, @@ -2530,6 +3015,39 @@ export class GitHubGitProvider implements GitProvider, Disposable { return true; } + @log() + async isAncestorOf(repoPath: string, ref1: string, ref2: string): Promise { + if (repoPath == null) return false; + + const scope = getLogScope(); + + const { metadata, github, session } = await this.ensureRepositoryContext(repoPath); + + try { + const result = await github.getComparison( + session.accessToken, + metadata.repo.owner, + metadata.repo.name, + createRevisionRange(stripOrigin(ref1), stripOrigin(ref2), '...'), + ); + + switch (result?.status) { + case 'ahead': + case 'diverged': + return false; + case 'identical': + case 'behind': + return true; + default: + return false; + } + } catch (ex) { + Logger.error(ex, scope); + debugger; + return false; + } + } + isTrackable(uri: Uri): boolean { return this.supportedSchemes.has(uri.scheme); } @@ -2573,9 +3091,9 @@ export class GitHubGitProvider implements GitProvider, Disposable { ) { if ( !ref || - ref === GitRevision.deletedOrMissing || - (pathOrUri == null && GitRevision.isSha(ref)) || - (pathOrUri != null && GitRevision.isUncommitted(ref)) + ref === deletedOrMissing || + (pathOrUri == null && isSha(ref)) || + (pathOrUri != null && isUncommitted(ref)) ) { return ref; } @@ -2583,7 +3101,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { let relativePath; if (pathOrUri != null) { relativePath = this.getRelativePath(pathOrUri, repoPath); - } else if (!GitRevision.isShaLike(ref) || ref.endsWith('^3')) { + } else if (!isShaLike(ref) || ref.endsWith('^3')) { // If it doesn't look like a sha at all (e.g. branch name) or is a stash ref (^3) don't try to resolve it return ref; } @@ -2597,13 +3115,13 @@ export class GitHubGitProvider implements GitProvider, Disposable { session.accessToken, metadata.repo.owner, metadata.repo.name, - ref, + stripOrigin(ref), relativePath, ); if (resolved != null) return resolved; - return relativePath ? GitRevision.deletedOrMissing : ref; + return relativePath ? deletedOrMissing : ref; } @log() @@ -2619,8 +3137,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { const operations = parseSearchQuery(search); const values = operations.get('commit:'); - if (values != null) { - const commit = await this.getCommit(repoPath, values[0]); + if (values?.size) { + const commit = await this.getCommit(repoPath, first(values)!); if (commit == null) return undefined; return { @@ -2651,8 +3169,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { options?.ordering === 'date' ? 'committer-date' : options?.ordering === 'author-date' - ? 'author-date' - : undefined, + ? 'author-date' + : undefined, }); if (result == null) return undefined; @@ -2789,8 +3307,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { const values = operations.get('commit:'); if (values != null) { - const commitsResults = await Promise.allSettled[]>( - values.map(v => this.getCommit(repoPath, v.replace(doubleQuoteRegex, ''))), + const commitsResults = await Promise.allSettled( + map(values, v => this.getCommit(repoPath, v.replace(doubleQuoteRegex, ''))), ); let i = 0; @@ -2843,8 +3361,8 @@ export class GitHubGitProvider implements GitProvider, Disposable { options?.ordering === 'date' ? 'committer-date' : options?.ordering === 'author-date' - ? 'author-date' - : undefined, + ? 'author-date' + : undefined, }); if (result == null || options?.cancellation?.isCancellationRequested) { @@ -2875,7 +3393,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { }; } - return searchForCommitsCore.call(this, options?.limit); + return await searchForCommitsCore.call(this, options?.limit); } catch (ex) { if (ex instanceof GitSearchError) { throw ex; @@ -2901,24 +3419,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { async stageDirectory(_repoPath: string, _directoryOrUri: string | Uri): Promise {} @log() - async unStageFile(_repoPath: string, _pathOrUri: string | Uri): Promise {} - - @log() - async unStageDirectory(_repoPath: string, _directoryOrUri: string | Uri): Promise {} + async unstageFile(_repoPath: string, _pathOrUri: string | Uri): Promise {} @log() - async stashApply(_repoPath: string, _stashName: string, _options?: { deleteAfter?: boolean }): Promise {} - - @log() - async stashDelete(_repoPath: string, _stashName: string, _ref?: string): Promise {} - - @log({ args: { 2: uris => uris?.length } }) - async stashSave( - _repoPath: string, - _message?: string, - _uris?: Uri[], - _options?: { includeUntracked?: boolean; keepIndex?: boolean }, - ): Promise {} + async unstageDirectory(_repoPath: string, _directoryOrUri: string | Uri): Promise {} @gate() private async ensureRepositoryContext( @@ -2955,11 +3459,23 @@ export class GitHubGitProvider implements GitProvider, Disposable { } } - const metadata = await remotehub?.getMetadata(uri); + const metadata = await ensureProviderLoaded(uri, remotehub, uri => remotehub?.getMetadata(uri)); if (metadata?.provider.id !== 'github') { throw new OpenVirtualRepositoryError(repoPath, OpenVirtualRepositoryErrorReason.NotAGitHubRepository); } + const data = decodeRemoteHubAuthority(uri.authority); + // If the virtual repository is opened to a PR, then we need to ensure the owner is the owner of the current branch + if (data.metadata?.ref?.type === RepositoryRefType.PullRequest) { + const revision = await metadata.getRevision(); + if (revision.type === HeadType.RemoteBranch) { + const [remote] = revision.name.split(':'); + if (remote !== metadata.repo.owner) { + metadata.repo.owner = remote; + } + } + } + let github; let session; try { @@ -3003,6 +3519,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { private _remotehub: RemoteHubApi | undefined; private _remotehubPromise: Promise | undefined; private async ensureRemoteHubApi(): Promise; + // eslint-disable-next-line @typescript-eslint/unified-signatures private async ensureRemoteHubApi(silent: false): Promise; private async ensureRemoteHubApi(silent: boolean): Promise; private async ensureRemoteHubApi(silent?: boolean): Promise { @@ -3026,30 +3543,30 @@ export class GitHubGitProvider implements GitProvider, Disposable { private _sessionPromise: Promise | undefined; private async ensureSession(force: boolean = false, silent: boolean = false): Promise { + // never get silent in web environments, because we assume that we always have a github session there: + silent = silent && !isWeb; if (force || this._sessionPromise == null) { async function getSession(this: GitHubGitProvider): Promise { let skip = this.container.storage.get(`provider:authentication:skip:${this.descriptor.id}`, false); + const authenticationProvider = await this.authenticationService.get(this.authenticationProviderId); try { + let session; if (force) { skip = false; void this.container.storage.delete(`provider:authentication:skip:${this.descriptor.id}`); - return await authentication.getSession('github', githubAuthenticationScopes, { + session = await authenticationProvider.getSession(this.authenticationDescriptor, { forceNewSession: true, }); - } - - if (!skip && !silent) { - return await authentication.getSession('github', githubAuthenticationScopes, { - createIfNone: true, + } else if (!skip && !silent) { + session = await authenticationProvider.getSession(this.authenticationDescriptor, { + createIfNeeded: true, }); + } else { + session = await authenticationProvider.getSession(this.authenticationDescriptor); } - const session = await authentication.getSession('github', githubAuthenticationScopes, { - createIfNone: false, - silent: silent, - }); if (session != null) return session; throw new Error('User did not consent'); @@ -3100,7 +3617,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { if (typeof ref === 'string') { if (ref) { - if (GitRevision.isSha(ref)) { + if (isSha(ref)) { metadata = { v: 1, ref: { id: ref, type: 2 /* RepositoryRefType.Commit */ } }; } else { metadata = { v: 1, ref: { id: ref, type: 4 /* RepositoryRefType.Tree */ } }; @@ -3168,10 +3685,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { return revision.revision; } - if (GitRevision.isSha(ref)) return ref; + if (isSha(ref)) return ref; // TODO@eamodio need to handle ranges - if (GitRevision.isRange(ref)) return undefined; + if (isRevisionRange(ref)) return undefined; const [branchResults, tagResults] = await Promise.allSettled([ this.getBranches(repoPath, { filter: b => b.name === ref }), @@ -3186,7 +3703,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { private async getQueryArgsFromSearchQuery( search: SearchQuery, - operations: Map, + operations: Map>, repoPath: string, ) { const query = []; @@ -3194,12 +3711,12 @@ export class GitHubGitProvider implements GitProvider, Disposable { for (const [op, values] of operations.entries()) { switch (op) { case 'message:': - query.push(...values.map(m => m.replace(/ /g, '+'))); + query.push(...map(values, m => m.replace(/ /g, '+'))); break; case 'author:': { let currentUser: GitUser | undefined; - if (values.includes('@me')) { + if (values.has('@me')) { currentUser = await this.getCurrentUser(repoPath); } @@ -3239,3 +3756,38 @@ export class GitHubGitProvider implements GitProvider, Disposable { function encodeAuthority(scheme: string, metadata?: T): string { return `${scheme}${metadata != null ? `+${encodeUtf8Hex(JSON.stringify(metadata))}` : ''}`; } + +let ensuringProvider: Promise | undefined; +async function ensureProviderLoaded any>( + uri: Uri, + remotehub: RemoteHubApi, + action: T, +): Promise> { + let retrying = false; + while (true) { + try { + const result = await action(uri); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return result; + } catch (ex) { + // HACK: If the provider isn't loaded, try to force it to load + if (!retrying && (/No provider registered/i.test(ex.message) || remotehub.getProvider(uri) == null)) { + ensuringProvider ??= remotehub.loadWorkspaceContents(uri); + try { + await ensuringProvider; + retrying = true; + continue; + } catch (_ex) { + debugger; + } + } + + throw ex; + } + } +} + +//** Strips `origin/` from a reference or range, because we "fake" origin as the default remote */ +function stripOrigin(ref: T): T { + return ref?.replace(/(?:^|(?<=..))origin\//, '') as T; +} diff --git a/src/plus/integrations/providers/github/models.ts b/src/plus/integrations/providers/github/models.ts new file mode 100644 index 0000000000000..3979832407619 --- /dev/null +++ b/src/plus/integrations/providers/github/models.ts @@ -0,0 +1,506 @@ +import type { Endpoints } from '@octokit/types'; +import { GitFileIndexStatus } from '../../../../git/models/file'; +import type { IssueLabel } from '../../../../git/models/issue'; +import { Issue, RepositoryAccessLevel } from '../../../../git/models/issue'; +import type { PullRequestState } from '../../../../git/models/pullRequest'; +import { + PullRequest, + PullRequestMergeableState, + PullRequestReviewDecision, + PullRequestReviewState, + PullRequestStatusCheckRollupState, +} from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; + +export interface GitHubBlame { + ranges: GitHubBlameRange[]; + viewer?: string; +} + +export interface GitHubMember { + login: string; + avatarUrl: string; + url: string; +} + +export interface GitHubBlameRange { + startingLine: number; + endingLine: number; + commit: GitHubCommit; +} + +export interface GitHubBranch { + name: string; + target: { + oid: string; + authoredDate: string; + committedDate: string; + }; +} + +export interface GitHubCommit { + oid: string; + parents: { nodes: { oid: string }[] }; + message: string; + additions?: number | undefined; + changedFiles?: number | undefined; + deletions?: number | undefined; + author: { avatarUrl: string | undefined; date: string; email: string | undefined; name: string }; + committer: { date: string; email: string | undefined; name: string }; + + files?: Endpoints['GET /repos/{owner}/{repo}/commits/{ref}']['response']['data']['files']; +} + +export interface GitHubCommitRef { + oid: string; +} + +export type GitHubContributor = Endpoints['GET /repos/{owner}/{repo}/contributors']['response']['data'][0]; +export interface GitHubIssueOrPullRequest { + __typename: 'Issue' | 'PullRequest'; + + closed: boolean; + closedAt: string | null; + createdAt: string; + id: string; + number: number; + state: GitHubIssueOrPullRequestState; + title: string; + updatedAt: string; + url: string; +} + +export interface GitHubPagedResult { + pageInfo: GitHubPageInfo; + totalCount: number; + values: T[]; +} +export interface GitHubPageInfo { + startCursor?: string | null; + endCursor?: string | null; + hasNextPage: boolean; + hasPreviousPage: boolean; +} + +export type GitHubIssueState = 'OPEN' | 'CLOSED'; +export type GitHubPullRequestState = 'OPEN' | 'CLOSED' | 'MERGED'; +export type GitHubIssueOrPullRequestState = GitHubIssueState | GitHubPullRequestState; + +export interface GitHubPullRequestLite extends Omit { + author: GitHubMember; + + baseRefName: string; + baseRefOid: string; + + headRefName: string; + headRefOid: string; + headRepository: { + name: string; + owner: { + login: string; + }; + url: string; + }; + + isCrossRepository: boolean; + mergedAt: string | null; + permalink: string; + + repository: { + isFork: boolean; + name: string; + owner: { + login: string; + }; + url: string; + viewerPermission: GitHubViewerPermission; + }; +} + +export interface GitHubIssue extends Omit { + author: GitHubMember; + assignees: { nodes: GitHubMember[] }; + comments?: { + totalCount: number; + }; + labels?: { nodes: IssueLabel[] }; + reactions?: { + totalCount: number; + }; + repository: { + name: string; + owner: { + login: string; + }; + viewerPermission: GitHubViewerPermission; + }; +} + +export type GitHubPullRequestReviewDecision = 'CHANGES_REQUESTED' | 'APPROVED' | 'REVIEW_REQUIRED'; +export type GitHubPullRequestMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'; +export type GitHubPullRequestStatusCheckRollupState = 'SUCCESS' | 'FAILURE' | 'PENDING' | 'EXPECTED' | 'ERROR'; +export type GitHubPullRequestReviewState = 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING'; + +export interface GitHubPullRequest extends GitHubPullRequestLite { + additions: number; + assignees: { + nodes: GitHubMember[]; + }; + checksUrl: string; + deletions: number; + isDraft: boolean; + mergeable: GitHubPullRequestMergeableState; + reviewDecision: GitHubPullRequestReviewDecision; + latestReviews: { + nodes: { + author: GitHubMember; + state: GitHubPullRequestReviewState; + }[]; + }; + reviewRequests: { + nodes: { + asCodeOwner: boolean; + requestedReviewer: GitHubMember | null; + }[]; + }; + statusCheckRollup: { + state: 'SUCCESS' | 'FAILURE' | 'PENDING' | 'EXPECTED' | 'ERROR'; + } | null; + totalCommentsCount: number; + viewerCanUpdate: boolean; +} + +export function fromGitHubPullRequestLite(pr: GitHubPullRequestLite, provider: Provider): PullRequest { + return new PullRequest( + provider, + { + id: pr.author.login, + name: pr.author.login, + avatarUrl: pr.author.avatarUrl, + url: pr.author.url, + }, + String(pr.number), + pr.id, + pr.title, + pr.permalink, + { + owner: pr.repository.owner.login, + repo: pr.repository.name, + accessLevel: fromGitHubViewerPermissionToAccessLevel(pr.repository.viewerPermission), + }, + fromGitHubIssueOrPullRequestState(pr.state), + new Date(pr.createdAt), + new Date(pr.updatedAt), + pr.closedAt == null ? undefined : new Date(pr.closedAt), + pr.mergedAt == null ? undefined : new Date(pr.mergedAt), + undefined, + undefined, + { + head: { + exists: pr.headRepository != null, + owner: pr.headRepository?.owner.login, + repo: pr.headRepository?.name, + sha: pr.headRefOid, + branch: pr.headRefName, + url: pr.headRepository?.url, + }, + base: { + exists: pr.repository != null, + owner: pr.repository?.owner.login, + repo: pr.repository?.name, + sha: pr.baseRefOid, + branch: pr.baseRefName, + url: pr.repository?.url, + }, + isCrossRepository: pr.isCrossRepository, + }, + ); +} + +export function fromGitHubIssueOrPullRequestState(state: GitHubPullRequestState): PullRequestState { + return state === 'MERGED' ? 'merged' : state === 'CLOSED' ? 'closed' : 'opened'; +} + +export function toGitHubPullRequestState(state: PullRequestState): GitHubPullRequestState { + return state === 'merged' ? 'MERGED' : state === 'closed' ? 'CLOSED' : 'OPEN'; +} + +export function fromGitHubPullRequestReviewDecision( + reviewDecision: GitHubPullRequestReviewDecision, +): PullRequestReviewDecision { + switch (reviewDecision) { + case 'APPROVED': + return PullRequestReviewDecision.Approved; + case 'CHANGES_REQUESTED': + return PullRequestReviewDecision.ChangesRequested; + case 'REVIEW_REQUIRED': + return PullRequestReviewDecision.ReviewRequired; + } +} + +export function fromGitHubPullRequestReviewState(state: GitHubPullRequestReviewState): PullRequestReviewState { + switch (state) { + case 'APPROVED': + return PullRequestReviewState.Approved; + case 'CHANGES_REQUESTED': + return PullRequestReviewState.ChangesRequested; + case 'COMMENTED': + return PullRequestReviewState.Commented; + case 'DISMISSED': + return PullRequestReviewState.Dismissed; + case 'PENDING': + return PullRequestReviewState.Pending; + } +} + +export function toGitHubPullRequestReviewDecision( + reviewDecision: PullRequestReviewDecision, +): GitHubPullRequestReviewDecision { + switch (reviewDecision) { + case PullRequestReviewDecision.Approved: + return 'APPROVED'; + case PullRequestReviewDecision.ChangesRequested: + return 'CHANGES_REQUESTED'; + case PullRequestReviewDecision.ReviewRequired: + return 'REVIEW_REQUIRED'; + } +} + +export function fromGitHubPullRequestMergeableState( + mergeableState: GitHubPullRequestMergeableState, +): PullRequestMergeableState { + switch (mergeableState) { + case 'MERGEABLE': + return PullRequestMergeableState.Mergeable; + case 'CONFLICTING': + return PullRequestMergeableState.Conflicting; + case 'UNKNOWN': + return PullRequestMergeableState.Unknown; + } +} + +export function toGitHubPullRequestMergeableState( + mergeableState: PullRequestMergeableState, +): GitHubPullRequestMergeableState { + switch (mergeableState) { + case PullRequestMergeableState.Mergeable: + return 'MERGEABLE'; + case PullRequestMergeableState.Conflicting: + return 'CONFLICTING'; + case PullRequestMergeableState.Unknown: + return 'UNKNOWN'; + } +} + +export function fromGitHubPullRequestStatusCheckRollupState( + state: GitHubPullRequestStatusCheckRollupState | null | undefined, +): PullRequestStatusCheckRollupState | undefined { + switch (state) { + case 'SUCCESS': + case 'EXPECTED': + return PullRequestStatusCheckRollupState.Success; + case 'FAILURE': + case 'ERROR': + return PullRequestStatusCheckRollupState.Failed; + case 'PENDING': + return PullRequestStatusCheckRollupState.Pending; + default: + return undefined; + } +} + +export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: Provider): PullRequest { + return new PullRequest( + provider, + { + id: pr.author.login, + name: pr.author.login, + avatarUrl: pr.author.avatarUrl, + url: pr.author.url, + }, + String(pr.number), + pr.id, + pr.title, + pr.permalink, + { + owner: pr.repository.owner.login, + repo: pr.repository.name, + accessLevel: fromGitHubViewerPermissionToAccessLevel(pr.repository.viewerPermission), + }, + fromGitHubIssueOrPullRequestState(pr.state), + new Date(pr.createdAt), + new Date(pr.updatedAt), + pr.closedAt == null ? undefined : new Date(pr.closedAt), + pr.mergedAt == null ? undefined : new Date(pr.mergedAt), + fromGitHubPullRequestMergeableState(pr.mergeable), + pr.viewerCanUpdate, + { + head: { + exists: pr.headRepository != null, + owner: pr.headRepository?.owner.login, + repo: pr.headRepository?.name, + sha: pr.headRefOid, + branch: pr.headRefName, + url: pr.headRepository?.url, + }, + base: { + exists: pr.repository != null, + owner: pr.repository?.owner.login, + repo: pr.repository?.name, + sha: pr.baseRefOid, + branch: pr.baseRefName, + url: pr.repository?.url, + }, + isCrossRepository: pr.isCrossRepository, + }, + pr.isDraft, + pr.additions, + pr.deletions, + pr.totalCommentsCount, + 0, //pr.reactions.totalCount, + fromGitHubPullRequestReviewDecision(pr.reviewDecision), + pr.reviewRequests.nodes + .map(r => + r.requestedReviewer != null + ? { + isCodeOwner: r.asCodeOwner, + reviewer: { + id: r.requestedReviewer.login, + name: r.requestedReviewer.login, + avatarUrl: r.requestedReviewer.avatarUrl, + url: r.requestedReviewer.url, + }, + state: PullRequestReviewState.ReviewRequested, + } + : undefined, + ) + .filter((r?: T): r is T => Boolean(r)), + pr.latestReviews.nodes.map(r => ({ + reviewer: { + id: r.author.login, + name: r.author.login, + avatarUrl: r.author.avatarUrl, + url: r.author.url, + }, + state: fromGitHubPullRequestReviewState(r.state), + })), + pr.assignees.nodes.map(r => ({ + id: r.login, + name: r.login, + avatarUrl: r.avatarUrl, + url: r.url, + })), + fromGitHubPullRequestStatusCheckRollupState(pr.statusCheckRollup?.state), + ); +} + +export function fromGitHubIssue(value: GitHubIssue, provider: Provider): Issue { + return new Issue( + { + id: provider.id, + name: provider.name, + domain: provider.domain, + icon: provider.icon, + }, + String(value.number), + value.id, + value.title, + value.url, + new Date(value.createdAt), + new Date(value.updatedAt), + value.closed, + fromGitHubIssueOrPullRequestState(value.state), + { + id: value.author.login, + name: value.author.login, + avatarUrl: value.author.avatarUrl, + url: value.author.url, + }, + { + owner: value.repository.owner.login, + repo: value.repository.name, + accessLevel: fromGitHubViewerPermissionToAccessLevel(value.repository.viewerPermission), + }, + value.assignees.nodes.map(assignee => ({ + id: assignee.login, + name: assignee.login, + avatarUrl: assignee.avatarUrl, + url: assignee.url, + })), + value.closedAt == null ? undefined : new Date(value.closedAt), + value.labels?.nodes == null + ? undefined + : value.labels.nodes.map(label => ({ + color: label.color, + name: label.name, + })), + value.comments?.totalCount, + value.reactions?.totalCount, + ); +} + +type GitHubViewerPermission = + | 'ADMIN' // Can read, clone, and push to this repository. Can also manage issues, pull requests, and repository settings, including adding collaborators + | 'MAINTAIN' // Can read, clone, and push to this repository. They can also manage issues, pull requests, and some repository settings + | 'WRITE' // Can read, clone, and push to this repository. Can also manage issues and pull requests + | 'TRIAGE' // Can read and clone this repository. Can also manage issues and pull requests + | 'READ' // Can read and clone this repository. Can also open and comment on issues and pull requests + | 'NONE'; + +function fromGitHubViewerPermissionToAccessLevel( + permission: GitHubViewerPermission | null | undefined, +): RepositoryAccessLevel { + switch (permission) { + case 'ADMIN': + return RepositoryAccessLevel.Admin; + case 'MAINTAIN': + return RepositoryAccessLevel.Maintain; + case 'WRITE': + return RepositoryAccessLevel.Write; + case 'TRIAGE': + return RepositoryAccessLevel.Triage; + case 'READ': + return RepositoryAccessLevel.Read; + default: + return RepositoryAccessLevel.None; + } +} + +export interface GitHubTag { + name: string; + target: { + oid: string; + authoredDate?: string; + committedDate?: string; + message?: string | null; + tagger?: { + date: string; + } | null; + + target?: { + oid?: string; + authoredDate?: string; + committedDate?: string; + message?: string | null; + }; + }; +} + +export function fromCommitFileStatus( + status: NonNullable[0]['status'], +): GitFileIndexStatus | undefined { + switch (status) { + case 'added': + return GitFileIndexStatus.Added; + case 'changed': + case 'modified': + return GitFileIndexStatus.Modified; + case 'removed': + return GitFileIndexStatus.Deleted; + case 'renamed': + return GitFileIndexStatus.Renamed; + case 'copied': + return GitFileIndexStatus.Copied; + } + return undefined; +} diff --git a/src/plus/integrations/providers/gitlab.ts b/src/plus/integrations/providers/gitlab.ts new file mode 100644 index 0000000000000..654799d6c7acb --- /dev/null +++ b/src/plus/integrations/providers/gitlab.ts @@ -0,0 +1,365 @@ +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import { window } from 'vscode'; +import type { Sources } from '../../../constants.telemetry'; +import type { Container } from '../../../container'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { + PullRequest, + PullRequestMergeMethod, + PullRequestState, + SearchedPullRequest, +} from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import { log } from '../../../system/decorators/log'; +import { uniqueBy } from '../../../system/iterable'; +import { ensurePaidPlan } from '../../utils'; +import type { + IntegrationAuthenticationProviderDescriptor, + IntegrationAuthenticationService, +} from '../authentication/integrationAuthentication'; +import { HostingIntegration } from '../integration'; +import { fromGitLabMergeRequestProvidersApi } from './gitlab/models'; +import type { ProviderPullRequest } from './models'; +import { + HostingIntegrationId, + ProviderPullRequestReviewState, + providersMetadata, + SelfHostedIntegrationId, +} from './models'; +import type { ProvidersApi } from './providersApi'; + +const metadata = providersMetadata[HostingIntegrationId.GitLab]; +const authProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: metadata.id, + scopes: metadata.scopes, +}); + +const enterpriseMetadata = providersMetadata[SelfHostedIntegrationId.GitLabSelfHosted]; +const enterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: enterpriseMetadata.id, + scopes: enterpriseMetadata.scopes, +}); + +export type GitLabRepositoryDescriptor = { + key: string; + owner: string; + name: string; +}; + +abstract class GitLabIntegrationBase< + ID extends HostingIntegrationId.GitLab | SelfHostedIntegrationId.GitLabSelfHosted, +> extends HostingIntegration { + protected abstract get apiBaseUrl(): string; + + protected override async getProviderAccountForCommit( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.gitlab)?.getAccountForCommit(this, accessToken, repo.owner, repo.name, ref, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderAccountForEmail( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.gitlab)?.getAccountForEmail(this, accessToken, repo.owner, repo.name, email, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderDefaultBranch( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + ): Promise { + return (await this.container.gitlab)?.getDefaultBranch(this, accessToken, repo.owner, repo.name, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderIssueOrPullRequest( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + id: string, + ): Promise { + return (await this.container.gitlab)?.getIssueOrPullRequest( + this, + accessToken, + repo.owner, + repo.name, + Number(id), + { + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForBranch( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + branch: string, + options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + const { include, ...opts } = options ?? {}; + + const toGitLabMergeRequestState = (await import(/* webpackChunkName: "integrations" */ './gitlab/models')) + .toGitLabMergeRequestState; + return (await this.container.gitlab)?.getPullRequestForBranch( + this, + accessToken, + repo.owner, + repo.name, + branch, + { + ...opts, + include: include?.map(s => toGitLabMergeRequestState(s)), + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForCommit( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + ref: string, + ): Promise { + return (await this.container.gitlab)?.getPullRequestForCommit(this, accessToken, repo.owner, repo.name, ref, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderRepositoryMetadata( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + cancellation?: CancellationToken, + ): Promise { + return (await this.container.gitlab)?.getRepositoryMetadata( + this, + accessToken, + repo.owner, + repo.name, + { + baseUrl: this.apiBaseUrl, + }, + cancellation, + ); + } + + protected override async searchProviderMyPullRequests( + { accessToken }: AuthenticationSession, + repos?: GitLabRepositoryDescriptor[], + ): Promise { + const api = await this.getProvidersApi(); + const username = (await this.getCurrentAccount())?.username; + if (!username) { + return Promise.resolve([]); + } + const apiResult = await api.getPullRequestsForUser(this.id, username, { + accessToken: accessToken, + }); + + if (apiResult == null) { + return Promise.resolve([]); + } + + // now I'm going to filter prs from the result according to the repos parameter + let prs; + if (repos != null) { + const repoMap = new Map(); + for (const repo of repos) { + repoMap.set(`${repo.owner}/${repo.name}`, repo); + } + prs = apiResult.values.filter(pr => { + const repo = repoMap.get(`${pr.repository.owner.login}/${pr.repository.name}`); + return repo != null; + }); + } else { + prs = apiResult.values; + } + + const toQueryResult = (pr: ProviderPullRequest, reason?: string): SearchedPullRequest => { + return { + pullRequest: fromGitLabMergeRequestProvidersApi(pr, this), + reasons: reason ? [reason] : [], + }; + }; + + function uniqueWithReasons(items: T[], lookup: (item: T) => unknown): T[] { + return [ + ...uniqueBy(items, lookup, (original, current) => { + if (current.reasons.length !== 0) { + original.reasons.push(...current.reasons); + } + return original; + }), + ]; + } + + const results: SearchedPullRequest[] = uniqueWithReasons( + [ + ...prs.flatMap(pr => { + const result: SearchedPullRequest[] = []; + if (pr.assignees?.some(a => a.username === username)) { + result.push(toQueryResult(pr, 'assigned')); + } + + if ( + pr.reviews?.some( + review => + review.reviewer?.username === username || + review.state === ProviderPullRequestReviewState.ReviewRequested, + ) + ) { + result.push(toQueryResult(pr, 'review-requested')); + } + + if (pr.author?.username === username) { + result.push(toQueryResult(pr, 'authored')); + } + + // It seems like GitLab doesn't give us mentioned PRs. + // if (???) { + // return toQueryResult(pr, 'mentioned'); + // } + + return result; + }), + ], + r => r.pullRequest.url, + ); + + return results; + } + + protected override searchProviderMyIssues( + _session: AuthenticationSession, + _repos?: GitLabRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } + + protected override async mergeProviderPullRequest( + _session: AuthenticationSession, + pr: PullRequest, + options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + if (!this.isPullRequest(pr)) return false; + const api = await this.getProvidersApi(); + try { + const res = await api.mergePullRequest(this.id, pr, options); + return res; + } catch (ex) { + void this.showMergeErrorMessage(ex); + return false; + } + } + + private async showMergeErrorMessage(ex: Error) { + // Unfortunately, providers-api does not let us know the exact reason for the error, + // so we show the same message to everything. + // When we update the library, we can improve the error handling here. + const confirm = 'Reauthenticate'; + const result = await window.showErrorMessage( + `${ex.message}. Would you like to try reauthenticating to provide additional access? Your token needs to have the 'api' scope to perform merge.`, + confirm, + ); + + if (result === confirm) { + await this.reauthenticate(); + } + } + + private isPullRequest(pr: PullRequest | { id: string; headRefSha: string }): pr is PullRequest { + return (pr as PullRequest).refs != null; + } + + protected override async getProviderCurrentAccount({ + accessToken, + }: AuthenticationSession): Promise { + const api = await this.getProvidersApi(); + const currentUser = await api.getCurrentUser(this.id, { accessToken: accessToken }); + if (currentUser == null) return undefined; + + return { + provider: { + id: this.id, + name: this.name, + domain: this.domain, + icon: this.icon, + }, + id: currentUser.id, + name: currentUser.name || undefined, + email: currentUser.email || undefined, + avatarUrl: currentUser.avatarUrl || undefined, + username: currentUser.username || undefined, + }; + } +} + +export class GitLabIntegration extends GitLabIntegrationBase { + readonly authProvider = authProvider; + readonly id = HostingIntegrationId.GitLab; + protected readonly key = this.id; + readonly name: string = 'GitLab'; + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://gitlab.com/api'; + } +} + +export class GitLabSelfHostedIntegration extends GitLabIntegrationBase { + readonly authProvider = enterpriseAuthProvider; + readonly id = SelfHostedIntegrationId.GitLabSelfHosted; + protected readonly key = `${this.id}:${this.domain}` as const; + readonly name = 'GitLab Self-Hosted'; + get domain(): string { + return this._domain; + } + protected override get apiBaseUrl(): string { + return `https://${this._domain}/api`; + } + + constructor( + container: Container, + authenticationService: IntegrationAuthenticationService, + getProvidersApi: () => Promise, + private readonly _domain: string, + ) { + super(container, authenticationService, getProvidersApi); + } + + @log() + override async connect(source: Sources): Promise { + if ( + !(await ensurePaidPlan(this.container, `Rich integration with ${this.name} is a Pro feature.`, { + source: 'integrations', + detail: { action: 'connect', integration: this.id }, + })) + ) { + return false; + } + + return super.connect(source); + } +} diff --git a/src/plus/gitlab/gitlab.ts b/src/plus/integrations/providers/gitlab/gitlab.ts similarity index 69% rename from src/plus/gitlab/gitlab.ts rename to src/plus/integrations/providers/gitlab/gitlab.ts index c94193228d4e7..9303f781a1b20 100644 --- a/src/plus/gitlab/gitlab.ts +++ b/src/plus/integrations/providers/gitlab/gitlab.ts @@ -1,65 +1,79 @@ -import type { HttpsProxyAgent } from 'https-proxy-agent'; -import { Disposable, Uri, window } from 'vscode'; import type { RequestInit, Response } from '@env/fetch'; import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch'; import { isWeb } from '@env/platform'; -import { configuration } from '../../configuration'; -import { LogLevel } from '../../constants'; -import type { Container } from '../../container'; +import type { HttpsProxyAgent } from 'https-proxy-agent'; +import type { CancellationToken, Disposable } from 'vscode'; +import { Uri, window } from 'vscode'; +import type { Container } from '../../../../container'; import { AuthenticationError, AuthenticationErrorReason, + CancellationError, ProviderFetchError, - ProviderRequestClientError, - ProviderRequestNotFoundError, - ProviderRequestRateLimitError, -} from '../../errors'; -import type { Account } from '../../git/models/author'; -import type { DefaultBranch } from '../../git/models/defaultBranch'; -import type { IssueOrPullRequest } from '../../git/models/issue'; -import { IssueOrPullRequestType } from '../../git/models/issue'; -import { PullRequest } from '../../git/models/pullRequest'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; -import { Logger } from '../../logger'; -import type { LogScope } from '../../logScope'; -import { getLogScope } from '../../logScope'; + RequestClientError, + RequestNotFoundError, + RequestRateLimitError, +} from '../../../../errors'; +import type { Account } from '../../../../git/models/author'; +import type { DefaultBranch } from '../../../../git/models/defaultBranch'; +import type { IssueOrPullRequest } from '../../../../git/models/issue'; +import { PullRequest } from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; +import type { RepositoryMetadata } from '../../../../git/models/repositoryMetadata'; import { showIntegrationRequestFailed500WarningMessage, showIntegrationRequestTimedOutWarningMessage, -} from '../../messages'; -import { debug } from '../../system/decorators/log'; -import { Stopwatch } from '../../system/stopwatch'; -import { equalsIgnoreCase } from '../../system/string'; -import type { GitLabCommit, GitLabIssue, GitLabUser } from './models'; -import { GitLabMergeRequest, GitLabMergeRequestREST, GitLabMergeRequestState } from './models'; +} from '../../../../messages'; +import { debug } from '../../../../system/decorators/log'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getLogScope, setLogScopeExit } from '../../../../system/logger.scope'; +import { maybeStopWatch } from '../../../../system/stopwatch'; +import { equalsIgnoreCase } from '../../../../system/string'; +import { configuration } from '../../../../system/vscode/configuration'; +import type { + GitLabCommit, + GitLabIssue, + GitLabMergeRequest, + GitLabMergeRequestREST, + GitLabMergeRequestState, + GitLabProjectREST, + GitLabUser, +} from './models'; +import { fromGitLabMergeRequestREST, fromGitLabMergeRequestState } from './models'; + +// drop it as soon as we switch to @gitkraken/providers-api +const gitlabUserIdPrefix = 'gid://gitlab/User/'; +function buildGitLabUserId(id: string | undefined): string | undefined { + return id?.startsWith(gitlabUserIdPrefix) ? id.substring(gitlabUserIdPrefix.length) : id; +} export class GitLabApi implements Disposable { - private _disposable: Disposable | undefined; + private readonly _disposable: Disposable; private _projectIds = new Map>(); constructor(_container: Container) { - this._disposable = Disposable.from( - configuration.onDidChange(e => { - if (configuration.changed(e, 'proxy') || configuration.changed(e, 'remotes')) { - this._projectIds.clear(); - this._proxyAgents.clear(); - } - }), - configuration.onDidChangeAny(e => { - if (e.affectsConfiguration('http.proxy') || e.affectsConfiguration('http.proxyStrictSSL')) { - this._projectIds.clear(); - this._proxyAgents.clear(); - } - }), - ); + this._disposable = configuration.onDidChangeAny(e => { + if ( + configuration.changedCore(e, ['http.proxy', 'http.proxyStrictSSL']) || + configuration.changed(e, ['proxy', 'remotes']) + ) { + this.resetCaches(); + } + }); } dispose(): void { - this._disposable?.dispose(); + this._disposable.dispose(); + } + + private resetCaches(): void { + this._projectIds.clear(); + this._proxyAgents.clear(); } private _proxyAgents = new Map(); - private getProxyAgent(provider: RichRemoteProvider): HttpsProxyAgent | undefined { + private getProxyAgent(provider: Provider): HttpsProxyAgent | undefined { if (isWeb) return undefined; let proxyAgent = this._proxyAgents.get(provider.id); @@ -74,7 +88,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -83,10 +97,11 @@ export class GitLabApi implements Disposable { baseUrl?: string; avatarSize?: number; }, + cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); - const projectId = await this.getProjectId(provider, token, owner, repo, options?.baseUrl); + const projectId = await this.getProjectId(provider, token, owner, repo, options?.baseUrl, cancellation); if (!projectId) return undefined; try { @@ -99,6 +114,7 @@ export class GitLabApi implements Disposable { method: 'GET', // ...options, }, + cancellation, scope, ); @@ -126,12 +142,14 @@ export class GitLabApi implements Disposable { return { provider: provider, + id: String(user.id), name: user.name || undefined, email: commit.author_email || undefined, avatarUrl: user.avatarUrl || undefined, + username: user.username || undefined, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } @@ -139,7 +157,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForEmail( - provider: RichRemoteProvider, + provider: Provider, token: string, _owner: string, _repo: string, @@ -157,12 +175,14 @@ export class GitLabApi implements Disposable { return { provider: provider, + id: String(user.id), name: user.name || undefined, email: user.publicEmail || undefined, avatarUrl: user.avatarUrl || undefined, + username: user.username || undefined, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } @@ -170,13 +190,14 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getDefaultBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, options?: { baseUrl?: string; }, + cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); @@ -209,6 +230,7 @@ export class GitLabApi implements Disposable { { fullPath: `${owner}/${repo}`, }, + cancellation, scope, ); @@ -220,7 +242,7 @@ export class GitLabApi implements Disposable { name: defaultBranch, }; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } @@ -228,7 +250,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getIssueOrPullRequest( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -236,6 +258,7 @@ export class GitLabApi implements Disposable { options?: { baseUrl?: string; }, + cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); @@ -256,6 +279,7 @@ export class GitLabApi implements Disposable { project(fullPath: $fullPath) { mergeRequest(iid: $iid) { author { + id name avatarUrl webUrl @@ -271,6 +295,7 @@ export class GitLabApi implements Disposable { } issue(iid: $iid) { author { + id name avatarUrl webUrl @@ -296,6 +321,7 @@ export class GitLabApi implements Disposable { fullPath: `${owner}/${repo}`, iid: String(number), }, + cancellation, scope, ); @@ -303,13 +329,16 @@ export class GitLabApi implements Disposable { const issue = rsp.data.project.issue; return { provider: provider, - type: IssueOrPullRequestType.Issue, + type: 'issue', id: issue.iid, - date: new Date(issue.createdAt), + nodeId: undefined, + createdDate: new Date(issue.createdAt), + updatedDate: new Date(issue.updatedAt), title: issue.title, closed: issue.state === 'closed', closedDate: issue.closedAt == null ? undefined : new Date(issue.closedAt), url: issue.webUrl, + state: issue.state === 'locked' ? 'closed' : issue.state, }; } @@ -317,20 +346,23 @@ export class GitLabApi implements Disposable { const mergeRequest = rsp.data.project.mergeRequest; return { provider: provider, - type: IssueOrPullRequestType.PullRequest, + type: 'pullrequest', id: mergeRequest.iid, - date: new Date(mergeRequest.createdAt), + nodeId: undefined, + createdDate: new Date(mergeRequest.createdAt), + updatedDate: new Date(mergeRequest.updatedAt), title: mergeRequest.title, closed: mergeRequest.state === 'closed', // TODO@eamodio this isn't right, but GitLab doesn't seem to provide a closedAt on merge requests in GraphQL closedDate: mergeRequest.state === 'closed' ? new Date(mergeRequest.updatedAt) : undefined, url: mergeRequest.webUrl, + state: mergeRequest.state === 'locked' ? 'closed' : mergeRequest.state, }; } return undefined; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } @@ -338,7 +370,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -348,6 +380,7 @@ export class GitLabApi implements Disposable { avatarSize?: number; include?: GitLabMergeRequestState[]; }, + cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); @@ -375,6 +408,7 @@ export class GitLabApi implements Disposable { nodes { iid author { + id name avatarUrl webUrl @@ -401,21 +435,21 @@ export class GitLabApi implements Disposable { : '' } ${ - options?.include?.includes(GitLabMergeRequestState.OPEN) + options?.include?.includes('opened') ? `opened: mergeRequests(sourceBranches: $branches state: opened sort: UPDATED_DESC first: 1) { ${fragment} }` : '' } ${ - options?.include?.includes(GitLabMergeRequestState.MERGED) + options?.include?.includes('merged') ? `merged: mergeRequests(sourceBranches: $branches state: merged sort: UPDATED_DESC first: 1) { ${fragment} }` : '' } ${ - options?.include?.includes(GitLabMergeRequestState.CLOSED) + options?.include?.includes('closed') ? `closed: mergeRequests(sourceBranches: $branches state: closed sort: UPDATED_DESC first: 1) { ${fragment} }` @@ -434,6 +468,7 @@ export class GitLabApi implements Disposable { branches: [branch], state: options?.include, }, + cancellation, scope, ); @@ -444,11 +479,11 @@ export class GitLabApi implements Disposable { } else { for (const state of options.include) { let mr; - if (state === GitLabMergeRequestState.OPEN) { + if (state === 'opened') { mr = rsp?.data?.project?.opened?.nodes?.[0]; - } else if (state === GitLabMergeRequestState.MERGED) { + } else if (state === 'merged') { mr = rsp?.data?.project?.merged?.nodes?.[0]; - } else if (state === GitLabMergeRequestState.CLOSED) { + } else if (state === 'closed') { mr = rsp?.data?.project?.closed?.nodes?.[0]; } @@ -463,21 +498,25 @@ export class GitLabApi implements Disposable { return new PullRequest( provider, { + id: buildGitLabUserId(pr.author?.id) ?? '', name: pr.author?.name ?? 'Unknown', avatarUrl: pr.author?.avatarUrl ?? '', url: pr.author?.webUrl ?? '', }, String(pr.iid), + undefined, pr.title, pr.webUrl, - GitLabMergeRequest.fromState(pr.state), + { owner: owner, repo: repo }, + fromGitLabMergeRequestState(pr.state), + new Date(pr.createdAt), new Date(pr.updatedAt), // TODO@eamodio this isn't right, but GitLab doesn't seem to provide a closedAt on merge requests in GraphQL - pr.state !== GitLabMergeRequestState.CLOSED ? undefined : new Date(pr.updatedAt), + pr.state !== 'closed' ? undefined : new Date(pr.updatedAt), pr.mergedAt == null ? undefined : new Date(pr.mergedAt), ); } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } @@ -485,7 +524,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -494,16 +533,16 @@ export class GitLabApi implements Disposable { baseUrl?: string; avatarSize?: number; }, + cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); - const projectId = await this.getProjectId(provider, token, owner, repo, options?.baseUrl); + const projectId = await this.getProjectId(provider, token, owner, repo, options?.baseUrl, cancellation); if (!projectId) return undefined; try { const mrs = await this.request( provider, - token, options?.baseUrl, `v4/projects/${projectId}/repository/commits/${ref}/merge_requests`, @@ -511,6 +550,7 @@ export class GitLabApi implements Disposable { method: 'GET', // ...options, }, + cancellation, scope, ); if (mrs == null || mrs.length === 0) return undefined; @@ -518,28 +558,79 @@ export class GitLabApi implements Disposable { if (mrs.length > 1) { mrs.sort( (a, b) => - (a.state === GitLabMergeRequestState.OPEN ? -1 : 1) - - (b.state === GitLabMergeRequestState.OPEN ? -1 : 1) || + (a.state === 'opened' ? -1 : 1) - (b.state === 'opened' ? -1 : 1) || new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), ); } - return GitLabMergeRequestREST.from(mrs[0], provider); + return fromGitLabMergeRequestREST(mrs[0], provider, { owner: owner, repo: repo }); + } catch (ex) { + if (ex instanceof RequestNotFoundError) return undefined; + + throw this.handleException(ex, provider, scope); + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getRepositoryMetadata( + provider: Provider, + token: string, + owner: string, + repo: string, + options?: { + baseUrl?: string; + }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + + const projectId = await this.getProjectId(provider, token, owner, repo, options?.baseUrl, cancellation); + if (!projectId) return undefined; + + try { + const proj = await this.request( + provider, + token, + options?.baseUrl, + `v4/projects/${projectId}`, + { + method: 'GET', + // ...options, + }, + cancellation, + scope, + ); + if (proj == null) return undefined; + + return { + provider: provider, + owner: proj.namespace.full_path, + name: proj.path, + isFork: proj.forked_from_project != null, + parent: + proj.forked_from_project != null + ? { + owner: proj.forked_from_project.namespace.full_path, + name: proj.forked_from_project.path, + } + : undefined, + } satisfies RepositoryMetadata; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; throw this.handleException(ex, provider, scope); } } private async findUser( - provider: RichRemoteProvider, + provider: Provider, token: string, search: string, options?: { baseUrl?: string; avatarSize?: number; }, + cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); @@ -583,6 +674,7 @@ $search: String! { search: search, }, + cancellation, scope, ); @@ -608,7 +700,7 @@ $search: String! return users; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return []; + if (ex instanceof RequestNotFoundError) return []; this.handleException(ex, provider, scope); return []; @@ -616,17 +708,18 @@ $search: String! } private getProjectId( - provider: RichRemoteProvider, + provider: Provider, token: string, group: string, repo: string, - baseUrl?: string, + baseUrl: string | undefined, + cancellation: CancellationToken | undefined, ): Promise { const key = `${token}|${group}/${repo}`; let projectId = this._projectIds.get(key); if (projectId == null) { - projectId = this.getProjectIdCore(provider, token, group, repo, baseUrl); + projectId = this.getProjectIdCore(provider, token, group, repo, baseUrl, cancellation); this._projectIds.set(key, projectId); } @@ -634,11 +727,12 @@ $search: String! } private async getProjectIdCore( - provider: RichRemoteProvider, + provider: Provider, token: string, group: string, repo: string, - baseUrl?: string, + baseUrl: string | undefined, + cancellation: CancellationToken | undefined, ): Promise { const scope = getLogScope(); @@ -662,6 +756,7 @@ $search: String! { fullPath: `${group}/${repo}`, }, + cancellation, scope, ); @@ -673,12 +768,10 @@ $search: String! const projectId = match[1]; - if (scope != null) { - scope.exitDetails = `\u2022 projectId=${projectId}`; - } + setLogScopeExit(scope, ` \u2022 projectId=${projectId}`); return projectId; } catch (ex) { - if (ex instanceof ProviderRequestNotFoundError) return undefined; + if (ex instanceof RequestNotFoundError) return undefined; this.handleException(ex, provider, scope); return undefined; @@ -686,28 +779,34 @@ $search: String! } private async graphql( - provider: RichRemoteProvider, + provider: Provider, token: string, baseUrl: string | undefined, query: string, - variables: { [key: string]: any }, + variables: Record, + cancellation: CancellationToken | undefined, scope: LogScope | undefined, ): Promise { let rsp: Response; try { - const stopwatch = - Logger.logLevel === LogLevel.Debug || Logger.isDebugging - ? new Stopwatch(`[GITLAB] POST ${baseUrl}`, { log: false }) - : undefined; - + const sw = maybeStopWatch(`[GITLAB] POST ${baseUrl}`, { log: false }); const agent = this.getProxyAgent(provider); try { + let aborter: AbortController | undefined; + if (cancellation != null) { + if (cancellation.isCancellationRequested) throw new CancellationError(); + + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter!.abort()); + } + rsp = await wrapForForcedInsecureSSL(provider.getIgnoreSSLErrors(), () => fetch(`${baseUrl ?? 'https://gitlab.com/api'}/graphql`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, agent: agent, + signal: aborter?.signal, body: JSON.stringify({ query: query, variables: variables }), }), ); @@ -724,10 +823,10 @@ $search: String! const match = /(^[^({\n]+)/.exec(query); const message = ` ${match?.[1].trim() ?? query}`; - stopwatch?.stop({ message: message }); + sw?.stop({ message: message }); } } catch (ex) { - if (ex instanceof ProviderFetchError) { + if (ex instanceof ProviderFetchError || ex.name === 'AbortError') { this.handleRequestError(provider, token, ex, scope); } else if (Logger.isDebugging) { void window.showErrorMessage(`GitLab request failed: ${ex.message}`); @@ -738,29 +837,35 @@ $search: String! } private async request( - provider: RichRemoteProvider, + provider: Provider, token: string, baseUrl: string | undefined, route: string, options: { method: RequestInit['method'] } & Record, + cancellation: CancellationToken | undefined, scope: LogScope | undefined, ): Promise { const url = `${baseUrl ?? 'https://gitlab.com/api'}/${route}`; let rsp: Response; try { - const stopwatch = - Logger.logLevel === LogLevel.Debug || Logger.isDebugging - ? new Stopwatch(`[GITLAB] ${options?.method ?? 'GET'} ${url}`, { log: false }) - : undefined; - + const sw = maybeStopWatch(`[GITLAB] ${options?.method ?? 'GET'} ${url}`, { log: false }); const agent = this.getProxyAgent(provider); try { + let aborter: AbortController | undefined; + if (cancellation != null) { + if (cancellation.isCancellationRequested) throw new CancellationError(); + + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter!.abort()); + } + rsp = await wrapForForcedInsecureSSL(provider.getIgnoreSSLErrors(), () => fetch(url, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, agent: agent, + signal: aborter?.signal, ...options, }), ); @@ -772,10 +877,10 @@ $search: String! throw new ProviderFetchError('GitLab', rsp); } finally { - stopwatch?.stop(); + sw?.stop(); } } catch (ex) { - if (ex instanceof ProviderFetchError) { + if (ex instanceof ProviderFetchError || ex.name === 'AbortError') { this.handleRequestError(provider, token, ex, scope); } else if (Logger.isDebugging) { void window.showErrorMessage(`GitLab request failed: ${ex.message}`); @@ -786,16 +891,18 @@ $search: String! } private handleRequestError( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, - ex: ProviderFetchError, + ex: ProviderFetchError | (Error & { name: 'AbortError' }), scope: LogScope | undefined, ): void { + if (ex.name === 'AbortError' || !(ex instanceof ProviderFetchError)) throw new CancellationError(ex); + switch (ex.status) { case 404: // Not found case 410: // Gone case 422: // Unprocessable Entity - throw new ProviderRequestNotFoundError(ex); + throw new RequestNotFoundError(ex); // case 429: //Too Many Requests case 401: // Unauthorized throw new AuthenticationError('gitlab', AuthenticationErrorReason.Unauthorized, ex); @@ -811,7 +918,7 @@ $search: String! } } - throw new ProviderRequestRateLimitError(ex, token, resetAt); + throw new RequestRateLimitError(ex, token, resetAt); } throw new AuthenticationError('gitlab', AuthenticationErrorReason.Forbidden, ex); case 500: // Internal Server Error @@ -820,7 +927,7 @@ $search: String! provider?.trackRequestException(); void showIntegrationRequestFailed500WarningMessage( `${provider?.name ?? 'GitLab'} failed to respond and might be experiencing issues.${ - !provider?.custom + provider == null || provider.id === 'gitlab' ? ' Please visit the [GitLab status page](https://status.gitlab.com) for more information.' : '' }`, @@ -837,7 +944,7 @@ $search: String! } break; default: - if (ex.status >= 400 && ex.status < 500) throw new ProviderRequestClientError(ex); + if (ex.status >= 400 && ex.status < 500) throw new RequestClientError(ex); break; } @@ -849,7 +956,7 @@ $search: String! } } - private handleException(ex: Error, provider: RichRemoteProvider, scope: LogScope | undefined): Error { + private handleException(ex: Error, provider: Provider, scope: LogScope | undefined): Error { Logger.error(ex, scope); // debugger; @@ -859,7 +966,7 @@ $search: String! return ex; } - private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: RichRemoteProvider) { + private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: Provider) { if (ex.reason === AuthenticationErrorReason.Unauthorized || ex.reason === AuthenticationErrorReason.Forbidden) { const confirm = 'Reauthenticate'; const result = await window.showErrorMessage( @@ -871,6 +978,7 @@ $search: String! if (result === confirm) { await provider.reauthenticate(); + this.resetCaches(); } } else { void window.showErrorMessage(ex.message); diff --git a/src/plus/integrations/providers/gitlab/models.ts b/src/plus/integrations/providers/gitlab/models.ts new file mode 100644 index 0000000000000..846588c795540 --- /dev/null +++ b/src/plus/integrations/providers/gitlab/models.ts @@ -0,0 +1,152 @@ +import type { PullRequestState } from '../../../../git/models/pullRequest'; +import { PullRequest } from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; +import type { Integration } from '../../integration'; +import type { ProviderPullRequest } from '../models'; +import { fromProviderPullRequest } from '../models'; + +export interface GitLabUser { + id: number; + name: string; + username: string; + publicEmail: string | undefined; + state: string; + avatarUrl: string | undefined; + webUrl: string; +} + +export interface GitLabCommit { + id: string; + short_id: string; + created_at: Date; + parent_ids: string[]; + title: string; + message: string; + author_name: string; + author_email: string; + authored_date: Date; + committer_name: string; + committer_email: string; + committed_date: Date; + status: string; + project_id: number; +} + +export interface GitLabIssue { + iid: string; + author: { + name: string; + avatarUrl: string | null; + webUrl: string; + } | null; + title: string; + description: string; + createdAt: string; + updatedAt: string; + closedAt: string; + webUrl: string; + state: 'opened' | 'closed' | 'locked'; +} + +export interface GitLabMergeRequest { + iid: string; + author: { + id: string; + name: string; + avatarUrl: string | null; + webUrl: string; + } | null; + title: string; + description: string | null; + state: GitLabMergeRequestState; + createdAt: string; + updatedAt: string; + mergedAt: string | null; + webUrl: string; +} + +export type GitLabMergeRequestState = 'opened' | 'closed' | 'locked' | 'merged'; + +export function fromGitLabMergeRequestState(state: GitLabMergeRequestState): PullRequestState { + return state === 'locked' ? 'closed' : state; +} + +export function toGitLabMergeRequestState(state: PullRequestState): GitLabMergeRequestState { + return state; +} + +export interface GitLabMergeRequestREST { + id: number; + iid: number; + author: { + id: string; + name: string; + avatar_url?: string; + web_url: string; + } | null; + title: string; + description: string; + state: GitLabMergeRequestState; + created_at: string; + updated_at: string; + closed_at: string | null; + merged_at: string | null; + web_url: string; +} + +export function fromGitLabMergeRequestREST( + pr: GitLabMergeRequestREST, + provider: Provider, + repo: { owner: string; repo: string }, +): PullRequest { + return new PullRequest( + provider, + { + id: pr.author?.id ?? '', + name: pr.author?.name ?? 'Unknown', + avatarUrl: pr.author?.avatar_url ?? '', + url: pr.author?.web_url ?? '', + }, + String(pr.iid), + undefined, + pr.title, + pr.web_url, + repo, + fromGitLabMergeRequestState(pr.state), + new Date(pr.created_at), + new Date(pr.updated_at), + pr.closed_at == null ? undefined : new Date(pr.closed_at), + pr.merged_at == null ? undefined : new Date(pr.merged_at), + ); +} + +export interface GitLabProjectREST { + namespace: { + path: string; + full_path: string; + }; + path: string; + + forked_from_project?: { + namespace: { + path: string; + full_path: string; + }; + path: string; + }; +} + +export function fromGitLabMergeRequestProvidersApi(pr: ProviderPullRequest, provider: Integration): PullRequest { + const wrappedPr: ProviderPullRequest = { + ...pr, + // @gitkraken/providers-api returns global ID as id, while allover GitLens we use internal ID (iid) that is returned as `number`: + id: String(pr.number), + // Substitute some defaults that are needed to enable PRs because @gitkraken/providers-api always returns null here: + // Discussed: https://github.com/gitkraken/provider-apis-package-js/blob/6ee521eb6b46bbb759d9c68646979c3b25681d90/src/providers/gitlab/gitlab.ts#L597 + permissions: pr.permissions ?? { + canMerge: true, + canMergeAndBypassProtections: false, + }, + }; + return fromProviderPullRequest(wrappedPr, provider); +} diff --git a/src/plus/integrations/providers/jira.ts b/src/plus/integrations/providers/jira.ts new file mode 100644 index 0000000000000..55d062e8ef6f1 --- /dev/null +++ b/src/plus/integrations/providers/jira.ts @@ -0,0 +1,307 @@ +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import type { DynamicAutolinkReference } from '../../../annotations/autolinks'; +import type { AutolinkReference } from '../../../config'; +import type { Account } from '../../../git/models/author'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import { filterMap, flatten } from '../../../system/iterable'; +import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; +import type { ResourceDescriptor } from '../integration'; +import { IssueIntegration } from '../integration'; +import { IssueFilter, IssueIntegrationId, providersMetadata, toAccount, toSearchedIssue } from './models'; + +const metadata = providersMetadata[IssueIntegrationId.Jira]; +const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); + +export interface JiraBaseDescriptor extends ResourceDescriptor { + id: string; + name: string; +} +export interface JiraOrganizationDescriptor extends JiraBaseDescriptor { + url: string; + avatarUrl: string; +} + +export interface JiraProjectDescriptor extends JiraBaseDescriptor { + resourceId: string; +} + +export class JiraIntegration extends IssueIntegration { + readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; + readonly id = IssueIntegrationId.Jira; + protected readonly key = this.id; + readonly name: string = 'Jira'; + + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://api.atlassian.com'; + } + + private _autolinks: Map | undefined; + override async autolinks(): Promise<(AutolinkReference | DynamicAutolinkReference)[]> { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return []; + if (this._session == null || this._organizations == null || this._projects == null) return []; + + this._autolinks ||= new Map(); + + const cachedAutolinks = this._autolinks.get(this._session.accessToken); + if (cachedAutolinks != null) { + return cachedAutolinks; + } + + const autolinks: (AutolinkReference | DynamicAutolinkReference)[] = []; + const organizations = this._organizations.get(this._session.accessToken); + if (organizations != null) { + for (const organization of organizations) { + const projects = this._projects.get(`${this._session.accessToken}:${organization.id}`); + if (projects != null) { + for (const project of projects) { + const prefix = `${project.key}-`; + autolinks.push({ + type: 'issue', + url: `${organization.url}/browse/${prefix}`, + prefix: prefix, + title: `Open Issue ${prefix} on ${organization.name}`, + description: `${organization.name} Issue ${prefix}`, + descriptor: { ...organization }, + }); + } + } + } + } + + this._autolinks.set(this._session.accessToken, autolinks); + + return autolinks; + } + + protected override async getProviderAccountForResource( + { accessToken }: AuthenticationSession, + resource: JiraOrganizationDescriptor, + ): Promise { + const api = await this.getProvidersApi(); + const user = await api.getCurrentUserForResource(this.id, resource.id, { + accessToken: accessToken, + }); + + if (user == null) return undefined; + return toAccount(user, this); + } + + private _organizations: Map | undefined; + protected override async getProviderResourcesForUser( + { accessToken }: AuthenticationSession, + force: boolean = false, + ): Promise { + this._organizations ||= new Map(); + + const cachedResources = this._organizations.get(accessToken); + + if (cachedResources == null || force) { + const api = await this.getProvidersApi(); + const resources = await api.getJiraResourcesForCurrentUser({ accessToken: accessToken }); + this._organizations.set( + accessToken, + resources != null ? resources.map(r => ({ ...r, key: r.id })) : undefined, + ); + } + + return this._organizations.get(accessToken); + } + + private _projects: Map | undefined; + protected override async getProviderProjectsForResources( + { accessToken }: AuthenticationSession, + resources: JiraOrganizationDescriptor[], + force: boolean = false, + ): Promise { + this._projects ||= new Map(); + + let resourcesWithoutProjects = []; + if (force) { + resourcesWithoutProjects = resources; + } else { + for (const resource of resources) { + const resourceKey = `${accessToken}:${resource.id}`; + const cachedProjects = this._projects.get(resourceKey); + if (cachedProjects == null) { + resourcesWithoutProjects.push(resource); + } + } + } + + if (resourcesWithoutProjects.length > 0) { + const api = await this.getProvidersApi(); + const jiraProjectBaseDescriptors = await api.getJiraProjectsForResources( + resourcesWithoutProjects.map(r => r.id), + { accessToken: accessToken }, + ); + + for (const resource of resourcesWithoutProjects) { + const projects = jiraProjectBaseDescriptors?.filter(p => p.resourceId === resource.id); + if (projects != null) { + this._projects.set( + `${accessToken}:${resource.id}`, + projects.map(p => ({ ...p })), + ); + } + } + } + + return resources.reduce((projects, resource) => { + const resourceProjects = this._projects!.get(`${accessToken}:${resource.id}`); + if (resourceProjects != null) { + projects.push(...resourceProjects); + } + return projects; + }, []); + } + + protected override async getProviderIssuesForProject( + { accessToken }: AuthenticationSession, + project: JiraProjectDescriptor, + options?: { user: string; filters: IssueFilter[] }, + ): Promise { + let results; + + const api = await this.getProvidersApi(); + + const getSearchedUserIssuesForFilter = async ( + user: string, + filter: IssueFilter, + ): Promise => { + const results = await api.getIssuesForProject(this.id, project.name, project.resourceId, { + authorLogin: filter === IssueFilter.Author ? user : undefined, + assigneeLogins: filter === IssueFilter.Assignee ? [user] : undefined, + mentionLogin: filter === IssueFilter.Mention ? user : undefined, + accessToken: accessToken, + }); + + return results + ?.map(issue => toSearchedIssue(issue, this, filter)) + .filter((result): result is SearchedIssue => result !== undefined); + }; + + if (options?.user != null && options.filters.length > 0) { + const resultsPromise = Promise.allSettled( + options.filters.map(filter => getSearchedUserIssuesForFilter(options.user, filter)), + ); + + results = [ + ...flatten( + filterMap(await resultsPromise, r => + r.status === 'fulfilled' && r.value != null ? r.value : undefined, + ), + ), + ]; + + const resultsById = new Map(); + for (const result of results) { + if (resultsById.has(result.issue.id)) { + const existing = resultsById.get(result.issue.id)!; + existing.reasons = [...existing.reasons, ...result.reasons]; + } else { + resultsById.set(result.issue.id, result); + } + } + + return [...resultsById.values()]; + } + + results = await api.getIssuesForProject(this.id, project.name, project.resourceId, { + accessToken: accessToken, + }); + return results + ?.map(issue => toSearchedIssue(issue, this)) + .filter((result): result is SearchedIssue => result !== undefined); + } + + protected override async searchProviderMyIssues( + session: AuthenticationSession, + resources?: JiraOrganizationDescriptor[], + _cancellation?: CancellationToken, + ): Promise { + const myResources = resources ?? (await this.getProviderResourcesForUser(session)); + if (!myResources) return undefined; + + const api = await this.getProvidersApi(); + + const results: SearchedIssue[] = []; + for (const resource of myResources) { + const userLogin = (await this.getProviderAccountForResource(session, resource))?.username; + const resourceIssues = await api.getIssuesForResourceForCurrentUser(this.id, resource.id, { + accessToken: session.accessToken, + }); + const formattedIssues = resourceIssues + ?.map(issue => toSearchedIssue(issue, this, undefined, userLogin)) + .filter((result): result is SearchedIssue => result != null); + if (formattedIssues != null) { + results.push(...formattedIssues); + } + } + + return results; + } + + protected override async getProviderIssueOrPullRequest( + session: AuthenticationSession, + resource: JiraOrganizationDescriptor, + id: string, + ): Promise { + const api = await this.getProvidersApi(); + const userLogin = (await this.getProviderAccountForResource(session, resource))?.username; + const issue = await api.getIssue(this.id, resource.id, id, { accessToken: session.accessToken }); + return issue != null ? toSearchedIssue(issue, this, undefined, userLogin)?.issue : undefined; + } + + protected override async providerOnConnect(): Promise { + this._autolinks = undefined; + if (this._session == null) return; + + const storedOrganizations = this.container.storage.get(`jira:${this._session.accessToken}:organizations`); + const storedProjects = this.container.storage.get(`jira:${this._session.accessToken}:projects`); + let organizations = storedOrganizations?.data?.map(o => ({ ...o })); + let projects = storedProjects?.data?.map(p => ({ ...p })); + + if (storedOrganizations == null) { + organizations = await this.getProviderResourcesForUser(this._session, true); + await this.container.storage.store(`jira:${this._session.accessToken}:organizations`, { + v: 1, + timestamp: Date.now(), + data: organizations, + }); + } + + this._organizations ||= new Map(); + this._organizations.set(this._session.accessToken, organizations); + + if (storedProjects == null && organizations?.length) { + projects = await this.getProviderProjectsForResources(this._session, organizations); + await this.container.storage.store(`jira:${this._session.accessToken}:projects`, { + v: 1, + timestamp: Date.now(), + data: projects, + }); + } + + this._projects ||= new Map(); + for (const project of projects ?? []) { + const projectKey = `${this._session.accessToken}:${project.resourceId}`; + const projects = this._projects.get(projectKey); + if (projects == null) { + this._projects.set(projectKey, [project]); + } else { + projects.push(project); + } + } + } + + protected override providerOnDisconnect(): void { + this._organizations = undefined; + this._projects = undefined; + this._autolinks = undefined; + } +} diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts new file mode 100644 index 0000000000000..7e079798bb6c9 --- /dev/null +++ b/src/plus/integrations/providers/models.ts @@ -0,0 +1,893 @@ +import type { + Account, + ActionablePullRequest, + AzureDevOps, + AzureOrganization, + AzureProject, + Bitbucket, + EnterpriseOptions, + GetRepoInput, + GitHub, + GitLab, + GitPullRequest, + GitRepository, + Issue, + Jira, + JiraProject, + JiraResource, + PullRequestWithUniqueID, + RequestFunction, + RequestOptions, + Response, + Trello, +} from '@gitkraken/provider-apis'; +import { + EntityIdentifierUtils, + GitBuildStatusState, + GitProviderUtils, + GitPullRequestMergeableState, + GitPullRequestReviewState, + GitPullRequestState, +} from '@gitkraken/provider-apis'; +import type { GitProvider } from '@gitkraken/provider-apis/dist/providers/gitProvider'; +import type { Account as UserAccount } from '../../../git/models/author'; +import type { IssueMember, SearchedIssue } from '../../../git/models/issue'; +import { RepositoryAccessLevel } from '../../../git/models/issue'; +import type { + PullRequestMember, + PullRequestRefs, + PullRequestRepositoryIdentityDescriptor, + PullRequestReviewer, + PullRequestState, +} from '../../../git/models/pullRequest'; +import { + PullRequest, + PullRequestMergeableState, + PullRequestReviewDecision, + PullRequestReviewState, + PullRequestStatusCheckRollupState, +} from '../../../git/models/pullRequest'; +import type { ProviderReference } from '../../../git/models/remoteProvider'; +import type { EnrichableItem } from '../../launchpad/enrichmentService'; +import type { Integration } from '../integration'; +import { getEntityIdentifierInput } from './utils'; + +export type ProviderAccount = Account; +export type ProviderReposInput = (string | number)[] | GetRepoInput[]; +export type ProviderRepoInput = GetRepoInput; +export type ProviderPullRequest = GitPullRequest; +export type ProviderRepository = GitRepository; +export type ProviderIssue = Issue; +export type ProviderEnterpriseOptions = EnterpriseOptions; +export type ProviderJiraProject = JiraProject; +export type ProviderJiraResource = JiraResource; +export type ProviderAzureProject = AzureProject; +export type ProviderAzureResource = AzureOrganization; +export const ProviderPullRequestReviewState = GitPullRequestReviewState; +export const ProviderBuildStatusState = GitBuildStatusState; +export type ProviderRequestFunction = RequestFunction; +export type ProviderRequestResponse = Response; +export type ProviderRequestOptions = RequestOptions; + +export type IntegrationId = HostingIntegrationId | IssueIntegrationId | SelfHostedIntegrationId; + +export enum HostingIntegrationId { + GitHub = 'github', + GitLab = 'gitlab', + Bitbucket = 'bitbucket', + AzureDevOps = 'azureDevOps', +} + +export enum IssueIntegrationId { + Jira = 'jira', + Trello = 'trello', +} + +export enum SelfHostedIntegrationId { + GitHubEnterprise = 'github-enterprise', + GitLabSelfHosted = 'gitlab-self-hosted', +} + +const selfHostedIntegrationIds: SelfHostedIntegrationId[] = [ + SelfHostedIntegrationId.GitHubEnterprise, + SelfHostedIntegrationId.GitLabSelfHosted, +] as const; + +export const supportedIntegrationIds: IntegrationId[] = [ + HostingIntegrationId.GitHub, + HostingIntegrationId.GitLab, + HostingIntegrationId.Bitbucket, + HostingIntegrationId.AzureDevOps, + IssueIntegrationId.Jira, + IssueIntegrationId.Trello, + ...selfHostedIntegrationIds, +] as const; + +export function isSelfHostedIntegrationId(id: IntegrationId): id is SelfHostedIntegrationId { + return selfHostedIntegrationIds.includes(id as SelfHostedIntegrationId); +} + +export enum PullRequestFilter { + Author = 'author', + Assignee = 'assignee', + ReviewRequested = 'review-requested', + Mention = 'mention', +} + +export enum IssueFilter { + Author = 'author', + Assignee = 'assignee', + Mention = 'mention', +} + +export enum PagingMode { + Project = 'project', + Repo = 'repo', + Repos = 'repos', +} + +export interface PagingInput { + cursor?: string | null; + page?: number; +} + +export interface PagedRepoInput { + repo: GetRepoInput; + cursor?: string; +} + +export interface PagedProjectInput { + namespace: string; + project: string; + cursor?: string; +} + +export interface GetPullRequestsOptions { + authorLogin?: string; + assigneeLogins?: string[]; + reviewRequestedLogin?: string; + mentionLogin?: string; + cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} + baseUrl?: string; +} + +export interface GetPullRequestsForUserOptions { + includeFromArchivedRepos?: boolean; + cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} + baseUrl?: string; +} + +export interface GetPullRequestsForUserInput extends GetPullRequestsForUserOptions { + userId: string; +} + +export interface GetPullRequestsAssociatedWithUserInput extends GetPullRequestsForUserOptions { + username: string; +} + +export interface GetPullRequestsForRepoInput extends GetPullRequestsOptions { + repo: GetRepoInput; +} + +export interface GetPullRequestsForReposInput extends GetPullRequestsOptions { + repos: GetRepoInput[]; +} + +export interface GetPullRequestsForRepoIdsInput extends GetPullRequestsOptions { + repoIds: (string | number)[]; +} + +export interface GetIssuesOptions { + authorLogin?: string; + assigneeLogins?: string[]; + mentionLogin?: string; + cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} + baseUrl?: string; +} + +export interface GetIssuesForRepoInput extends GetIssuesOptions { + repo: GetRepoInput; +} + +export interface GetIssuesForReposInput extends GetIssuesOptions { + repos: GetRepoInput[]; +} + +export interface GetIssuesForRepoIdsInput extends GetIssuesOptions { + repoIds: (string | number)[]; +} + +export interface GetIssuesForProjectInput extends GetIssuesOptions { + project: string; + resourceId: string; +} + +export interface GetIssuesForAzureProjectInput extends GetIssuesOptions { + namespace: string; + project: string; +} + +export interface GetReposOptions { + cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} +} + +export interface GetReposForAzureProjectInput { + namespace: string; + project: string; +} + +export interface PageInfo { + hasNextPage: boolean; + endCursor?: string | null; + nextPage?: number | null; +} + +export type GetPullRequestsForReposFn = ( + input: (GetPullRequestsForReposInput | GetPullRequestsForRepoIdsInput) & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderPullRequest[]; pageInfo?: PageInfo }>; + +export type GetPullRequestsForRepoFn = ( + input: GetPullRequestsForRepoInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderPullRequest[]; pageInfo?: PageInfo }>; + +export type GetPullRequestsForUserFn = ( + input: GetPullRequestsForUserInput | GetPullRequestsAssociatedWithUserInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderPullRequest[]; pageInfo?: PageInfo }>; + +export type GetPullRequestsForAzureProjectsFn = ( + input: { projects: { namespace: string; project: string }[]; authorLogin?: string; assigneeLogins?: string[] }, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderPullRequest[] }>; + +export type MergePullRequestFn = GitProvider['mergePullRequest']; + +export type GetIssueFn = ( + input: { resourceId: string; number: string }, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue }>; + +export type GetIssuesForReposFn = ( + input: (GetIssuesForReposInput | GetIssuesForRepoIdsInput) & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue[]; pageInfo?: PageInfo }>; + +export type GetIssuesForRepoFn = ( + input: GetIssuesForRepoInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue[]; pageInfo?: PageInfo }>; + +export type GetIssuesForAzureProjectFn = ( + input: GetIssuesForAzureProjectInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue[]; pageInfo?: PageInfo }>; + +export type GetReposForAzureProjectFn = ( + input: GetReposForAzureProjectInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderRepository[]; pageInfo?: PageInfo }>; + +export type GetCurrentUserFn = ( + input: Record, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderAccount }>; +export type GetCurrentUserForInstanceFn = ( + input: { namespace: string }, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderAccount }>; +export type GetCurrentUserForResourceFn = ( + input: { resourceId: string }, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderAccount }>; + +export type GetJiraResourcesForCurrentUserFn = (options?: EnterpriseOptions) => Promise<{ data: JiraResource[] }>; +export type GetJiraProjectsForResourcesFn = ( + input: { resourceIds: string[] }, + options?: EnterpriseOptions, +) => Promise<{ data: JiraProject[] }>; +export type GetAzureResourcesForUserFn = ( + input: { userId: string }, + options?: EnterpriseOptions, +) => Promise<{ data: AzureOrganization[] }>; +export type GetAzureProjectsForResourceFn = ( + input: { namespace: string; cursor?: string }, + options?: EnterpriseOptions, +) => Promise<{ data: AzureProject[]; pageInfo?: PageInfo }>; +export type GetIssuesForProjectFn = Jira['getIssuesForProject']; +export type GetIssuesForResourceForCurrentUserFn = ( + input: { resourceId: string }, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderIssue[] }>; + +export interface ProviderInfo extends ProviderMetadata { + provider: GitHub | GitLab | Bitbucket | Jira | Trello | AzureDevOps; + getPullRequestsForReposFn?: GetPullRequestsForReposFn; + getPullRequestsForRepoFn?: GetPullRequestsForRepoFn; + getPullRequestsForUserFn?: GetPullRequestsForUserFn; + getPullRequestsForAzureProjectsFn?: GetPullRequestsForAzureProjectsFn; + getIssueFn?: GetIssueFn; + getIssuesForReposFn?: GetIssuesForReposFn; + getIssuesForRepoFn?: GetIssuesForRepoFn; + getIssuesForAzureProjectFn?: GetIssuesForAzureProjectFn; + getCurrentUserFn?: GetCurrentUserFn; + getCurrentUserForInstanceFn?: GetCurrentUserForInstanceFn; + getCurrentUserForResourceFn?: GetCurrentUserForResourceFn; + getJiraResourcesForCurrentUserFn?: GetJiraResourcesForCurrentUserFn; + getAzureResourcesForUserFn?: GetAzureResourcesForUserFn; + getJiraProjectsForResourcesFn?: GetJiraProjectsForResourcesFn; + getAzureProjectsForResourceFn?: GetAzureProjectsForResourceFn; + getIssuesForProjectFn?: GetIssuesForProjectFn; + getReposForAzureProjectFn?: GetReposForAzureProjectFn; + getIssuesForResourceForCurrentUserFn?: GetIssuesForResourceForCurrentUserFn; + mergePullRequestFn?: MergePullRequestFn; +} + +export interface ProviderMetadata { + domain: string; + id: IntegrationId; + issuesPagingMode?: PagingMode; + pullRequestsPagingMode?: PagingMode; + scopes: string[]; + supportedPullRequestFilters?: PullRequestFilter[]; + supportedIssueFilters?: IssueFilter[]; +} + +export type Providers = Record; +export type ProvidersMetadata = Record; + +export const providersMetadata: ProvidersMetadata = { + [HostingIntegrationId.GitHub]: { + domain: 'github.com', + id: HostingIntegrationId.GitHub, + issuesPagingMode: PagingMode.Repos, + pullRequestsPagingMode: PagingMode.Repos, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + PullRequestFilter.Mention, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + scopes: ['repo', 'read:user', 'user:email'], + }, + [SelfHostedIntegrationId.GitHubEnterprise]: { + domain: '', + id: SelfHostedIntegrationId.GitHubEnterprise, + issuesPagingMode: PagingMode.Repos, + pullRequestsPagingMode: PagingMode.Repos, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + PullRequestFilter.Mention, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + scopes: ['repo', 'read:user', 'user:email'], + }, + [HostingIntegrationId.GitLab]: { + domain: 'gitlab.com', + id: HostingIntegrationId.GitLab, + issuesPagingMode: PagingMode.Repo, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee], + scopes: ['api', 'read_user', 'read_repository'], + }, + [SelfHostedIntegrationId.GitLabSelfHosted]: { + domain: '', + id: SelfHostedIntegrationId.GitLabSelfHosted, + issuesPagingMode: PagingMode.Repo, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee], + scopes: ['api', 'read_user', 'read_repository'], + }, + [HostingIntegrationId.Bitbucket]: { + domain: 'bitbucket.org', + id: HostingIntegrationId.Bitbucket, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'id' property on account for PR filters + supportedPullRequestFilters: [PullRequestFilter.Author], + scopes: ['account:read', 'repository:read', 'pullrequest:read', 'issue:read'], + }, + [HostingIntegrationId.AzureDevOps]: { + domain: 'dev.azure.com', + id: HostingIntegrationId.AzureDevOps, + issuesPagingMode: PagingMode.Project, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'id' property on account for PR filters + supportedPullRequestFilters: [PullRequestFilter.Author, PullRequestFilter.Assignee], + // Use 'name' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + scopes: ['vso.code', 'vso.identity', 'vso.project', 'vso.profile', 'vso.work'], + }, + [IssueIntegrationId.Jira]: { + domain: 'atlassian.net', + id: IssueIntegrationId.Jira, + scopes: [ + 'read:status:jira', + 'read:application-role:jira', + 'write:attachment:jira', + 'read:comment:jira', + 'read:project-category:jira', + 'read:project:jira', + 'read:issue.vote:jira', + 'read:field-configuration:jira', + 'write:issue:jira', + 'read:issue-security-level:jira', + 'write:issue.property:jira', + 'read:issue.changelog:jira', + 'read:avatar:jira', + 'read:issue-meta:jira', + 'read:permission:jira', + 'offline_access', + 'read:issue:jira', + 'read:me', + 'read:audit-log:jira', + 'read:project.component:jira', + 'read:group:jira', + 'read:project-role:jira', + 'write:comment:jira', + 'read:label:jira', + 'write:comment.property:jira', + 'read:issue-details:jira', + 'read:issue-type-hierarchy:jira', + 'read:issue.transition:jira', + 'read:user:jira', + 'read:field:jira', + 'read:issue-type:jira', + 'read:project.property:jira', + 'read:comment.property:jira', + 'read:project-version:jira', + ], + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + }, + [IssueIntegrationId.Trello]: { + domain: 'trello.com', + id: IssueIntegrationId.Trello, + scopes: [], + }, +}; + +export function getReasonsForUserIssue(issue: ProviderIssue, userLogin: string): string[] { + const reasons: string[] = []; + let isAuthor = false; + let isAssignee = false; + if (issue.author?.username === userLogin || issue.author?.name === userLogin) { + reasons.push('authored'); + isAuthor = true; + } + if (issue.assignees?.some(assignee => assignee.username === userLogin || assignee.name === userLogin)) { + reasons.push('assigned'); + isAssignee = true; + } + + // TODO: Impossible to denote all issues we are mentioned on given their properties. for now just + // assume we are mentioned on any of our issues we are not the author or assignee on + if (!isAuthor && !isAssignee) { + reasons.push('mentioned'); + } + + return reasons; +} + +export function toSearchedIssue( + issue: ProviderIssue, + provider: ProviderReference, + filterUsed?: IssueFilter, + userLogin?: string, +): SearchedIssue | undefined { + // TODO: Add some protections/baselines rather than killing the transformation here + if (issue.updatedDate == null || issue.author == null || issue.url == null) return undefined; + + return { + reasons: + filterUsed != null + ? [issueFilterToReason(filterUsed)] + : userLogin != null + ? getReasonsForUserIssue(issue, userLogin) + : [], + issue: { + type: 'issue', + provider: provider, + id: issue.number, + nodeId: issue.graphQLId ?? issue.id, + title: issue.title, + url: issue.url, + createdDate: issue.createdDate, + updatedDate: issue.updatedDate, + closedDate: issue.closedDate ?? undefined, + closed: issue.closedDate != null, + state: issue.closedDate != null ? 'closed' : 'opened', + author: { + id: issue.author.id ?? '', + name: issue.author.name ?? '', + avatarUrl: issue.author.avatarUrl ?? undefined, + url: issue.author.url ?? undefined, + }, + assignees: + issue.assignees?.map(assignee => ({ + id: assignee.id ?? '', + name: assignee.name ?? '', + avatarUrl: assignee.avatarUrl ?? undefined, + url: assignee.url ?? undefined, + })) ?? [], + repository: + issue.repository?.owner?.login != null + ? { + owner: issue.repository.owner.login, + repo: issue.repository.name, + } + : undefined, + labels: issue.labels.map(label => ({ color: label.color ?? undefined, name: label.name })), + commentsCount: issue.commentCount ?? undefined, + thumbsUpCount: issue.upvoteCount ?? undefined, + }, + }; +} + +export function issueFilterToReason(filter: IssueFilter): 'authored' | 'assigned' | 'mentioned' { + switch (filter) { + case IssueFilter.Author: + return 'authored'; + case IssueFilter.Assignee: + return 'assigned'; + case IssueFilter.Mention: + return 'mentioned'; + } +} + +export function toAccount(account: ProviderAccount, provider: ProviderReference): UserAccount { + return { + provider: provider, + id: account.id, + name: account.name ?? undefined, + email: account.email ?? undefined, + avatarUrl: account.avatarUrl ?? undefined, + username: account.username ?? undefined, + }; +} + +export const toProviderBuildStatusState = { + [PullRequestStatusCheckRollupState.Success]: GitBuildStatusState.Success, + [PullRequestStatusCheckRollupState.Failed]: GitBuildStatusState.Failed, + [PullRequestStatusCheckRollupState.Pending]: GitBuildStatusState.Pending, +}; + +export const fromProviderBuildStatusState = { + [GitBuildStatusState.Success]: PullRequestStatusCheckRollupState.Success, + [GitBuildStatusState.Failed]: PullRequestStatusCheckRollupState.Failed, + [GitBuildStatusState.Pending]: PullRequestStatusCheckRollupState.Pending, + [GitBuildStatusState.ActionRequired]: PullRequestStatusCheckRollupState.Failed, + // TODO: The rest of these are defaulted because we don't have a matching state for them + [GitBuildStatusState.Error]: undefined, + [GitBuildStatusState.Cancelled]: undefined, + [GitBuildStatusState.OptionalActionRequired]: undefined, + [GitBuildStatusState.Skipped]: undefined, + [GitBuildStatusState.Running]: undefined, + [GitBuildStatusState.Warning]: undefined, +}; + +export const toProviderPullRequestReviewState = { + [PullRequestReviewState.Approved]: GitPullRequestReviewState.Approved, + [PullRequestReviewState.ChangesRequested]: GitPullRequestReviewState.ChangesRequested, + [PullRequestReviewState.Commented]: GitPullRequestReviewState.Commented, + [PullRequestReviewState.ReviewRequested]: GitPullRequestReviewState.ReviewRequested, + [PullRequestReviewState.Dismissed]: null, + [PullRequestReviewState.Pending]: null, +}; + +export const fromProviderPullRequestReviewState = { + [GitPullRequestReviewState.Approved]: PullRequestReviewState.Approved, + [GitPullRequestReviewState.ChangesRequested]: PullRequestReviewState.ChangesRequested, + [GitPullRequestReviewState.Commented]: PullRequestReviewState.Commented, + [GitPullRequestReviewState.ReviewRequested]: PullRequestReviewState.ReviewRequested, +}; + +export const toProviderPullRequestMergeableState = { + [PullRequestMergeableState.Mergeable]: GitPullRequestMergeableState.Mergeable, + [PullRequestMergeableState.Conflicting]: GitPullRequestMergeableState.Conflicts, + [PullRequestMergeableState.Unknown]: GitPullRequestMergeableState.Unknown, +}; + +export const fromProviderPullRequestMergeableState = { + [GitPullRequestMergeableState.Mergeable]: PullRequestMergeableState.Mergeable, + [GitPullRequestMergeableState.Conflicts]: PullRequestMergeableState.Conflicting, + [GitPullRequestMergeableState.Unknown]: PullRequestMergeableState.Unknown, + [GitPullRequestMergeableState.Behind]: PullRequestMergeableState.Unknown, + [GitPullRequestMergeableState.Blocked]: PullRequestMergeableState.Unknown, + [GitPullRequestMergeableState.UnknownAndBlocked]: PullRequestMergeableState.Unknown, + [GitPullRequestMergeableState.Unstable]: PullRequestMergeableState.Unknown, +}; + +export function toProviderReviews(reviewers: PullRequestReviewer[]): ProviderPullRequest['reviews'] { + return reviewers + .filter(r => r.state !== PullRequestReviewState.Dismissed && r.state !== PullRequestReviewState.Pending) + .map(reviewer => ({ + reviewer: toProviderAccount(reviewer.reviewer), + state: toProviderPullRequestReviewState[reviewer.state] ?? GitPullRequestReviewState.ReviewRequested, + })); +} + +export function toReviewRequests(reviews: ProviderPullRequest['reviews']): PullRequestReviewer[] | undefined { + return reviews == null + ? undefined + : reviews + ?.filter(r => r.state === GitPullRequestReviewState.ReviewRequested) + .map(r => ({ + isCodeOwner: false, // TODO: Find this value, and implement in the shared lib if needed + reviewer: fromProviderAccount(r.reviewer), + state: PullRequestReviewState.ReviewRequested, + })); +} + +export function toCompletedReviews(reviews: ProviderPullRequest['reviews']): PullRequestReviewer[] | undefined { + return reviews == null + ? undefined + : reviews + ?.filter(r => r.state !== GitPullRequestReviewState.ReviewRequested) + .map(r => ({ + isCodeOwner: false, // TODO: Find this value, and implement in the shared lib if needed + reviewer: fromProviderAccount(r.reviewer), + state: fromProviderPullRequestReviewState[r.state], + })); +} + +export function toProviderReviewDecision( + reviewDecision?: PullRequestReviewDecision, + reviewers?: PullRequestReviewer[], +): GitPullRequestReviewState | null { + switch (reviewDecision) { + case PullRequestReviewDecision.Approved: + return GitPullRequestReviewState.Approved; + case PullRequestReviewDecision.ChangesRequested: + return GitPullRequestReviewState.ChangesRequested; + case PullRequestReviewDecision.ReviewRequired: + return GitPullRequestReviewState.ReviewRequested; + default: { + if (reviewers?.some(r => r.state === PullRequestReviewState.ReviewRequested)) { + return GitPullRequestReviewState.ReviewRequested; + } else if (reviewers?.some(r => r.state === PullRequestReviewState.Commented)) { + return GitPullRequestReviewState.Commented; + } + return null; + } + } +} + +export const fromPullRequestReviewDecision = { + [GitPullRequestReviewState.Approved]: PullRequestReviewDecision.Approved, + [GitPullRequestReviewState.ChangesRequested]: PullRequestReviewDecision.ChangesRequested, + [GitPullRequestReviewState.Commented]: undefined, + [GitPullRequestReviewState.ReviewRequested]: PullRequestReviewDecision.ReviewRequired, +}; + +export function toProviderPullRequestState(state: PullRequestState): GitPullRequestState { + return state === 'opened' + ? GitPullRequestState.Open + : state === 'closed' + ? GitPullRequestState.Closed + : GitPullRequestState.Merged; +} + +export function fromProviderPullRequestState(state: GitPullRequestState): PullRequestState { + return state === GitPullRequestState.Open ? 'opened' : state === GitPullRequestState.Closed ? 'closed' : 'merged'; +} + +export function toProviderPullRequest(pr: PullRequest): ProviderPullRequest { + const prReviews = [...(pr.reviewRequests ?? []), ...(pr.latestReviews ?? [])]; + return { + id: pr.id, + graphQLId: pr.nodeId, + number: Number.parseInt(pr.id, 10), + title: pr.title, + url: pr.url, + state: toProviderPullRequestState(pr.state), + isDraft: pr.isDraft ?? false, + createdDate: pr.createdDate, + updatedDate: pr.updatedDate, + closedDate: pr.closedDate ?? null, + mergedDate: pr.mergedDate ?? null, + commentCount: pr.commentsCount ?? null, + upvoteCount: pr.thumbsUpCount ?? null, + commitCount: null, + fileCount: null, + additions: pr.additions ?? null, + deletions: pr.deletions ?? null, + author: toProviderAccount(pr.author), + assignees: pr.assignees?.map(toProviderAccount) ?? null, + baseRef: + pr.refs?.base == null + ? null + : { + name: pr.refs.base.branch, + oid: pr.refs.base.sha, + }, + headRef: + pr.refs?.head == null + ? null + : { + name: pr.refs.head.branch, + oid: pr.refs.head.sha, + }, + reviews: toProviderReviews(prReviews), + reviewDecision: toProviderReviewDecision(pr.reviewDecision, prReviews), + repository: + pr.repository != null + ? { + id: pr.repository.repo, + name: pr.repository.repo, + owner: { + login: pr.repository.owner, + }, + remoteInfo: null, // TODO: Add the urls to our model + } + : { + id: '', + name: '', + owner: { + login: '', + }, + remoteInfo: null, + }, + headRepository: + pr.refs?.head != null + ? { + id: pr.refs.head.repo, + name: pr.refs.head.repo, + owner: { + login: pr.refs.head.owner, + }, + remoteInfo: null, + } + : null, + headCommit: + pr.statusCheckRollupState != null + ? { + buildStatuses: [ + { + completedAt: null, + description: '', + name: '', + state: toProviderBuildStatusState[pr.statusCheckRollupState], + startedAt: null, + stage: null, + url: '', + }, + ], + } + : null, + permissions: { + canMerge: + pr.viewerCanUpdate === true && + pr.repository.accessLevel != null && + pr.repository.accessLevel >= RepositoryAccessLevel.Write, + canMergeAndBypassProtections: + pr.viewerCanUpdate === true && + pr.repository.accessLevel != null && + pr.repository.accessLevel >= RepositoryAccessLevel.Admin, + }, + mergeableState: pr.mergeableState + ? toProviderPullRequestMergeableState[pr.mergeableState] + : GitPullRequestMergeableState.Unknown, + }; +} + +export function fromProviderPullRequest(pr: ProviderPullRequest, integration: Integration): PullRequest { + return new PullRequest( + integration, + fromProviderAccount(pr.author), + pr.id, + pr.graphQLId, + pr.title, + pr.url ?? '', + { + owner: pr.repository.owner.login, + repo: pr.repository.name, + // This has to be here until we can take this information from ProviderPullRequest: + accessLevel: RepositoryAccessLevel.Write, + }, + fromProviderPullRequestState(pr.state), + pr.createdDate, + pr.updatedDate, + pr.closedDate ?? undefined, + pr.mergedDate ?? undefined, + pr.mergeableState ? fromProviderPullRequestMergeableState[pr.mergeableState] : undefined, + pr.permissions?.canMerge || pr.permissions?.canMergeAndBypassProtections ? true : undefined, + { + base: { + branch: pr.baseRef?.name ?? '', + sha: pr.baseRef?.oid ?? '', + repo: pr.repository.name, + owner: pr.repository.owner.login, + exists: pr.baseRef != null, + url: pr.repository.remoteInfo?.cloneUrlHTTPS + ? pr.repository.remoteInfo.cloneUrlHTTPS.replace(/\.git$/, '') + : '', + }, + head: { + branch: pr.headRef?.name ?? '', + sha: pr.headRef?.oid ?? '', + repo: pr.headRepository?.name ?? '', + owner: pr.headRepository?.owner.login ?? '', + exists: pr.headRef != null, + url: pr.headRepository?.remoteInfo?.cloneUrlHTTPS + ? pr.headRepository.remoteInfo.cloneUrlHTTPS.replace(/\.git$/, '') + : '', + }, + isCrossRepository: pr.headRepository?.id !== pr.repository.id, + }, + pr.isDraft, + pr.additions ?? undefined, + pr.deletions ?? undefined, + pr.commentCount ?? undefined, + pr.upvoteCount ?? undefined, + pr.reviewDecision ? fromPullRequestReviewDecision[pr.reviewDecision] : undefined, + toReviewRequests(pr.reviews), + toCompletedReviews(pr.reviews), + pr.assignees?.map(fromProviderAccount) ?? undefined, + pr.headCommit?.buildStatuses?.[0]?.state + ? fromProviderBuildStatusState[pr.headCommit.buildStatuses[0].state] + : undefined, + ); +} + +export function toProviderPullRequestWithUniqueId(pr: PullRequest): PullRequestWithUniqueID { + return { + ...toProviderPullRequest(pr), + uuid: EntityIdentifierUtils.encode(getEntityIdentifierInput(pr)), + }; +} + +export function toProviderAccount(account: PullRequestMember | IssueMember): ProviderAccount { + return { + id: account.id ?? null, + avatarUrl: account.avatarUrl ?? null, + name: account.name ?? null, + url: account.url ?? null, + // TODO: Implement these in our own model + email: '', + username: account.name ?? null, + }; +} + +export function fromProviderAccount(account: ProviderAccount | null): PullRequestMember | IssueMember { + return { + id: account?.id ?? '', + name: account?.name ?? 'unknown', + avatarUrl: account?.avatarUrl ?? undefined, + url: account?.url ?? '', + }; +} + +export type ProviderActionablePullRequest = ActionablePullRequest; + +export type EnrichablePullRequest = ProviderPullRequest & { + uuid: string; + type: 'pullrequest'; + provider: ProviderReference; + enrichable: EnrichableItem; + repoIdentity: PullRequestRepositoryIdentityDescriptor; + refs?: PullRequestRefs; + underlyingPullRequest: PullRequest; +}; + +export const getActionablePullRequests = GitProviderUtils.getActionablePullRequests; diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts new file mode 100644 index 0000000000000..c998f830e5bea --- /dev/null +++ b/src/plus/integrations/providers/providersApi.ts @@ -0,0 +1,862 @@ +import type { Response as FetchResponse } from '@env/fetch'; +import { fetch as _fetch, getProxyAgent } from '@env/fetch'; +import { getPlatform } from '@env/platform'; +import ProviderApis from '@gitkraken/provider-apis'; +import { version as codeVersion, env } from 'vscode'; +import type { Container } from '../../../container'; +import { + AuthenticationError, + AuthenticationErrorReason, + RequestClientError, + RequestRateLimitError, +} from '../../../errors'; +import type { PagedResult } from '../../../git/gitProvider'; +import type { PullRequest, PullRequestMergeMethod } from '../../../git/models/pullRequest'; +import { base64 } from '../../../system/string'; +import type { IntegrationAuthenticationService } from '../authentication/integrationAuthentication'; +import type { + GetAzureProjectsForResourceFn, + GetAzureResourcesForUserFn, + GetCurrentUserFn, + GetCurrentUserForInstanceFn, + GetIssuesForAzureProjectFn, + GetIssuesForRepoFn, + GetIssuesForReposFn, + GetIssuesOptions, + GetPullRequestsForAzureProjectsFn, + GetPullRequestsForRepoFn, + GetPullRequestsForReposFn, + GetPullRequestsForUserFn, + GetPullRequestsForUserOptions, + GetPullRequestsOptions, + GetReposForAzureProjectFn, + GetReposOptions, + IntegrationId, + IssueFilter, + PageInfo, + PagingMode, + ProviderAccount, + ProviderAzureProject, + ProviderAzureResource, + ProviderInfo, + ProviderIssue, + ProviderJiraProject, + ProviderJiraResource, + ProviderPullRequest, + ProviderRepoInput, + ProviderReposInput, + ProviderRepository, + ProviderRequestFunction, + ProviderRequestOptions, + ProviderRequestResponse, + Providers, + PullRequestFilter, +} from './models'; +import { HostingIntegrationId, IssueIntegrationId, providersMetadata, SelfHostedIntegrationId } from './models'; + +export class ProvidersApi { + private readonly providers: Providers; + + constructor( + private readonly container: Container, + private readonly authenticationService: IntegrationAuthenticationService, + ) { + const proxyAgent = getProxyAgent(); + const userAgent = `${ + container.debugging ? 'GitLens-Debug' : container.prerelease ? 'GitLens-Pre' : 'GitLens' + }/${container.version} (${env.appName}/${codeVersion}; ${getPlatform()})`; + const customFetch: ProviderRequestFunction = async ({ + url, + ...options + }: ProviderRequestOptions): Promise> => { + const response = await _fetch(url, { + agent: proxyAgent, + ...options, + headers: { + 'User-Agent': userAgent, + ...options.headers, + }, + }); + + return parseFetchResponseForApi(response); + }; + const providerApis = ProviderApis({ request: customFetch }); + this.providers = { + [HostingIntegrationId.GitHub]: { + ...providersMetadata[HostingIntegrationId.GitHub], + provider: providerApis.github, + getCurrentUserFn: providerApis.github.getCurrentUser.bind(providerApis.github) as GetCurrentUserFn, + getPullRequestsForReposFn: providerApis.github.getPullRequestsForRepos.bind( + providerApis.github, + ) as GetPullRequestsForReposFn, + getPullRequestsForUserFn: providerApis.github.getPullRequestsAssociatedWithUser.bind( + providerApis.github, + ) as GetPullRequestsForUserFn, + getIssuesForReposFn: providerApis.github.getIssuesForRepos.bind( + providerApis.github, + ) as GetIssuesForReposFn, + }, + [SelfHostedIntegrationId.GitHubEnterprise]: { + ...providersMetadata[SelfHostedIntegrationId.GitHubEnterprise], + provider: providerApis.github, + getCurrentUserFn: providerApis.github.getCurrentUser.bind(providerApis.github) as GetCurrentUserFn, + getPullRequestsForReposFn: providerApis.github.getPullRequestsForRepos.bind( + providerApis.github, + ) as GetPullRequestsForReposFn, + getPullRequestsForUserFn: providerApis.github.getPullRequestsAssociatedWithUser.bind( + providerApis.github, + ) as GetPullRequestsForUserFn, + getIssuesForReposFn: providerApis.github.getIssuesForRepos.bind( + providerApis.github, + ) as GetIssuesForReposFn, + }, + [HostingIntegrationId.GitLab]: { + ...providersMetadata[HostingIntegrationId.GitLab], + provider: providerApis.gitlab, + getCurrentUserFn: providerApis.gitlab.getCurrentUser.bind(providerApis.gitlab) as GetCurrentUserFn, + getPullRequestsForReposFn: providerApis.gitlab.getPullRequestsForRepos.bind( + providerApis.gitlab, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind( + providerApis.gitlab, + ) as GetPullRequestsForRepoFn, + getPullRequestsForUserFn: providerApis.gitlab.getPullRequestsAssociatedWithUser.bind( + providerApis.gitlab, + ) as GetPullRequestsForUserFn, + getIssuesForReposFn: providerApis.gitlab.getIssuesForRepos.bind( + providerApis.gitlab, + ) as GetIssuesForReposFn, + getIssuesForRepoFn: providerApis.gitlab.getIssuesForRepo.bind( + providerApis.gitlab, + ) as GetIssuesForRepoFn, + mergePullRequestFn: providerApis.gitlab.mergePullRequest.bind(providerApis.gitlab), + }, + [SelfHostedIntegrationId.GitLabSelfHosted]: { + ...providersMetadata[SelfHostedIntegrationId.GitLabSelfHosted], + provider: providerApis.gitlab, + getCurrentUserFn: providerApis.gitlab.getCurrentUser.bind(providerApis.gitlab) as GetCurrentUserFn, + getPullRequestsForReposFn: providerApis.gitlab.getPullRequestsForRepos.bind( + providerApis.gitlab, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind( + providerApis.gitlab, + ) as GetPullRequestsForRepoFn, + getPullRequestsForUserFn: providerApis.gitlab.getPullRequestsAssociatedWithUser.bind( + providerApis.gitlab, + ) as GetPullRequestsForUserFn, + getIssuesForReposFn: providerApis.gitlab.getIssuesForRepos.bind( + providerApis.gitlab, + ) as GetIssuesForReposFn, + getIssuesForRepoFn: providerApis.gitlab.getIssuesForRepo.bind( + providerApis.gitlab, + ) as GetIssuesForRepoFn, + }, + [HostingIntegrationId.Bitbucket]: { + ...providersMetadata[HostingIntegrationId.Bitbucket], + provider: providerApis.bitbucket, + getCurrentUserFn: providerApis.bitbucket.getCurrentUser.bind( + providerApis.bitbucket, + ) as GetCurrentUserFn, + getPullRequestsForReposFn: providerApis.bitbucket.getPullRequestsForRepos.bind( + providerApis.bitbucket, + ) as GetPullRequestsForReposFn, + getPullRequestsForUserFn: providerApis.bitbucket.getPullRequestsForUser.bind( + providerApis.bitbucket, + ) as GetPullRequestsForUserFn, + getPullRequestsForRepoFn: providerApis.bitbucket.getPullRequestsForRepo.bind( + providerApis.bitbucket, + ) as GetPullRequestsForRepoFn, + }, + [HostingIntegrationId.AzureDevOps]: { + ...providersMetadata[HostingIntegrationId.AzureDevOps], + provider: providerApis.azureDevOps, + getCurrentUserFn: providerApis.azureDevOps.getCurrentUser.bind( + providerApis.azureDevOps, + ) as GetCurrentUserFn, + getCurrentUserForInstanceFn: providerApis.azureDevOps.getCurrentUserForInstance.bind( + providerApis.azureDevOps, + ) as GetCurrentUserForInstanceFn, + getAzureResourcesForUserFn: providerApis.azureDevOps.getOrgsForUser.bind( + providerApis.azureDevOps, + ) as GetAzureResourcesForUserFn, + getAzureProjectsForResourceFn: providerApis.azureDevOps.getAzureProjects.bind( + providerApis.azureDevOps, + ) as GetAzureProjectsForResourceFn, + getPullRequestsForReposFn: providerApis.azureDevOps.getPullRequestsForRepos.bind( + providerApis.azureDevOps, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.azureDevOps.getPullRequestsForRepo.bind( + providerApis.azureDevOps, + ) as GetPullRequestsForRepoFn, + getPullRequestsForAzureProjectsFn: providerApis.azureDevOps.getPullRequestsForProjects.bind( + providerApis.azureDevOps, + ) as GetPullRequestsForAzureProjectsFn, + getIssuesForAzureProjectFn: providerApis.azureDevOps.getIssuesForAzureProject.bind( + providerApis.azureDevOps, + ) as GetIssuesForAzureProjectFn, + getReposForAzureProjectFn: providerApis.azureDevOps.getReposForAzureProject.bind( + providerApis.azureDevOps, + ) as GetReposForAzureProjectFn, + }, + [IssueIntegrationId.Jira]: { + ...providersMetadata[IssueIntegrationId.Jira], + provider: providerApis.jira, + getCurrentUserForResourceFn: providerApis.jira.getCurrentUserForResource.bind(providerApis.jira), + getJiraResourcesForCurrentUserFn: providerApis.jira.getJiraResourcesForCurrentUser.bind( + providerApis.jira, + ), + getJiraProjectsForResourcesFn: providerApis.jira.getJiraProjectsForResources.bind(providerApis.jira), + getIssueFn: providerApis.jira.getIssue.bind(providerApis.jira), + getIssuesForProjectFn: providerApis.jira.getIssuesForProject.bind(providerApis.jira), + getIssuesForResourceForCurrentUserFn: providerApis.jira.getIssuesForResourceForCurrentUser.bind( + providerApis.jira, + ), + }, + [IssueIntegrationId.Trello]: { + ...providersMetadata[IssueIntegrationId.Trello], + provider: providerApis.trello, + }, + }; + } + + getScopesForProvider(providerId: IntegrationId): string[] | undefined { + return this.providers[providerId]?.scopes; + } + + getProviderDomain(providerId: IntegrationId): string | undefined { + return this.providers[providerId]?.domain; + } + + getProviderPullRequestsPagingMode(providerId: IntegrationId): PagingMode | undefined { + return this.providers[providerId]?.pullRequestsPagingMode; + } + + getProviderIssuesPagingMode(providerId: IntegrationId): PagingMode | undefined { + return this.providers[providerId]?.issuesPagingMode; + } + + providerSupportsPullRequestFilters(providerId: IntegrationId, filters: PullRequestFilter[]): boolean { + return ( + this.providers[providerId]?.supportedPullRequestFilters != null && + filters.every(filter => this.providers[providerId]?.supportedPullRequestFilters?.includes(filter)) + ); + } + + providerSupportsIssueFilters(providerId: IntegrationId, filters: IssueFilter[]): boolean { + return ( + this.providers[providerId]?.supportedIssueFilters != null && + filters.every(filter => this.providers[providerId]?.supportedIssueFilters?.includes(filter)) + ); + } + + isRepoIdsInput(input: any): input is (string | number)[] { + return ( + input != null && + Array.isArray(input) && + input.every((id: any) => typeof id === 'string' || typeof id === 'number') + ); + } + + private async getProviderToken( + provider: ProviderInfo, + options?: { createSessionIfNeeded?: boolean }, + ): Promise { + const providerDescriptor = + provider.domain == null || provider.scopes == null + ? undefined + : { domain: provider.domain, scopes: provider.scopes }; + try { + const authProvider = await this.authenticationService.get(provider.id); + return ( + await authProvider.getSession(providerDescriptor, { + createIfNeeded: options?.createSessionIfNeeded, + }) + )?.accessToken; + } catch { + return undefined; + } + } + + private getAzurePATForOAuthToken(oauthToken: string) { + return base64(`PAT:${oauthToken}`); + } + + private async ensureProviderTokenAndFunction( + providerId: IntegrationId, + providerFn: keyof ProviderInfo, + accessToken?: string, + ): Promise<{ provider: ProviderInfo; token: string }> { + const provider = this.providers[providerId]; + if (provider == null) { + throw new Error(`Provider with id ${providerId} not registered`); + } + + const token = accessToken ?? (await this.getProviderToken(provider)); + if (token == null) { + throw new Error(`Not connected to provider ${providerId}`); + } + + if (provider[providerFn] == null) { + throw new Error(`Provider with id ${providerId} does not support function: ${providerFn}`); + } + + return { provider: provider, token: token }; + } + + private handleProviderError(providerId: IntegrationId, token: string, error: any): T { + const provider = this.providers[providerId]; + if (provider == null) { + throw new Error(`Provider with id ${providerId} not registered`); + } + + switch (providerId) { + case IssueIntegrationId.Jira: { + if (error?.response?.status != null) { + if (error.response.status === 401) { + throw new AuthenticationError(providerId, AuthenticationErrorReason.Forbidden, error); + } else if (error.response.status === 429) { + let resetAt: number | undefined; + + const reset = error.response.headers?.['x-ratelimit-reset']; + if (reset != null) { + resetAt = parseInt(reset, 10); + if (Number.isNaN(resetAt)) { + resetAt = undefined; + } + } + + throw new RequestRateLimitError(error, token, resetAt); + } else if (error.response.status >= 400 && error.response.status < 500) { + throw new RequestClientError(error); + } + } + throw error; + } + default: { + throw error; + } + } + } + + async getPagedResult( + _provider: ProviderInfo, + args: any, + providerFn: + | (( + input: any, + options?: { token?: string; isPAT?: boolean }, + ) => Promise<{ data: NonNullable[]; pageInfo?: PageInfo }>) + | undefined, + token: string, + cursor: string = '{}', + usePAT: boolean = false, + ): Promise> { + let cursorInfo; + try { + cursorInfo = JSON.parse(cursor); + } catch { + cursorInfo = {}; + } + const cursorValue = cursorInfo.value; + const cursorType = cursorInfo.type; + let cursorOrPage = {}; + if (cursorType === 'page') { + cursorOrPage = { page: cursorValue }; + } else if (cursorType === 'cursor') { + cursorOrPage = { cursor: cursorValue }; + } + + const input = { + ...args, + ...cursorOrPage, + }; + + const result = await providerFn?.(input, { token: token, isPAT: usePAT }); + if (result == null) { + return { values: [] }; + } + + const hasMore = result.pageInfo?.hasNextPage ?? false; + + let nextCursor = '{}'; + if (result.pageInfo?.endCursor != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); + } else if (result.pageInfo?.nextPage != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); + } + + return { + values: result.data, + paging: { + cursor: nextCursor, + more: hasMore, + }, + }; + } + + async getCurrentUser( + providerId: IntegrationId, + options?: { accessToken?: string; isPAT?: boolean }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getCurrentUserFn', + options?.accessToken, + ); + + try { + return (await provider.getCurrentUserFn?.({}, { token: token, isPAT: options?.isPAT }))?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } + + async getCurrentUserForInstance( + providerId: IntegrationId, + namespace: string, + options?: { accessToken?: string; isPAT?: boolean }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getCurrentUserForInstanceFn', + options?.accessToken, + ); + + return ( + await provider.getCurrentUserForInstanceFn?.( + { namespace: namespace }, + { token: token, isPAT: options?.isPAT }, + ) + )?.data; + } + + async getCurrentUserForResource( + providerId: IntegrationId, + resourceId: string, + options?: { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getCurrentUserForResourceFn', + options?.accessToken, + ); + + try { + return (await provider.getCurrentUserForResourceFn?.({ resourceId: resourceId }, { token: token }))?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } + + async getJiraResourcesForCurrentUser(options?: { + accessToken?: string; + }): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + IssueIntegrationId.Jira, + 'getJiraResourcesForCurrentUserFn', + options?.accessToken, + ); + + try { + return (await provider.getJiraResourcesForCurrentUserFn?.({ token: token }))?.data; + } catch (e) { + return this.handleProviderError(IssueIntegrationId.Jira, token, e); + } + } + + async getAzureResourcesForUser( + userId: string, + options?: { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + HostingIntegrationId.AzureDevOps, + 'getAzureResourcesForUserFn', + options?.accessToken, + ); + + try { + return (await provider.getAzureResourcesForUserFn?.({ userId: userId }, { token: token }))?.data; + } catch (e) { + return this.handleProviderError( + HostingIntegrationId.AzureDevOps, + token, + e, + ); + } + } + + async getJiraProjectsForResources( + resourceIds: string[], + options?: { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + IssueIntegrationId.Jira, + 'getJiraProjectsForResourcesFn', + options?.accessToken, + ); + + try { + return (await provider.getJiraProjectsForResourcesFn?.({ resourceIds: resourceIds }, { token: token })) + ?.data; + } catch (e) { + return this.handleProviderError(IssueIntegrationId.Jira, token, e); + } + } + + async getAzureProjectsForResource( + namespace: string, + options?: { accessToken?: string; cursor?: string; isPAT?: boolean }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + HostingIntegrationId.AzureDevOps, + 'getAzureProjectsForResourceFn', + options?.accessToken, + ); + + // Azure only supports PAT for this call + const azureToken = options?.isPAT ? token : this.getAzurePATForOAuthToken(token); + + try { + return await this.getPagedResult( + provider, + { namespace: namespace, ...options }, + provider.getAzureProjectsForResourceFn, + azureToken, + options?.cursor, + true, + ); + } catch (e) { + return this.handleProviderError>( + HostingIntegrationId.AzureDevOps, + token, + e, + ); + } + } + + async getReposForAzureProject( + namespace: string, + project: string, + options?: GetReposOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + HostingIntegrationId.AzureDevOps, + 'getReposForAzureProjectFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { namespace: namespace, project: project, ...options }, + provider.getReposForAzureProjectFn, + token, + options?.cursor, + ); + } + + async getPullRequestsForRepos( + providerId: IntegrationId, + reposOrIds: ProviderReposInput, + options?: GetPullRequestsOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getPullRequestsForReposFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { + ...(this.isRepoIdsInput(reposOrIds) ? { repoIds: reposOrIds } : { repos: reposOrIds }), + ...options, + }, + provider.getPullRequestsForReposFn, + token, + options?.cursor, + ); + } + + async getPullRequestsForRepo( + providerId: IntegrationId, + repo: ProviderRepoInput, + options?: GetPullRequestsOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getPullRequestsForRepoFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { repo: repo, ...options }, + provider.getPullRequestsForRepoFn, + token, + options?.cursor, + ); + } + + async getPullRequestsForUser( + providerId: HostingIntegrationId.Bitbucket, + userId: string, + options?: { accessToken?: string } & GetPullRequestsForUserOptions, + ): Promise>; + async getPullRequestsForUser( + providerId: Exclude, + username: string, + options?: { accessToken?: string } & GetPullRequestsForUserOptions, + ): Promise>; + async getPullRequestsForUser( + providerId: IntegrationId, + usernameOrId: string, + options?: { accessToken?: string } & GetPullRequestsForUserOptions, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getPullRequestsForUserFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { + ...(providerId === HostingIntegrationId.Bitbucket + ? { userId: usernameOrId } + : { username: usernameOrId }), + ...options, + }, + provider.getPullRequestsForUserFn, + token, + options?.cursor, + ); + } + + async getPullRequestsForAzureProjects( + projects: { namespace: string; project: string }[], + options?: { accessToken?: string; authorLogin?: string; assigneeLogins?: string[]; isPAT?: boolean }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + HostingIntegrationId.AzureDevOps, + 'getPullRequestsForAzureProjectsFn', + options?.accessToken, + ); + + // Azure only supports PAT for this call + const azureToken = options?.isPAT ? token : this.getAzurePATForOAuthToken(token); + + try { + return ( + await provider.getPullRequestsForAzureProjectsFn?.( + { projects: projects, ...options }, + { token: azureToken, isPAT: true }, + ) + )?.data; + } catch (e) { + return this.handleProviderError(HostingIntegrationId.AzureDevOps, token, e); + } + } + + async mergePullRequest( + providerId: IntegrationId, + pr: PullRequest, + options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction(providerId, 'mergePullRequestFn'); + const headRef = pr.refs?.head; + if (headRef == null) return false; + + try { + await provider.mergePullRequestFn?.( + { + pullRequest: { + headRef: { oid: headRef.sha }, + id: pr.id, + number: Number.parseInt(pr.id, 10), + repository: { + id: pr.repository.repo, + name: pr.repository.repo, + owner: { + login: pr.repository.owner, + }, + }, + }, + ...options, + }, + { token: token }, + ); + return true; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } + + async getIssuesForRepos( + providerId: IntegrationId, + reposOrIds: ProviderReposInput, + options?: GetIssuesOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getIssuesForReposFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { + ...(this.isRepoIdsInput(reposOrIds) ? { repoIds: reposOrIds } : { repos: reposOrIds }), + ...options, + }, + provider.getIssuesForReposFn, + token, + options?.cursor, + ); + } + + async getIssuesForRepo( + providerId: IntegrationId, + repo: ProviderRepoInput, + options?: GetIssuesOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getIssuesForRepoFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { repo: repo, ...options }, + provider.getIssuesForRepoFn, + token, + options?.cursor, + ); + } + + async getIssuesForAzureProject( + namespace: string, + project: string, + options?: GetIssuesOptions & { accessToken?: string }, + ): Promise> { + const { provider, token } = await this.ensureProviderTokenAndFunction( + HostingIntegrationId.AzureDevOps, + 'getIssuesForAzureProjectFn', + options?.accessToken, + ); + + return this.getPagedResult( + provider, + { namespace: namespace, project: project, ...options }, + provider.getIssuesForAzureProjectFn, + token, + options?.cursor, + ); + } + + async getIssuesForProject( + providerId: IntegrationId, + project: string, + resourceId: string, + options?: GetIssuesOptions & { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getIssuesForProjectFn', + options?.accessToken, + ); + + try { + const result = await provider.getIssuesForProjectFn?.( + { projectKey: project, resourceId: resourceId, ...options }, + { token: token }, + ); + + return result?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } + + async getIssuesForResourceForCurrentUser( + providerId: IntegrationId, + resourceId: string, + options?: { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getIssuesForResourceForCurrentUserFn', + options?.accessToken, + ); + + try { + const result = await provider.getIssuesForResourceForCurrentUserFn?.( + { resourceId: resourceId }, + { token: token }, + ); + + return result?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } + + async getIssue( + providerId: IntegrationId, + resourceId: string, + issueId: string, + options?: { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getIssueFn', + options?.accessToken, + ); + + try { + const result = await provider.getIssueFn?.({ resourceId: resourceId, number: issueId }, { token: token }); + + return result?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } +} + +// This is copied over from the shared provider library because the current version is not respecting the "forceIsFetch: true" +// option in the config and our custom fetch function isn't being wrapped by the necessary fetch wrapper. Remove this once the library +// properly wraps our custom fetch and use `forceIsFetch: true` in the config. +async function parseFetchResponseForApi(response: FetchResponse): Promise> { + const contentType = response.headers.get('content-type') || ''; + let body = null; + + // parse the response body + if (contentType.startsWith('application/json')) { + const text = await response.text(); + body = text.trim().length > 0 ? JSON.parse(text) : null; + } else if (contentType.startsWith('text/') || contentType === '') { + body = await response.text(); + } else if (contentType.startsWith('application/vnd.github.raw+json')) { + body = await response.arrayBuffer(); + } else { + throw new Error(`Unsupported content-type: ${contentType}`); + } + + const result = { + body: body, + headers: Object.fromEntries(response.headers.entries()), + status: response.status, + statusText: response.statusText, + }; + + // throw an error if the response is not ok + if (!response.ok) { + const error = new Error(response.statusText); + Object.assign(error, { response: result }); + throw error; + } + + return result; +} diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts new file mode 100644 index 0000000000000..7d208a3eaa375 --- /dev/null +++ b/src/plus/integrations/providers/utils.ts @@ -0,0 +1,67 @@ +import type { AnyEntityIdentifierInput, EntityIdentifier } from '@gitkraken/provider-apis'; +import { EntityIdentifierProviderType, EntityType, EntityVersion } from '@gitkraken/provider-apis'; +import type { IssueOrPullRequest } from '../../../git/models/issue'; +import { equalsIgnoreCase } from '../../../system/string'; +import type { LaunchpadItem } from '../../launchpad/launchpadProvider'; +import type { IntegrationId } from './models'; +import { HostingIntegrationId, SelfHostedIntegrationId } from './models'; + +function isGitHubDotCom(domain: string): boolean { + return equalsIgnoreCase(domain, 'github.com'); +} + +function isGitLabDotCom(domain: string): boolean { + return equalsIgnoreCase(domain, 'gitlab.com'); +} + +function isLaunchpadItem(item: IssueOrPullRequest | LaunchpadItem): item is LaunchpadItem { + return (item as LaunchpadItem).uuid !== undefined; +} + +export function getEntityIdentifierInput(entity: IssueOrPullRequest | LaunchpadItem): AnyEntityIdentifierInput { + let entityType = EntityType.Issue; + if (entity.type === 'pullrequest') { + entityType = EntityType.PullRequest; + } + + let provider = fromStringToEntityIdentifierProviderType(entity.provider.id); + let domain = undefined; + if (provider === EntityIdentifierProviderType.Github && !isGitHubDotCom(entity.provider.domain)) { + provider = EntityIdentifierProviderType.GithubEnterprise; + domain = entity.provider.domain; + } + if (provider === EntityIdentifierProviderType.Gitlab && !isGitLabDotCom(entity.provider.domain)) { + provider = EntityIdentifierProviderType.GitlabSelfHosted; + domain = entity.provider.domain; + } + + return { + provider: provider, + entityType: entityType, + version: EntityVersion.One, + domain: domain, + entityId: isLaunchpadItem(entity) ? entity.graphQLId! : entity.nodeId!, + }; +} + +export function getProviderIdFromEntityIdentifier(entityIdentifier: EntityIdentifier): IntegrationId | undefined { + switch (entityIdentifier.provider) { + case EntityIdentifierProviderType.Github: + return HostingIntegrationId.GitHub; + case EntityIdentifierProviderType.GithubEnterprise: + return SelfHostedIntegrationId.GitHubEnterprise; + default: + return undefined; + } +} + +function fromStringToEntityIdentifierProviderType(str: string): EntityIdentifierProviderType { + switch (str) { + case 'github': + return EntityIdentifierProviderType.Github; + case 'gitlab': + return EntityIdentifierProviderType.Gitlab; + default: + throw new Error(`Unknown provider type '${str}'`); + } +} diff --git a/src/plus/launchpad/enrichmentService.ts b/src/plus/launchpad/enrichmentService.ts new file mode 100644 index 0000000000000..04c0e992a6e31 --- /dev/null +++ b/src/plus/launchpad/enrichmentService.ts @@ -0,0 +1,236 @@ +import type { CancellationToken, Disposable } from 'vscode'; +import type { Container } from '../../container'; +import { AuthenticationRequiredError, CancellationError } from '../../errors'; +import type { RemoteProvider } from '../../git/remotes/remoteProvider'; +import { log } from '../../system/decorators/log'; +import { Logger } from '../../system/logger'; +import { getLogScope } from '../../system/logger.scope'; +import type { ServerConnection } from '../gk/serverConnection'; +import { ensureAccount } from '../utils'; + +export interface EnrichableItem { + type: EnrichedItemResponse['entityType']; + id: string; + provider: EnrichedItemResponse['provider']; + url: string; + expiresAt?: string; +} + +export type EnrichedItem = { + id: string; + userId?: string; + type: EnrichedItemResponse['type']; + + provider: EnrichedItemResponse['provider']; + entityType: EnrichedItemResponse['entityType']; + entityId: string; + entityUrl: string; + + createdAt: string; + updatedAt: string; + expiresAt?: string; +}; + +type EnrichedItemRequest = { + provider: EnrichedItemResponse['provider']; + entityType: EnrichedItemResponse['entityType']; + entityId: string; + entityUrl: string; + expiresAt?: string; +}; + +type EnrichedItemResponse = { + id: string; + userId?: string; + type: 'pin' | 'snooze'; + + provider: 'azure' | 'bitbucket' | 'github' | 'gitlab' | 'jira' | 'trello' | 'gitkraken'; + entityType: 'issue' | 'pr'; + entityId: string; + entityUrl: string; + + createdAt: string; + updatedAt: string; + expiresAt?: string; +}; + +export class EnrichmentService implements Disposable { + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + dispose(): void {} + + private async delete(id: string, context: 'unpin' | 'unsnooze'): Promise { + const scope = getLogScope(); + + try { + const rsp = await this.connection.fetchGkDevApi(`v1/enrich-items/${id}`, { method: 'DELETE' }); + + if (!rsp.ok) throw new Error(`Unable to ${context} item '${id}': (${rsp.status}) ${rsp.statusText}`); + } catch (ex) { + Logger.error(ex, scope); + debugger; + throw ex; + } + } + + @log() + async get(type?: EnrichedItemResponse['type'], cancellation?: CancellationToken): Promise { + const scope = getLogScope(); + + try { + type Result = { data: EnrichedItemResponse[] }; + + const rsp = await this.connection.fetchGkDevApi('v1/enrich-items', { method: 'GET' }); + if (cancellation?.isCancellationRequested) throw new CancellationError(); + + const result = (await rsp.json()) as Result; + if (cancellation?.isCancellationRequested) throw new CancellationError(); + + return type == null ? result.data : result.data.filter(i => i.type === type); + } catch (ex) { + if (ex instanceof AuthenticationRequiredError) return []; + + Logger.error(ex, scope); + debugger; + throw ex; + } + } + + @log() + getPins(cancellation?: CancellationToken): Promise { + return this.get('pin', cancellation); + } + + @log() + getSnoozed(cancellation?: CancellationToken): Promise { + return this.get('snooze', cancellation); + } + + @log({ args: { 0: i => `${i.id} (${i.provider} ${i.type})` } }) + async pinItem(item: EnrichableItem): Promise { + const scope = getLogScope(); + + try { + if ( + !(await ensureAccount(this.container, 'Pinning is a Preview feature and requires an account.', { + source: 'launchpad', + detail: 'pin', + })) + ) { + throw new Error('Unable to pin item: account required'); + } + + type Result = { data: EnrichedItemResponse }; + + const rq: EnrichedItemRequest = { + provider: item.provider, + entityType: item.type, + entityId: item.id, + entityUrl: item.url, + }; + + const rsp = await this.connection.fetchGkDevApi('v1/enrich-items/pin', { + method: 'POST', + body: JSON.stringify(rq), + }); + + if (!rsp.ok) { + throw new Error( + `Unable to pin item '${rq.provider}|${rq.entityUrl}#${item.id}': (${rsp.status}) ${rsp.statusText}`, + ); + } + + const result = (await rsp.json()) as Result; + return result.data; + } catch (ex) { + Logger.error(ex, scope); + debugger; + throw ex; + } + } + + @log() + unpinItem(id: string): Promise { + return this.delete(id, 'unpin'); + } + + @log({ args: { 0: i => `${i.id} (${i.provider} ${i.type})` } }) + async snoozeItem(item: EnrichableItem): Promise { + const scope = getLogScope(); + + try { + if ( + !(await ensureAccount(this.container, 'Snoozing is a Preview feature and requires an acccount.', { + source: 'launchpad', + detail: 'snooze', + })) + ) { + throw new Error('Unable to snooze item: subscription required'); + } + + type Result = { data: EnrichedItemResponse }; + + const rq: EnrichedItemRequest = { + provider: item.provider, + entityType: item.type, + entityId: item.id, + entityUrl: item.url, + }; + if (item.expiresAt != null) { + rq.expiresAt = item.expiresAt; + } + + const rsp = await this.connection.fetchGkDevApi('v1/enrich-items/snooze', { + method: 'POST', + body: JSON.stringify(rq), + }); + + if (!rsp.ok) { + throw new Error( + `Unable to snooze item '${rq.provider}|${rq.entityUrl}#${item.id}': (${rsp.status}) ${rsp.statusText}`, + ); + } + + const result = (await rsp.json()) as Result; + return result.data; + } catch (ex) { + Logger.error(ex, scope); + debugger; + throw ex; + } + } + + @log() + unsnoozeItem(id: string): Promise { + return this.delete(id, 'unsnooze'); + } +} + +const supportedRemoteProvidersToEnrich: Record = { + 'azure-devops': 'azure', + bitbucket: 'bitbucket', + 'bitbucket-server': 'bitbucket', + custom: undefined, + gerrit: undefined, + gitea: undefined, + github: 'github', + gitlab: 'gitlab', + 'google-source': undefined, +}; + +export function convertRemoteProviderToEnrichProvider(provider: RemoteProvider): EnrichedItemResponse['provider'] { + return convertRemoteProviderIdToEnrichProvider(provider.id); +} + +export function convertRemoteProviderIdToEnrichProvider(id: RemoteProvider['id']): EnrichedItemResponse['provider'] { + const enrichProvider = supportedRemoteProvidersToEnrich[id]; + if (enrichProvider == null) throw new Error(`Unknown remote provider '${id}'`); + return enrichProvider; +} + +export function isEnrichableRemoteProviderId(id: string): id is RemoteProvider['id'] { + return supportedRemoteProvidersToEnrich[id as RemoteProvider['id']] != null; +} diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts new file mode 100644 index 0000000000000..f5bad45857cb3 --- /dev/null +++ b/src/plus/launchpad/launchpad.ts @@ -0,0 +1,1332 @@ +import type { QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; +import { commands, ThemeIcon, Uri } from 'vscode'; +import { getAvatarUri } from '../../avatars'; +import type { + AsyncStepResultGenerator, + PartialStepState, + StepGenerator, + StepResultGenerator, + StepSelection, + StepState, +} from '../../commands/quickCommand'; +import { + canPickStepContinue, + createPickStep, + endSteps, + freezeStep, + QuickCommand, + StepResultBreak, +} from '../../commands/quickCommand'; +import { + ConnectIntegrationButton, + FeedbackQuickInputButton, + LaunchpadSettingsQuickInputButton, + LearnAboutProQuickInputButton, + MergeQuickInputButton, + OpenOnGitHubQuickInputButton, + OpenOnGitLabQuickInputButton, + OpenOnWebQuickInputButton, + OpenWorktreeInNewWindowQuickInputButton, + PinQuickInputButton, + RefreshQuickInputButton, + SnoozeQuickInputButton, + UnpinQuickInputButton, + UnsnoozeQuickInputButton, +} from '../../commands/quickCommand.buttons'; +import { ensureAccessStep } from '../../commands/quickCommand.steps'; +import type { OpenWalkthroughCommandArgs } from '../../commands/walkthroughs'; +import { proBadge, urls } from '../../constants'; +import { Commands } from '../../constants.commands'; +import type { LaunchpadTelemetryContext, Source, Sources, TelemetryEvents } from '../../constants.telemetry'; +import type { Container } from '../../container'; +import { PlusFeatures } from '../../features'; +import type { QuickPickItemOfT } from '../../quickpicks/items/common'; +import { createQuickPickItemOfT, createQuickPickSeparator } from '../../quickpicks/items/common'; +import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive'; +import { createDirectiveQuickPickItem, Directive, isDirectiveQuickPickItem } from '../../quickpicks/items/directive'; +import { getScopedCounter } from '../../system/counter'; +import { fromNow } from '../../system/date'; +import { some } from '../../system/iterable'; +import { interpolate, pluralize } from '../../system/string'; +import { executeCommand } from '../../system/vscode/command'; +import { configuration } from '../../system/vscode/configuration'; +import { openUrl } from '../../system/vscode/utils'; +import { getApplicablePromo } from '../gk/account/promos'; +import type { IntegrationId } from '../integrations/providers/models'; +import { + HostingIntegrationId, + ProviderBuildStatusState, + ProviderPullRequestReviewState, + SelfHostedIntegrationId, +} from '../integrations/providers/models'; +import type { + LaunchpadAction, + LaunchpadActionCategory, + LaunchpadCategorizedResult, + LaunchpadGroup, + LaunchpadItem, + LaunchpadTargetAction, +} from './launchpadProvider'; +import { + countLaunchpadItemGroups, + getLaunchpadItemIdHash, + groupAndSortLaunchpadItems, + launchpadGroupIconMap, + launchpadGroupLabelMap, + launchpadGroups, + supportedLaunchpadIntegrations, +} from './launchpadProvider'; + +const actionGroupMap = new Map([ + ['mergeable', ['Ready to Merge', 'Ready to merge']], + ['unassigned-reviewers', ['Unassigned Reviewers', 'You need to assign reviewers']], + ['failed-checks', ['Failed Checks', 'You need to resolve the failing checks']], + ['conflicts', ['Resolve Conflicts', 'You need to resolve merge conflicts']], + ['needs-my-review', ['Needs Your Review', `\${author} requested your review`]], + ['code-suggestions', ['Code Suggestions', 'Code suggestions have been made on this pull request']], + ['changes-requested', ['Changes Requested', 'Reviewers requested changes before this can be merged']], + ['reviewer-commented', ['Reviewers Commented', 'Reviewers have commented on this pull request']], + ['waiting-for-review', ['Waiting for Review', 'Waiting for reviewers to approve this pull request']], + ['draft', ['Draft', 'Continue working on your draft']], + ['other', ['Other', `Opened by \${author} \${createdDateRelative}`]], +]); + +export interface LaunchpadItemQuickPickItem extends QuickPickItemOfT { + group: LaunchpadGroup; +} + +type ConnectMoreIntegrationsItem = QuickPickItem & { + item: undefined; + group: undefined; +}; +const connectMoreIntegrationsItem: ConnectMoreIntegrationsItem = { + label: 'Connect more integrations', + detail: 'Connect integration with more Git providers', + item: undefined, + group: undefined, +}; +function isConnectMoreIntegrationsItem(item: unknown): item is ConnectMoreIntegrationsItem { + return item === connectMoreIntegrationsItem; +} + +interface Context { + result: LaunchpadCategorizedResult; + + title: string; + collapsed: Map; + telemetryContext: LaunchpadTelemetryContext | undefined; + connectedIntegrations: Map; + showGraduationPromo: boolean; +} + +interface GroupedLaunchpadItem extends LaunchpadItem { + group: LaunchpadGroup; +} + +interface State { + item?: GroupedLaunchpadItem; + action?: LaunchpadAction | LaunchpadTargetAction; + initialGroup?: LaunchpadGroup; + selectTopItem?: boolean; +} + +export interface LaunchpadCommandArgs { + readonly command: 'launchpad'; + confirm?: boolean; + source?: Sources; + state?: Partial; +} + +type LaunchpadStepState = RequireSome, 'item'>; + +function assertsLaunchpadStepState(state: StepState): asserts state is LaunchpadStepState { + if (state.item != null) return; + + debugger; + throw new Error('Missing item'); +} + +const instanceCounter = getScopedCounter(); + +const defaultCollapsedGroups: LaunchpadGroup[] = ['draft', 'other', 'snoozed']; + +export class LaunchpadCommand extends QuickCommand { + private readonly source: Source; + private readonly telemetryContext: LaunchpadTelemetryContext | undefined; + + constructor(container: Container, args?: LaunchpadCommandArgs) { + super(container, 'launchpad', 'launchpad', `GitLens Launchpad\u00a0\u00a0${proBadge}`, { + description: 'focus on a pull request', + }); + + if ( + args?.source === 'launchpad-indicator' && + container.storage.get('launchpad:indicator:hasInteracted') == null + ) { + void container.storage.store('launchpad:indicator:hasInteracted', new Date().toISOString()); + } + + this.source = { source: args?.source ?? 'commandPalette' }; + if (this.container.telemetry.enabled) { + this.telemetryContext = { + instance: instanceCounter.next(), + 'initialState.group': args?.state?.initialGroup, + 'initialState.selectTopItem': args?.state?.selectTopItem ?? false, + }; + + this.container.telemetry.sendEvent('launchpad/open', { ...this.telemetryContext }, this.source); + } + + let counter = 0; + if (args?.state?.item != null) { + counter++; + } + + this.initialState = { + counter: counter, + confirm: args?.confirm, + ...args?.state, + }; + } + + private async ensureIntegrationConnected(id: IntegrationId) { + const integration = await this.container.integrations.get(id); + let connected = integration.maybeConnected ?? (await integration.isConnected()); + if (!connected) { + connected = await integration.connect('launchpad'); + } + + return connected; + } + + protected async *steps(state: PartialStepState): StepGenerator { + if (this.container.git.isDiscoveringRepositories) { + await this.container.git.isDiscoveringRepositories; + } + + let storedCollapsed = this.container.storage.get('launchpad:groups:collapsed') satisfies + | LaunchpadGroup[] + | undefined; + if (storedCollapsed == null) { + storedCollapsed = defaultCollapsedGroups; + } + + const collapsed = new Map(storedCollapsed.map(g => [g, true])); + if (state.initialGroup != null) { + // set all to true except the initial group + for (const group of launchpadGroups) { + collapsed.set(group, group !== state.initialGroup); + } + } + + const context: Context = { + result: { items: [] }, + title: this.title, + collapsed: collapsed, + telemetryContext: this.telemetryContext, + connectedIntegrations: await this.container.launchpad.getConnectedIntegrations(), + showGraduationPromo: false, + }; + + let opened = false; + + while (this.canStepsContinue(state)) { + context.title = this.title; + + let newlyConnected = false; + const hasConnectedIntegrations = [...context.connectedIntegrations.values()].some(c => c); + if (!hasConnectedIntegrations) { + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + opened ? 'launchpad/steps/connect' : 'launchpad/opened', + { + ...context.telemetryContext!, + connected: false, + }, + this.source, + ); + } + + opened = true; + + const isUsingCloudIntegrations = configuration.get('cloudIntegrations.enabled', undefined, false); + const result = isUsingCloudIntegrations + ? yield* this.confirmCloudIntegrationsConnectStep(state, context) + : yield* this.confirmLocalIntegrationConnectStep(state, context); + if (result === StepResultBreak) { + return result; + } + + result.resume(); + + const connected = result.connected; + if (!connected) { + continue; + } + + newlyConnected = Boolean(connected); + } + + const result = yield* ensureAccessStep(state, context, PlusFeatures.Launchpad); + if (result === StepResultBreak) continue; + + context.showGraduationPromo = getApplicablePromo(result.subscription.current.state, 'launchpad') != null; + + await updateContextItems(this.container, context, { force: newlyConnected }); + + if (state.counter < 1 || state.item == null) { + if (this.container.telemetry.enabled) { + this.container.telemetry.sendEvent( + opened ? 'launchpad/steps/main' : 'launchpad/opened', + { + ...context.telemetryContext!, + connected: true, + }, + this.source, + ); + } + + opened = true; + + const result = yield* this.pickLaunchpadItemStep(state, context, { + picked: state.item?.graphQLId, + selectTopItem: state.selectTopItem, + }); + if (result === StepResultBreak) continue; + + if (isConnectMoreIntegrationsItem(result)) { + const isUsingCloudIntegrations = configuration.get('cloudIntegrations.enabled', undefined, false); + const result = isUsingCloudIntegrations + ? yield* this.confirmCloudIntegrationsConnectStep(state, context) + : yield* this.confirmLocalIntegrationConnectStep(state, context); + if (result === StepResultBreak) continue; + + result.resume(); + + const connected = result.connected; + newlyConnected = Boolean(connected); + await updateContextItems(this.container, context, { force: newlyConnected }); + continue; + } + + state.item = result; + } + + assertsLaunchpadStepState(state); + + if (this.confirm(state.confirm)) { + this.sendItemActionTelemetry('select', state.item, state.item.group, context); + await this.container.launchpad.ensureLaunchpadItemCodeSuggestions(state.item); + + const result = yield* this.confirmStep(state, context); + if (result === StepResultBreak) continue; + + state.action = result; + } + + if (state.action) { + this.sendItemActionTelemetry(state.action, state.item, state.item.group, context); + } + + if (typeof state.action === 'string') { + switch (state.action) { + case 'merge': + void this.container.launchpad.merge(state.item); + break; + case 'open': + this.container.launchpad.open(state.item); + break; + case 'soft-open': + this.container.launchpad.open(state.item); + state.counter = 2; + continue; + case 'switch': + case 'show-overview': + void this.container.launchpad.switchTo(state.item); + break; + case 'open-worktree': + void this.container.launchpad.switchTo(state.item, { skipWorktreeConfirmations: true }); + break; + case 'switch-and-code-suggest': + case 'code-suggest': + void this.container.launchpad.switchTo(state.item, { startCodeSuggestion: true }); + break; + case 'open-changes': + void this.container.launchpad.openChanges(state.item); + break; + case 'open-in-graph': + void this.container.launchpad.openInGraph(state.item); + break; + } + } else { + switch (state.action?.action) { + case 'open-suggestion': { + this.container.launchpad.openCodeSuggestion(state.item, state.action.target); + break; + } + } + } + + endSteps(state); + } + + return state.counter < 0 ? StepResultBreak : undefined; + } + + private *pickLaunchpadItemStep( + state: StepState, + context: Context, + { picked, selectTopItem }: { picked?: string; selectTopItem?: boolean }, + ): StepResultGenerator { + const hasDisconnectedIntegrations = [...context.connectedIntegrations.values()].some(c => !c); + const getItems = (result: LaunchpadCategorizedResult) => { + const items: (LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem)[] = []; + if (context.showGraduationPromo) { + items.push( + createDirectiveQuickPickItem(Directive.RequiresPaidSubscription, undefined, { + label: `Preview access of Launchpad will end on September 27th`, + detail: '$(blank) Upgrade before then to save 75% or more on GitLens Pro', + iconPath: new ThemeIcon('megaphone'), + buttons: [LearnAboutProQuickInputButton], + }), + ); + } + + if (result.items?.length) { + const uiGroups = groupAndSortLaunchpadItems(result.items); + const topItem: LaunchpadItem | undefined = + !selectTopItem || picked != null + ? undefined + : uiGroups.get('mergeable')?.[0] || + uiGroups.get('blocked')?.[0] || + uiGroups.get('follow-up')?.[0] || + uiGroups.get('needs-review')?.[0]; + for (const [ui, groupItems] of uiGroups) { + if (!groupItems.length) continue; + + items.push( + createQuickPickSeparator(groupItems.length ? groupItems.length.toString() : undefined), + createDirectiveQuickPickItem(Directive.Reload, false, { + label: `$(${ + context.collapsed.get(ui) ? 'chevron-down' : 'chevron-up' + })\u00a0\u00a0${launchpadGroupIconMap.get(ui)!}\u00a0\u00a0${launchpadGroupLabelMap + .get(ui) + ?.toUpperCase()}`, //'\u00a0', + //detail: groupMap.get(group)?.[0].toUpperCase(), + onDidSelect: () => { + const collapsed = !context.collapsed.get(ui); + context.collapsed.set(ui, collapsed); + if (state.initialGroup == null) { + void this.container.storage.store( + 'launchpad:groups:collapsed', + Array.from(context.collapsed.keys()).filter(g => context.collapsed.get(g)), + ); + } + + if (this.container.telemetry.enabled) { + updateTelemetryContext(context); + this.container.telemetry.sendEvent( + 'launchpad/groupToggled', + { + ...context.telemetryContext!, + group: ui, + collapsed: collapsed, + }, + this.source, + ); + } + }, + }), + ); + + if (context.collapsed.get(ui)) continue; + + items.push( + ...groupItems.map(i => { + const buttons = []; + + if (i.actionableCategory === 'mergeable') { + buttons.push(MergeQuickInputButton); + } + + buttons.push( + i.viewer.pinned ? UnpinQuickInputButton : PinQuickInputButton, + i.viewer.snoozed ? UnsnoozeQuickInputButton : SnoozeQuickInputButton, + ); + + buttons.push(...getOpenOnGitProviderQuickInputButtons(i.provider.id)); + + if (!i.openRepository?.localBranch?.current) { + buttons.push(OpenWorktreeInNewWindowQuickInputButton); + } + + return { + label: i.title.length > 60 ? `${i.title.substring(0, 60)}...` : i.title, + // description: `${i.repoAndOwner}#${i.id}, by @${i.author}`, + description: `\u00a0 ${i.repository.owner.login}/${i.repository.name}#${i.id} \u00a0 ${ + i.codeSuggestionsCount > 0 + ? ` $(gitlens-code-suggestion) ${i.codeSuggestionsCount}` + : '' + } \u00a0 ${i.isNew ? '(New since last view)' : ''}`, + detail: ` ${i.viewer.pinned ? '$(pinned) ' : ''}${ + i.actionableCategory === 'other' + ? '' + : `${actionGroupMap.get(i.actionableCategory)![0]} \u2022 ` + }${fromNow(i.updatedDate)} by @${i.author!.username}`, + + buttons: buttons, + iconPath: i.author?.avatarUrl != null ? Uri.parse(i.author.avatarUrl) : undefined, + item: i, + picked: i.graphQLId === picked || i.graphQLId === topItem?.graphQLId, + group: ui, + }; + }), + ); + } + } + + return items; + }; + + function getItemsAndPlaceholder() { + if (context.result.error != null) { + return { + placeholder: `Unable to load items (${ + context.result.error.name === 'HttpError' && + 'status' in context.result.error && + typeof context.result.error.status === 'number' + ? `${context.result.error.status}: ${String(context.result.error)}` + : String(context.result.error) + })`, + items: [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })], + }; + } + + if (!context.result.items.length) { + return { + placeholder: 'All done! Take a vacation', + items: [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })], + }; + } + + return { + placeholder: 'Choose an item to focus on', + items: getItems(context.result), + }; + } + + const updateItems = async ( + quickpick: QuickPick, + ) => { + quickpick.busy = true; + + try { + await updateContextItems(this.container, context, { force: true }); + + const { items, placeholder } = getItemsAndPlaceholder(); + quickpick.placeholder = placeholder; + quickpick.items = items; + } finally { + quickpick.busy = false; + } + }; + + const { items, placeholder } = getItemsAndPlaceholder(); + + let groupsHidden = false; + + const step = createPickStep({ + title: context.title, + placeholder: placeholder, + matchOnDetail: true, + items: items, + buttons: [ + // FeedbackQuickInputButton, + OpenOnWebQuickInputButton, + ...(hasDisconnectedIntegrations ? [ConnectIntegrationButton] : []), + LaunchpadSettingsQuickInputButton, + RefreshQuickInputButton, + ], + onDidChangeValue: quickpick => { + const hideGroups = Boolean(quickpick.value?.length); + + if (groupsHidden != hideGroups) { + groupsHidden = hideGroups; + quickpick.items = hideGroups ? items.filter(i => !isDirectiveQuickPickItem(i)) : items; + } + + return true; + }, + onDidClickButton: async (quickpick, button) => { + switch (button) { + case ConnectIntegrationButton: + this.sendTitleActionTelemetry('connect', context); + return this.next([connectMoreIntegrationsItem]); + + case LaunchpadSettingsQuickInputButton: + this.sendTitleActionTelemetry('settings', context); + void commands.executeCommand('workbench.action.openSettings', 'gitlens.launchpad'); + break; + + case FeedbackQuickInputButton: + this.sendTitleActionTelemetry('feedback', context); + void openUrl('https://github.com/gitkraken/vscode-gitlens/discussions/3286'); + break; + + case OpenOnWebQuickInputButton: + this.sendTitleActionTelemetry('open-on-gkdev', context); + void openUrl(this.container.launchpad.generateWebUrl()); + break; + + case RefreshQuickInputButton: + this.sendTitleActionTelemetry('refresh', context); + await updateItems(quickpick); + break; + } + return undefined; + }, + + onDidClickItemButton: async (quickpick, button, { group, item }) => { + if (button === LearnAboutProQuickInputButton) { + void openUrl(urls.proFeatures); + return; + } + + if (!item) return; + + switch (button) { + case OpenOnGitHubQuickInputButton: + case OpenOnGitLabQuickInputButton: + this.sendItemActionTelemetry('soft-open', item, group, context); + this.container.launchpad.open(item); + break; + + case SnoozeQuickInputButton: + this.sendItemActionTelemetry('snooze', item, group, context); + await this.container.launchpad.snooze(item); + break; + + case UnsnoozeQuickInputButton: + this.sendItemActionTelemetry('unsnooze', item, group, context); + await this.container.launchpad.unsnooze(item); + break; + + case PinQuickInputButton: + this.sendItemActionTelemetry('pin', item, group, context); + await this.container.launchpad.pin(item); + break; + + case UnpinQuickInputButton: + this.sendItemActionTelemetry('unpin', item, group, context); + await this.container.launchpad.unpin(item); + break; + + case MergeQuickInputButton: + this.sendItemActionTelemetry('merge', item, group, context); + await this.container.launchpad.merge(item); + break; + + case OpenWorktreeInNewWindowQuickInputButton: + this.sendItemActionTelemetry('open-worktree', item, group, context); + await this.container.launchpad.switchTo(item, { skipWorktreeConfirmations: true }); + break; + } + + await updateItems(quickpick); + }, + }); + + const selection: StepSelection = yield step; + if (!canPickStepContinue(step, state, selection)) { + return StepResultBreak; + } + const element = selection[0]; + if (isConnectMoreIntegrationsItem(element)) { + return element; + } + return { ...element.item, group: element.group }; + } + + private *confirmStep( + state: LaunchpadStepState, + context: Context, + ): StepResultGenerator { + const gitProviderWebButtons = getOpenOnGitProviderQuickInputButtons(state.item.provider.id); + const confirmations: ( + | QuickPickItemOfT + | QuickPickItemOfT + | DirectiveQuickPickItem + )[] = [ + createQuickPickSeparator(fromNow(state.item.updatedDate)), + createQuickPickItemOfT( + { + label: state.item.title, + description: `${state.item.repository.owner.login}/${state.item.repository.name}#${state.item.id}`, + detail: interpolate(actionGroupMap.get(state.item.actionableCategory)![1], { + author: state.item.author!.username, + createdDateRelative: fromNow(state.item.createdDate), + }), + iconPath: state.item.author?.avatarUrl != null ? Uri.parse(state.item.author.avatarUrl) : undefined, + buttons: [...gitProviderWebButtons], + }, + 'soft-open', + ), + createDirectiveQuickPickItem(Directive.Noop, false, { label: '' }), + ...this.getLaunchpadItemInformationRows(state.item), + createQuickPickSeparator('Actions'), + ]; + + for (const action of state.item.suggestedActions) { + switch (action) { + case 'merge': { + let from; + let into; + if ( + state.item.headRepository?.owner != null && + state.item.headRepository.owner !== state.item.repository.owner + ) { + from = + state.item.headRef != null + ? `${state.item.headRepository.owner.login}:${state.item.headRef.name}` + : 'these changes'; + into = + state.item.baseRef != null + ? ` into ${state.item.repository.owner.login}:${state.item.baseRef.name}` + : ''; + } else { + from = state.item.headRef?.name ?? 'these changes'; + into = state.item.baseRef?.name ? ` into ${state.item.baseRef.name}` : ''; + } + + confirmations.push( + createQuickPickItemOfT( + { + label: 'Merge...', + detail: `Will merge ${from}${into}`, + buttons: [...gitProviderWebButtons], + }, + action, + ), + ); + break; + } + case 'open': + confirmations.push( + createQuickPickItemOfT( + { + label: `${this.getOpenActionLabel( + state.item.actionableCategory, + )} on ${getIntegrationTitle(state.item.provider.id)}`, + buttons: [...gitProviderWebButtons], + }, + action, + ), + ); + break; + case 'switch': + confirmations.push( + createQuickPickItemOfT( + { + label: 'Switch to Branch or Worktree', + detail: 'Will checkout the branch, create or open a worktree', + }, + action, + ), + ); + break; + case 'open-worktree': + confirmations.push( + createQuickPickItemOfT( + { + label: 'Open in Worktree', + detail: 'Will create or open a worktree in a new window', + }, + action, + ), + ); + break; + case 'switch-and-code-suggest': + confirmations.push( + createQuickPickItemOfT( + { + label: `Switch & Suggest ${ + state.item.viewer.isAuthor ? 'Additional ' : '' + }Code Changes`, + detail: 'Will checkout and start suggesting code changes', + }, + action, + ), + ); + break; + case 'code-suggest': + confirmations.push( + createQuickPickItemOfT( + { + label: `Suggest ${state.item.viewer.isAuthor ? 'Additional ' : ''}Code Changes`, + detail: 'Will start suggesting code changes', + }, + action, + ), + ); + break; + case 'show-overview': + confirmations.push( + createQuickPickItemOfT( + { + label: 'Open Details', + detail: 'Will open the pull request details in the Side Bar', + }, + action, + ), + ); + break; + case 'open-changes': + confirmations.push( + createQuickPickItemOfT( + { + label: 'Open Changes', + detail: 'Will open the pull request changes for review', + }, + action, + ), + ); + break; + case 'open-in-graph': + confirmations.push( + createQuickPickItemOfT( + { + label: 'Open in Commit Graph', + }, + action, + ), + ); + break; + } + } + + const step = this.createConfirmStep( + `Launchpad \u00a0\u2022\u00a0 Pull Request ${state.item.repository.owner.login}/${state.item.repository.name}#${state.item.id}`, + confirmations, + undefined, + { + placeholder: 'Choose an action to perform', + onDidClickItemButton: (_quickpick, button, item) => { + switch (button) { + case OpenOnGitHubQuickInputButton: + case OpenOnGitLabQuickInputButton: + this.sendItemActionTelemetry('soft-open', state.item, state.item.group, context); + this.container.launchpad.open(state.item); + break; + case OpenOnWebQuickInputButton: + this.sendItemActionTelemetry( + 'open-suggestion-browser', + state.item, + state.item.group, + context, + ); + if (isLaunchpadTargetActionQuickPickItem(item)) { + this.container.launchpad.openCodeSuggestionInBrowser(item.item.target); + } + break; + } + }, + }, + ); + + const selection: StepSelection = yield step; + return canPickStepContinue(step, state, selection) ? selection[0].item : StepResultBreak; + } + + private async *confirmLocalIntegrationConnectStep( + state: StepState, + context: Context, + ): AsyncStepResultGenerator<{ connected: boolean | IntegrationId; resume: () => void }> { + const confirmations: (QuickPickItemOfT | DirectiveQuickPickItem)[] = [ + createDirectiveQuickPickItem(Directive.Cancel, undefined, { + label: 'Launchpad prioritizes your pull requests to keep you focused and your team unblocked', + detail: 'Click to learn more about Launchpad', + iconPath: new ThemeIcon('rocket'), + onDidSelect: () => + void executeCommand(Commands.OpenWalkthrough, { + step: 'launchpad', + source: 'launchpad', + detail: 'info', + }), + }), + createQuickPickSeparator(), + ]; + + for (const integration of supportedLaunchpadIntegrations) { + if (context.connectedIntegrations.get(integration)) { + continue; + } + switch (integration) { + case HostingIntegrationId.GitHub: + confirmations.push( + createQuickPickItemOfT( + { + label: 'Connect to GitHub...', + detail: 'Will connect to GitHub to provide access your pull requests and issues', + }, + integration, + ), + ); + break; + case HostingIntegrationId.GitLab: + confirmations.push( + createQuickPickItemOfT( + { + label: 'Connect to GitLab...', + detail: 'Will connect to GitLab to provide access your pull requests and issues', + }, + integration, + ), + ); + break; + default: + break; + } + } + + const step = this.createConfirmStep( + `${this.title} \u00a0\u2022\u00a0 Connect an Integration`, + confirmations, + createDirectiveQuickPickItem(Directive.Cancel, false, { label: 'Cancel' }), + { placeholder: 'Connect an integration to get started with Launchpad', buttons: [], ignoreFocusOut: false }, + ); + + // Note: This is a hack to allow the quickpick to stay alive after the user finishes connecting the integration. + // Otherwise it disappears. + let freeze!: () => Disposable; + step.onDidActivate = qp => { + freeze = () => freezeStep(step, qp); + }; + + const selection: StepSelection = yield step; + if (canPickStepContinue(step, state, selection)) { + const resume = freeze(); + const chosenIntegrationId = selection[0].item; + const connected = await this.ensureIntegrationConnected(chosenIntegrationId); + return { connected: connected ? chosenIntegrationId : false, resume: () => resume[Symbol.dispose]() }; + } + + return StepResultBreak; + } + + private async *confirmCloudIntegrationsConnectStep( + state: StepState, + context: Context, + ): AsyncStepResultGenerator<{ connected: boolean | IntegrationId; resume: () => void }> { + const hasConnectedIntegration = some(context.connectedIntegrations.values(), c => c); + const step = this.createConfirmStep( + `${this.title} \u00a0\u2022\u00a0 Connect an ${hasConnectedIntegration ? 'Additional ' : ''}Integration`, + [ + createDirectiveQuickPickItem(Directive.Cancel, undefined, { + label: 'Launchpad prioritizes your pull requests to keep you focused and your team unblocked', + detail: 'Click to learn more about Launchpad', + iconPath: new ThemeIcon('rocket'), + onDidSelect: () => + void executeCommand(Commands.OpenWalkthrough, { + step: 'launchpad', + source: 'launchpad', + detail: 'info', + }), + }), + createQuickPickSeparator(), + createQuickPickItemOfT( + { + label: `Connect an ${hasConnectedIntegration ? 'Additional ' : ''}Integration...`, + detail: hasConnectedIntegration + ? 'Connect additional integrations to view their pull requests in Launchpad' + : 'Connect an integration to accelerate your PR reviews', + picked: true, + }, + true, + ), + ], + createDirectiveQuickPickItem(Directive.Cancel, false, { label: 'Cancel' }), + { + placeholder: hasConnectedIntegration + ? 'Connect additional integrations to Launchpad' + : 'Connect an integration to get started with Launchpad', + buttons: [], + ignoreFocusOut: true, + }, + ); + + // Note: This is a hack to allow the quickpick to stay alive after the user finishes connecting the integration. + // Otherwise it disappears. + let freeze!: () => Disposable; + let quickpick!: QuickPick; + step.onDidActivate = qp => { + quickpick = qp; + freeze = () => freezeStep(step, qp); + }; + + const selection: StepSelection = yield step; + + if (canPickStepContinue(step, state, selection)) { + const previousPlaceholder = quickpick.placeholder; + quickpick.placeholder = 'Connecting integrations...'; + quickpick.ignoreFocusOut = true; + const resume = freeze(); + const connected = await this.container.integrations.connectCloudIntegrations( + { integrationIds: supportedLaunchpadIntegrations }, + { + source: 'launchpad', + }, + ); + quickpick.placeholder = previousPlaceholder; + return { connected: connected, resume: () => resume[Symbol.dispose]() }; + } + + return StepResultBreak; + } + + private getLaunchpadItemInformationRows( + item: LaunchpadItem, + ): (QuickPickItemOfT | QuickPickItemOfT | DirectiveQuickPickItem)[] { + const information: ( + | QuickPickItemOfT + | QuickPickItemOfT + | DirectiveQuickPickItem + )[] = []; + switch (item.actionableCategory) { + case 'mergeable': + information.push( + createQuickPickSeparator('Status'), + this.getLaunchpadItemStatusInformation(item), + ...this.getLaunchpadItemReviewInformation(item), + ); + break; + case 'failed-checks': + case 'conflicts': + information.push(createQuickPickSeparator('Status'), this.getLaunchpadItemStatusInformation(item)); + break; + case 'unassigned-reviewers': + case 'needs-my-review': + case 'changes-requested': + case 'reviewer-commented': + case 'waiting-for-review': + information.push( + createQuickPickSeparator('Reviewers'), + ...this.getLaunchpadItemReviewInformation(item), + ); + break; + default: + break; + } + + if (item.codeSuggestions?.value != null && item.codeSuggestions.value.length > 0) { + if (information.length > 0) { + information.push(createDirectiveQuickPickItem(Directive.Noop, false, { label: '' })); + } + + information.push( + createQuickPickSeparator('Suggestions'), + ...this.getLaunchpadItemCodeSuggestionInformation(item), + ); + } + + if (information.length > 0) { + information.push(createDirectiveQuickPickItem(Directive.Noop, false, { label: '' })); + } + + return information; + } + + private getLaunchpadItemStatusInformation(item: LaunchpadItem): QuickPickItemOfT { + let status: string | undefined; + const base = item.baseRef?.name != null ? `$(git-branch) ${item.baseRef.name}` : ''; + const ciStatus = item.headCommit?.buildStatuses?.[0].state; + if (ciStatus === ProviderBuildStatusState.Success) { + if (item.hasConflicts) { + status = `$(error) Conflicts with ${base}, but passed CI checks`; + } else { + status = `$(pass) No conflicts, and passed CI checks`; + } + } else if (ciStatus === ProviderBuildStatusState.Failed) { + if (item.hasConflicts) { + status = `$(error) Conflicts with ${base}, and failed CI checks`; + } else { + status = `$(error) No conflicts, but failed CI checks`; + } + } else if (item.hasConflicts) { + status = `$(error) Conflicts with ${base}`; + } else { + status = `$(pass) No conflicts`; + } + + const gitProviderWebButtons = getOpenOnGitProviderQuickInputButtons(item.provider.id); + return createQuickPickItemOfT({ label: status, buttons: [...gitProviderWebButtons] }, 'soft-open'); + } + + private getLaunchpadItemReviewInformation(item: LaunchpadItem): QuickPickItemOfT[] { + const gitProviderWebButtons = getOpenOnGitProviderQuickInputButtons(item.provider.id); + if (item.reviews == null || item.reviews.length === 0) { + return [ + createQuickPickItemOfT( + { label: `$(info) No reviewers have been assigned`, buttons: [...gitProviderWebButtons] }, + 'soft-open', + ), + ]; + } + + const reviewInfo: QuickPickItemOfT[] = []; + + for (const review of item.reviews) { + const isCurrentUser = review.reviewer.username === item.currentViewer.username; + let reviewLabel: string | undefined; + const iconPath = review.reviewer.avatarUrl != null ? Uri.parse(review.reviewer.avatarUrl) : undefined; + switch (review.state) { + case ProviderPullRequestReviewState.Approved: + reviewLabel = `${isCurrentUser ? 'You' : review.reviewer.username} approved these changes`; + break; + case ProviderPullRequestReviewState.ChangesRequested: + reviewLabel = `${isCurrentUser ? 'You' : review.reviewer.username} requested changes`; + break; + case ProviderPullRequestReviewState.Commented: + reviewLabel = `${isCurrentUser ? 'You' : review.reviewer.username} left a comment review`; + break; + case ProviderPullRequestReviewState.ReviewRequested: + reviewLabel = `${ + isCurrentUser ? `You haven't` : `${review.reviewer.username} hasn't` + } reviewed these changes yet`; + break; + } + + if (reviewLabel != null) { + reviewInfo.push( + createQuickPickItemOfT( + { label: reviewLabel, iconPath: iconPath, buttons: [...gitProviderWebButtons] }, + 'soft-open', + ), + ); + } + } + + return reviewInfo; + } + + private getLaunchpadItemCodeSuggestionInformation( + item: LaunchpadItem, + ): (QuickPickItemOfT | DirectiveQuickPickItem)[] { + if (item.codeSuggestions?.value == null || item.codeSuggestions.value.length === 0) { + return []; + } + + const codeSuggestionInfo: (QuickPickItemOfT | DirectiveQuickPickItem)[] = [ + createDirectiveQuickPickItem(Directive.Noop, false, { + label: `$(gitlens-code-suggestion) ${pluralize('code suggestion', item.codeSuggestions.value.length)}`, + }), + ]; + + for (const suggestion of item.codeSuggestions.value) { + codeSuggestionInfo.push( + createQuickPickItemOfT( + { + label: ` ${suggestion.author.name} suggested a code change ${fromNow( + suggestion.createdAt, + )}: "${suggestion.title}"`, + iconPath: suggestion.author.avatarUri ?? getAvatarUri(suggestion.author.email), + buttons: [OpenOnWebQuickInputButton], + }, + { + action: 'open-suggestion', + target: suggestion.id, + }, + ), + ); + } + + return codeSuggestionInfo; + } + + private getOpenActionLabel(actionCategory: string) { + switch (actionCategory) { + case 'unassigned-reviewers': + return 'Assign Reviewers'; + case 'failed-checks': + return 'Resolve Failing Checks'; + case 'conflicts': + return 'Resolve Conflicts'; + case 'needs-my-review': + return 'Start Reviewing'; + case 'changes-requested': + case 'reviewer-commented': + return 'Respond to Reviewers'; + case 'waiting-for-review': + return 'Check In with Reviewers'; + case 'draft': + return 'View draft'; + default: + return 'Open'; + } + } + + private sendItemActionTelemetry( + actionOrTargetAction: + | LaunchpadAction + | LaunchpadTargetAction + | 'pin' + | 'unpin' + | 'snooze' + | 'unsnooze' + | 'open-suggestion-browser' + | 'select', + item: LaunchpadItem, + group: LaunchpadGroup, + context: Context, + ) { + if (!this.container.telemetry.enabled) return; + + let action: + | LaunchpadAction + | 'pin' + | 'unpin' + | 'snooze' + | 'unsnooze' + | 'open-suggestion' + | 'open-suggestion-browser' + | 'select' + | undefined; + if (typeof actionOrTargetAction !== 'string' && 'action' in actionOrTargetAction) { + action = actionOrTargetAction.action; + } else { + action = actionOrTargetAction; + } + if (action == null) return; + + this.container.telemetry.sendEvent( + action === 'select' ? 'launchpad/steps/details' : 'launchpad/action', + { + ...context.telemetryContext!, + action: action, + 'item.id': getLaunchpadItemIdHash(item), + 'item.type': item.type, + 'item.provider': item.provider.id, + 'item.actionableCategory': item.actionableCategory, + 'item.group': group, + 'item.assignees.count': item.assignees?.length ?? undefined, + 'item.createdDate': item.createdDate.getTime(), + 'item.updatedDate': item.updatedDate.getTime(), + 'item.isNew': item.isNew, + + 'item.comments.count': item.commentCount ?? undefined, + 'item.upvotes.count': item.upvoteCount ?? undefined, + + 'item.pr.codeSuggestionCount': item.codeSuggestionsCount, + 'item.pr.isDraft': item.isDraft, + 'item.pr.mergeableState': item.mergeableState, + 'item.pr.state': item.state, + + 'item.pr.changes.additions': item.additions ?? undefined, + 'item.pr.changes.deletions': item.deletions ?? undefined, + 'item.pr.changes.commits': item.commitCount ?? undefined, + 'item.pr.changes.files': item.fileCount ?? undefined, + + 'item.pr.failingCI': item.failingCI, + 'item.pr.hasConflicts': item.hasConflicts, + + 'item.pr.reviews.count': item.reviews?.length ?? undefined, + 'item.pr.reviews.decision': item.reviewDecision ?? undefined, + 'item.pr.reviews.changeRequestCount': item.changeRequestReviewCount ?? undefined, + + 'item.viewer.isAuthor': item.viewer.isAuthor, + 'item.viewer.isAssignee': item.viewer.isAssignee, + 'item.viewer.pinned': item.viewer.pinned, + 'item.viewer.snoozed': item.viewer.snoozed, + 'item.viewer.pr.canMerge': item.viewer.canMerge, + 'item.viewer.pr.isReviewer': item.viewer.isReviewer, + 'item.viewer.pr.shouldAssignReviewer': item.viewer.shouldAssignReviewer, + 'item.viewer.pr.shouldMerge': item.viewer.shouldMerge, + 'item.viewer.pr.shouldReview': item.viewer.shouldReview, + 'item.viewer.pr.waitingOnReviews': item.viewer.waitingOnReviews, + }, + this.source, + ); + } + + private sendTitleActionTelemetry(action: TelemetryEvents['launchpad/title/action']['action'], context: Context) { + if (!this.container.telemetry.enabled) return; + + this.container.telemetry.sendEvent( + 'launchpad/title/action', + { ...context.telemetryContext!, action: action }, + this.source, + ); + } +} + +function getOpenOnGitProviderQuickInputButton(integrationId: string): QuickInputButton | undefined { + switch (integrationId) { + case HostingIntegrationId.GitLab: + case SelfHostedIntegrationId.GitLabSelfHosted: + return OpenOnGitLabQuickInputButton; + case HostingIntegrationId.GitHub: + case SelfHostedIntegrationId.GitHubEnterprise: + return OpenOnGitHubQuickInputButton; + default: + return undefined; + } +} + +function getOpenOnGitProviderQuickInputButtons(integrationId: string): QuickInputButton[] { + const button = getOpenOnGitProviderQuickInputButton(integrationId); + return button != null ? [button] : []; +} + +function getIntegrationTitle(integrationId: string): string { + switch (integrationId) { + case HostingIntegrationId.GitLab: + case SelfHostedIntegrationId.GitLabSelfHosted: + return 'GitLab'; + case HostingIntegrationId.GitHub: + case SelfHostedIntegrationId.GitHubEnterprise: + return 'GitHub'; + default: + return integrationId; + } +} + +async function updateContextItems(container: Container, context: Context, options?: { force?: boolean }) { + context.result = await container.launchpad.getCategorizedItems(options); + if (container.telemetry.enabled) { + updateTelemetryContext(context); + } + context.connectedIntegrations = await container.launchpad.getConnectedIntegrations(); +} + +function updateTelemetryContext(context: Context) { + if (context.telemetryContext == null) return; + + let updatedContext: NonNullable<(typeof context)['telemetryContext']>; + if (context.result.error != null) { + updatedContext = { + ...context.telemetryContext, + 'items.error': String(context.result.error), + }; + } else { + const grouped = countLaunchpadItemGroups(context.result.items); + + updatedContext = { + ...context.telemetryContext, + 'items.count': context.result.items.length, + 'items.timings.prs': context.result.timings?.prs, + 'items.timings.codeSuggestionCounts': context.result.timings?.codeSuggestionCounts, + 'items.timings.enrichedItems': context.result.timings?.enrichedItems, + 'groups.count': grouped.size, + }; + + for (const [group, count] of grouped) { + updatedContext[`groups.${group}.count`] = count; + updatedContext[`groups.${group}.collapsed`] = context.collapsed.get(group); + } + } + + context.telemetryContext = updatedContext; +} + +function isLaunchpadTargetActionQuickPickItem(item: any): item is QuickPickItemOfT { + return item?.item?.action != null && item?.item?.target != null; +} diff --git a/src/plus/launchpad/launchpadIndicator.ts b/src/plus/launchpad/launchpadIndicator.ts new file mode 100644 index 0000000000000..d4ecc8fb78004 --- /dev/null +++ b/src/plus/launchpad/launchpadIndicator.ts @@ -0,0 +1,600 @@ +import type { ConfigurationChangeEvent, StatusBarItem } from 'vscode'; +import { Disposable, MarkdownString, StatusBarAlignment, ThemeColor, window } from 'vscode'; +import type { OpenWalkthroughCommandArgs } from '../../commands/walkthroughs'; +import { proBadge } from '../../constants'; +import type { Colors } from '../../constants.colors'; +import { Commands } from '../../constants.commands'; +import type { Container } from '../../container'; +import { groupByMap } from '../../system/iterable'; +import { wait } from '../../system/promise'; +import { pluralize } from '../../system/string'; +import { executeCommand, registerCommand } from '../../system/vscode/command'; +import { configuration } from '../../system/vscode/configuration'; +import type { ConnectionStateChangeEvent } from '../integrations/integrationService'; +import type { HostingIntegrationId } from '../integrations/providers/models'; +import type { LaunchpadCommandArgs } from './launchpad'; +import type { LaunchpadGroup, LaunchpadItem, LaunchpadProvider, LaunchpadRefreshEvent } from './launchpadProvider'; +import { + groupAndSortLaunchpadItems, + launchpadGroupIconMap, + launchpadPriorityGroups, + supportedLaunchpadIntegrations, +} from './launchpadProvider'; + +type LaunchpadIndicatorState = 'idle' | 'disconnected' | 'loading' | 'load' | 'failed'; + +export class LaunchpadIndicator implements Disposable { + private readonly _disposable: Disposable; + private _categorizedItems: LaunchpadItem[] | undefined; + /** Tracks if this is the first state after startup */ + private _firstStateAfterStartup: boolean = true; + private _hasRefreshed: boolean = false; + private _lastDataUpdate: Date | undefined; + private _lastRefreshPaused: Date | undefined; + private _refreshTimer: ReturnType | undefined; + private _state?: LaunchpadIndicatorState; + private _statusBarLaunchpad!: StatusBarItem; + + constructor( + private readonly container: Container, + private readonly provider: LaunchpadProvider, + ) { + this._disposable = Disposable.from( + window.onDidChangeWindowState(this.onWindowStateChanged, this), + provider.onDidRefresh(this.onLaunchpadRefreshed, this), + configuration.onDidChange(this.onConfigurationChanged, this), + container.integrations.onDidChangeConnectionState(this.onConnectedIntegrationsChanged, this), + ...this.registerCommands(), + ); + + void this.onReady(); + } + + dispose() { + this.clearRefreshTimer(); + this._statusBarLaunchpad?.dispose(); + this._disposable.dispose(); + } + + private get pollingEnabled() { + return ( + configuration.get('launchpad.indicator.polling.enabled') && + configuration.get('launchpad.indicator.polling.interval') > 0 + ); + } + + private get pollingInterval() { + return configuration.get('launchpad.indicator.polling.interval') * 1000 * 60; + } + + private async onConnectedIntegrationsChanged(e: ConnectionStateChangeEvent) { + if (supportedLaunchpadIntegrations.includes(e.key as HostingIntegrationId)) { + await this.maybeLoadData(true); + } + } + + private async onConfigurationChanged(e: ConfigurationChangeEvent) { + if (!configuration.changed(e, 'launchpad.indicator')) return; + + if (configuration.changed(e, 'launchpad.indicator.label')) { + this.updateStatusBarCommand(); + } + + let load = false; + + if (configuration.changed(e, 'launchpad.indicator.polling')) { + if (configuration.changed(e, 'launchpad.indicator.polling.enabled')) { + load = true; + } else if (configuration.changed(e, 'launchpad.indicator.polling.interval')) { + this.startRefreshTimer(); + } + } + + load ||= + configuration.changed(e, 'launchpad.indicator.useColors') || + configuration.changed(e, 'launchpad.indicator.icon') || + configuration.changed(e, 'launchpad.indicator.label') || + configuration.changed(e, 'launchpad.indicator.groups'); + + if (load) { + await this.maybeLoadData(); + } + } + + private async maybeLoadData(forceIfConnected: boolean = false) { + if (this.pollingEnabled) { + if (await this.provider.hasConnectedIntegration()) { + if (this._state === 'load' && this._categorizedItems != null && !forceIfConnected) + this.updateStatusBarState('load', this._categorizedItems); + else { + this.updateStatusBarState('loading'); + } + } else { + this.updateStatusBarState('disconnected'); + } + } else { + this.updateStatusBarState('idle'); + } + } + + private onLaunchpadRefreshed(e: LaunchpadRefreshEvent) { + this._hasRefreshed = true; + if (!this.pollingEnabled) { + this.updateStatusBarState('idle'); + + return; + } + + if (e.error != null) { + this.updateStatusBarState('failed'); + + return; + } + + this.updateStatusBarState('load', e.items); + } + + private async onReady(): Promise { + this._statusBarLaunchpad = window.createStatusBarItem('gitlens.launchpad', StatusBarAlignment.Left, 10000 - 3); + this._statusBarLaunchpad.name = 'GitLens Launchpad'; + + await this.maybeLoadData(); + this.updateStatusBarCommand(); + + this._statusBarLaunchpad.show(); + } + + private onWindowStateChanged(e: { focused: boolean }) { + if (this._state === 'disconnected' || this._state === 'idle') return; + + if (!e.focused) { + this.clearRefreshTimer(); + this._lastRefreshPaused = new Date(); + + return; + } + + if (this._lastRefreshPaused == null) return; + if (this._state === 'loading') { + this.startRefreshTimer(); + + return; + } + + const now = Date.now(); + const timeSinceLastUpdate = this._lastDataUpdate != null ? now - this._lastDataUpdate.getTime() : undefined; + const timeSinceLastUnfocused = now - this._lastRefreshPaused.getTime(); + this._lastRefreshPaused = undefined; + + const refreshInterval = configuration.get('launchpad.indicator.polling.interval') * 1000 * 60; + + let timeToNextPoll = timeSinceLastUpdate != null ? refreshInterval - timeSinceLastUpdate : refreshInterval; + if (timeToNextPoll < 0) { + timeToNextPoll = 0; + } + + const diff = timeToNextPoll - timeSinceLastUnfocused; + this.startRefreshTimer(diff < 0 ? 0 : diff); + } + + private clearRefreshTimer() { + if (this._refreshTimer != null) { + clearInterval(this._refreshTimer); + this._refreshTimer = undefined; + } + } + + private startRefreshTimer(startDelay?: number) { + const starting = this._firstStateAfterStartup; + if (starting) { + this._firstStateAfterStartup = false; + } + + this.clearRefreshTimer(); + if (!this.pollingEnabled || this._state === 'disconnected') { + if (this._state !== 'idle' && this._state !== 'disconnected') { + this.updateStatusBarState('idle'); + } + return; + } + + const startRefreshInterval = () => { + this._refreshTimer = setInterval(() => { + void this.provider.getCategorizedItems({ force: true }); + }, this.pollingInterval); + }; + + if (startDelay != null) { + this._refreshTimer = setTimeout(() => { + startRefreshInterval(); + + // If we are loading at startup, wait to give vscode time to settle before querying + if (starting) { + // Using a wait here, instead using the `startDelay` to avoid case where the timer could be cancelled if the user focused a different windows before the timer fires (because we will cancel the timer) + void wait(5000).then(() => { + // If something else has already caused a refresh, don't do another one + if (this._hasRefreshed) return; + + void this.provider.getCategorizedItems({ force: true }); + }); + } else { + void this.provider.getCategorizedItems({ force: true }); + } + }, startDelay); + } else { + startRefreshInterval(); + } + } + + private updateStatusBarState(state: LaunchpadIndicatorState, categorizedItems?: LaunchpadItem[]) { + if (state !== 'load' && state === this._state) return; + + this._state = state; + this._categorizedItems = categorizedItems; + + const tooltip = new MarkdownString('', true); + tooltip.supportHtml = true; + tooltip.isTrusted = true; + + tooltip.appendMarkdown(`GitLens Launchpad ${proBadge}\u00a0\u00a0\u00a0\u00a0—\u00a0\u00a0\u00a0\u00a0`); + tooltip.appendMarkdown(`[$(question)](command:gitlens.launchpad.indicator.action?%22info%22 "What is this?")`); + tooltip.appendMarkdown('\u00a0'); + tooltip.appendMarkdown(`[$(gear)](command:workbench.action.openSettings?%22gitlens.launchpad%22 "Settings")`); + tooltip.appendMarkdown('\u00a0\u00a0|\u00a0\u00a0'); + tooltip.appendMarkdown(`[$(circle-slash) Hide](command:gitlens.launchpad.indicator.action?%22hide%22 "Hide")`); + + if ( + state === 'idle' || + state === 'disconnected' || + state === 'loading' || + (state === 'load' && !this.hasInteracted()) + ) { + tooltip.appendMarkdown('\n\n---\n\n'); + tooltip.appendMarkdown( + '[Launchpad](command:gitlens.launchpad.indicator.action?%22info%22 "Learn about Launchpad") organizes your pull requests into actionable groups to help you focus and keep your team unblocked.', + ); + tooltip.appendMarkdown( + "\n\nIt's always accessible using the `GitLens: Open Launchpad` command from the Command Palette.", + ); + } + + switch (state) { + case 'idle': + this.clearRefreshTimer(); + this._statusBarLaunchpad.text = '$(rocket)'; + this._statusBarLaunchpad.tooltip = tooltip; + this._statusBarLaunchpad.color = undefined; + break; + + case 'disconnected': + this.clearRefreshTimer(); + tooltip.appendMarkdown( + `\n\n---\n\n[Connect an integration](command:gitlens.showLaunchpad?%7B%22source%22%3A%22launchpad-indicator%22%7D "Connect an integration") to get started.`, + ); + + this._statusBarLaunchpad.text = `$(rocket)$(gitlens-unplug) Launchpad`; + this._statusBarLaunchpad.tooltip = tooltip; + this._statusBarLaunchpad.color = undefined; + break; + + case 'loading': + this.startRefreshTimer(0); + tooltip.appendMarkdown('\n\n---\n\n$(loading~spin) Loading...'); + + this._statusBarLaunchpad.text = '$(rocket)$(loading~spin)'; + this._statusBarLaunchpad.tooltip = tooltip; + this._statusBarLaunchpad.color = undefined; + break; + + case 'load': + this.updateStatusBarWithItems(tooltip, categorizedItems); + break; + + case 'failed': + this.clearRefreshTimer(); + tooltip.appendMarkdown('\n\n---\n\n$(alert) Unable to load items'); + + this._statusBarLaunchpad.text = '$(rocket)$(alert)'; + this._statusBarLaunchpad.tooltip = tooltip; + this._statusBarLaunchpad.color = undefined; + break; + } + + // After the first state change, clear this + this._firstStateAfterStartup = false; + } + + private updateStatusBarCommand() { + const labelType = configuration.get('launchpad.indicator.label') ?? 'item'; + this._statusBarLaunchpad.command = { + title: 'Open Launchpad', + command: Commands.ShowLaunchpad, + arguments: [ + { + source: 'launchpad-indicator', + state: { selectTopItem: labelType === 'item' }, + } satisfies Omit, + ], + }; + } + + private updateStatusBarWithItems(tooltip: MarkdownString, categorizedItems: LaunchpadItem[] | undefined) { + this.sendTelemetryFirstLoadEvent(); + + this._lastDataUpdate = new Date(); + const useColors = configuration.get('launchpad.indicator.useColors'); + const groups: LaunchpadGroup[] = configuration.get('launchpad.indicator.groups') ?? []; + const labelType = configuration.get('launchpad.indicator.label') ?? 'item'; + const iconType = configuration.get('launchpad.indicator.icon') ?? 'default'; + + let color: string | ThemeColor | undefined = undefined; + let priorityIcon: `$(${string})` | undefined; + let priorityItem: { item: LaunchpadItem; groupLabel: string } | undefined; + + const groupedItems = groupAndSortLaunchpadItems(categorizedItems); + const totalGroupedItems = Array.from(groupedItems.values()).reduce((total, group) => total + group.length, 0); + + const hasImportantGroupsWithItems = groups.some(group => groupedItems.get(group)?.length); + if (totalGroupedItems === 0) { + tooltip.appendMarkdown('\n\n---\n\n'); + tooltip.appendMarkdown('You are all caught up!'); + } else if (!hasImportantGroupsWithItems) { + tooltip.appendMarkdown('\n\n---\n\n'); + tooltip.appendMarkdown( + `No pull requests need your attention\\\n(${totalGroupedItems} other pull requests)`, + ); + } else { + for (const group of groups) { + const items = groupedItems.get(group); + if (!items?.length) continue; + + if (tooltip.value.length > 0) { + tooltip.appendMarkdown(`\n\n---\n\n`); + } + + const icon = launchpadGroupIconMap.get(group)!; + switch (group) { + case 'mergeable': { + priorityIcon ??= icon; + color = new ThemeColor('gitlens.launchpadIndicatorMergeableColor' satisfies Colors); + priorityItem ??= { item: items[0], groupLabel: 'can be merged' }; + tooltip.appendMarkdown( + `${icon}$(blank) [${ + labelType === 'item' && priorityItem != null + ? this.getPriorityItemLabel(priorityItem.item, items.length) + : pluralize('pull request', items.length) + } can be merged](command:gitlens.showLaunchpad?${encodeURIComponent( + JSON.stringify({ + source: 'launchpad-indicator', + state: { + initialGroup: 'mergeable', + selectTopItem: labelType === 'item', + }, + } satisfies Omit), + )} "Open Ready to Merge in Launchpad")`, + ); + break; + } + case 'blocked': { + const action = groupByMap(items, i => + i.actionableCategory === 'failed-checks' || + i.actionableCategory === 'conflicts' || + i.actionableCategory === 'unassigned-reviewers' + ? i.actionableCategory + : 'blocked', + ); + + const hasMultipleCategories = action.size > 1; + + let item: LaunchpadItem | undefined; + let actionMessage = ''; + let summaryMessage = '('; + + let actionGroupItems = action.get('unassigned-reviewers'); + if (actionGroupItems?.length) { + actionMessage = `${actionGroupItems.length > 1 ? 'need' : 'needs'} reviewers`; + summaryMessage += `${actionGroupItems.length} ${actionMessage}`; + item ??= actionGroupItems[0]; + } + + actionGroupItems = action.get('failed-checks'); + if (actionGroupItems?.length) { + actionMessage = `failed CI checks`; + summaryMessage += `${hasMultipleCategories ? ', ' : ''}${ + actionGroupItems.length + } ${actionMessage}`; + item ??= actionGroupItems[0]; + } + + actionGroupItems = action.get('conflicts'); + if (actionGroupItems?.length) { + actionMessage = `${actionGroupItems.length > 1 ? 'have' : 'has'} conflicts`; + summaryMessage += `${hasMultipleCategories ? ', ' : ''}${ + actionGroupItems.length + } ${actionMessage}`; + item ??= actionGroupItems[0]; + } + + summaryMessage += ')'; + + priorityIcon ??= icon; + color ??= new ThemeColor('gitlens.launchpadIndicatorBlockedColor' satisfies Colors); + tooltip.appendMarkdown( + `${icon}$(blank) [${ + labelType === 'item' && item != null && priorityItem == null + ? this.getPriorityItemLabel(item, items.length) + : pluralize('pull request', items.length) + } ${ + hasMultipleCategories ? 'are blocked' : actionMessage + }](command:gitlens.showLaunchpad?${encodeURIComponent( + JSON.stringify({ + source: 'launchpad-indicator', + state: { initialGroup: 'blocked', selectTopItem: labelType === 'item' }, + } satisfies Omit), + )} "Open Blocked in Launchpad")`, + ); + if (hasMultipleCategories) { + tooltip.appendMarkdown(`\\\n$(blank)$(blank) ${summaryMessage}`); + } + + if (item != null) { + let label = 'is blocked'; + if (item.actionableCategory === 'unassigned-reviewers') { + label = 'needs reviewers'; + } else if (item.actionableCategory === 'failed-checks') { + label = 'failed CI checks'; + } else if (item.actionableCategory === 'conflicts') { + label = 'has conflicts'; + } + priorityItem ??= { item: item, groupLabel: label }; + } + break; + } + case 'follow-up': { + priorityIcon ??= icon; + color ??= new ThemeColor('gitlens.launchpadIndicatorAttentionColor' satisfies Colors); + tooltip.appendMarkdown( + `${icon}$(blank) [${ + labelType === 'item' && priorityItem == null && items.length + ? this.getPriorityItemLabel(items[0], items.length) + : pluralize('pull request', items.length) + } ${ + items.length > 1 ? 'require' : 'requires' + } follow-up](command:gitlens.showLaunchpad?${encodeURIComponent( + JSON.stringify({ + source: 'launchpad-indicator', + state: { + initialGroup: 'follow-up', + selectTopItem: labelType === 'item', + }, + } satisfies Omit), + )} "Open Follow-Up in Launchpad")`, + ); + priorityItem ??= { item: items[0], groupLabel: 'requires follow-up' }; + break; + } + case 'needs-review': { + priorityIcon ??= icon; + color ??= new ThemeColor('gitlens.launchpadIndicatorAttentionColor' satisfies Colors); + tooltip.appendMarkdown( + `${icon}$(blank) [${ + labelType === 'item' && priorityItem == null && items.length + ? this.getPriorityItemLabel(items[0], items.length) + : pluralize('pull request', items.length) + } ${ + items.length > 1 ? 'need' : 'needs' + } your review](command:gitlens.showLaunchpad?${encodeURIComponent( + JSON.stringify({ + source: 'launchpad-indicator', + state: { + initialGroup: 'needs-review', + selectTopItem: labelType === 'item', + }, + } satisfies Omit), + )} "Open Needs Your Review in Launchpad")`, + ); + priorityItem ??= { item: items[0], groupLabel: 'needs your review' }; + break; + } + } + } + } + + const iconSegment = iconType === 'group' && priorityIcon != null ? priorityIcon : '$(rocket)'; + + let labelSegment; + switch (labelType) { + case 'item': + labelSegment = + priorityItem != null + ? ` ${this.getPriorityItemLabel(priorityItem.item)} ${priorityItem.groupLabel}` + : ''; + break; + + case 'counts': + labelSegment = ''; + for (const group of groups) { + if (!launchpadPriorityGroups.includes(group)) continue; + + const count = groupedItems.get(group)?.length ?? 0; + const icon = launchpadGroupIconMap.get(group)!; + + labelSegment += + !labelSegment && iconSegment === icon ? `\u00a0${count}` : `\u00a0\u00a0${icon} ${count}`; + } + break; + + default: + labelSegment = ''; + break; + } + + this._statusBarLaunchpad.text = `${iconSegment}${labelSegment}`; + this._statusBarLaunchpad.tooltip = tooltip; + this._statusBarLaunchpad.color = useColors ? color : undefined; + } + + private registerCommands(): Disposable[] { + return [ + registerCommand('gitlens.launchpad.indicator.action', async (action: string) => { + this.storeFirstInteractionIfNeeded(); + switch (action) { + case 'info': { + void executeCommand(Commands.OpenWalkthrough, { + step: 'launchpad', + source: 'launchpad-indicator', + detail: 'info', + }); + break; + } + case 'hide': { + const hide = { title: 'Hide Anyway' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const action = await window.showInformationMessage( + 'GitLens Launchpad helps you focus and keep your team unblocked.\n\nAre you sure you want hide the indicator?', + { + modal: true, + detail: '\nYou can always access Launchpad using the "GitLens: Open Launchpad" command, and can re-enable the indicator with the "GitLens: Toggle Launchpad Indicator" command.', + }, + hide, + cancel, + ); + if (action === hide) { + void configuration.updateEffective('launchpad.indicator.enabled', false); + } + break; + } + default: + break; + } + }), + ]; + } + + private getPriorityItemLabel(item: LaunchpadItem, groupLength?: number) { + return `${item.repository != null ? `${item.repository.owner.login}/${item.repository.name}` : ''}#${item.id}${ + groupLength != null && groupLength > 1 + ? ` and ${pluralize('pull request', groupLength - 1, { infix: ' other ' })}` + : '' + }`; + } + + private sendTelemetryFirstLoadEvent() { + if (!this.container.telemetry.enabled) return; + + const hasLoaded = this.container.storage.get('launchpad:indicator:hasLoaded') ?? false; + if (!hasLoaded) { + void this.container.storage.store('launchpad:indicator:hasLoaded', true); + this.container.telemetry.sendEvent('launchpad/indicator/firstLoad'); + } + } + + private storeFirstInteractionIfNeeded() { + if (this.container.storage.get('launchpad:indicator:hasInteracted') != null) return; + void this.container.storage.store('launchpad:indicator:hasInteracted', new Date().toISOString()); + } + + private hasInteracted() { + return this.container.storage.get('launchpad:indicator:hasInteracted') != null; + } +} diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts new file mode 100644 index 0000000000000..09bef4d6d14e8 --- /dev/null +++ b/src/plus/launchpad/launchpadProvider.ts @@ -0,0 +1,1048 @@ +import { md5 } from '@env/crypto'; +import type { + CodeSuggestionsCountByPrUuid, + EnrichedItemsByUniqueId, + PullRequestWithUniqueID, +} from '@gitkraken/provider-apis'; +import type { CancellationToken, ConfigurationChangeEvent } from 'vscode'; +import { Disposable, env, EventEmitter, Uri, window } from 'vscode'; +import { Commands } from '../../constants.commands'; +import type { Container } from '../../container'; +import { CancellationError } from '../../errors'; +import { openComparisonChanges } from '../../git/actions/commit'; +import type { Account } from '../../git/models/author'; +import type { GitBranch } from '../../git/models/branch'; +import { getLocalBranchByUpstream } from '../../git/models/branch'; +import type { PullRequest, SearchedPullRequest } from '../../git/models/pullRequest'; +import { getComparisonRefsForPullRequest, getRepositoryIdentityForPullRequest } from '../../git/models/pullRequest'; +import type { GitRemote } from '../../git/models/remote'; +import type { Repository } from '../../git/models/repository'; +import type { CodeSuggestionCounts, Draft } from '../../gk/models/drafts'; +import { gate } from '../../system/decorators/gate'; +import { debug, log } from '../../system/decorators/log'; +import { filterMap, groupByMap, map, some } from '../../system/iterable'; +import { Logger } from '../../system/logger'; +import { getLogScope } from '../../system/logger.scope'; +import type { TimedResult } from '../../system/promise'; +import { getSettledValue, timedWithSlowThreshold } from '../../system/promise'; +import { executeCommand, registerCommand } from '../../system/vscode/command'; +import { configuration } from '../../system/vscode/configuration'; +import { setContext } from '../../system/vscode/context'; +import { openUrl } from '../../system/vscode/utils'; +import type { UriTypes } from '../../uris/deepLinks/deepLink'; +import { DeepLinkActionType, DeepLinkType } from '../../uris/deepLinks/deepLink'; +import { showInspectView } from '../../webviews/commitDetails/actions'; +import type { ShowWipArgs } from '../../webviews/commitDetails/protocol'; +import type { IntegrationResult } from '../integrations/integration'; +import type { ConnectionStateChangeEvent } from '../integrations/integrationService'; +import type { + EnrichablePullRequest, + IntegrationId, + ProviderActionablePullRequest, +} from '../integrations/providers/models'; +import { + fromProviderPullRequest, + getActionablePullRequests, + HostingIntegrationId, + toProviderPullRequestWithUniqueId, +} from '../integrations/providers/models'; +import type { EnrichableItem, EnrichedItem } from './enrichmentService'; +import { convertRemoteProviderIdToEnrichProvider, isEnrichableRemoteProviderId } from './enrichmentService'; + +export const launchpadActionCategories = [ + 'mergeable', + 'unassigned-reviewers', + 'failed-checks', + 'conflicts', + 'needs-my-review', + 'code-suggestions', + 'changes-requested', + 'reviewer-commented', + 'waiting-for-review', + 'draft', + 'other', +] as const; +export type LaunchpadActionCategory = (typeof launchpadActionCategories)[number]; + +export const launchpadGroups = [ + 'current-branch', + 'pinned', + 'mergeable', + 'blocked', + 'follow-up', + 'needs-review', + 'waiting-for-review', + 'draft', + 'other', + 'snoozed', +] as const; +export type LaunchpadGroup = (typeof launchpadGroups)[number]; + +export const launchpadPriorityGroups = [ + 'mergeable', + 'blocked', + 'follow-up', + 'needs-review', +] satisfies readonly LaunchpadPriorityGroup[] as readonly LaunchpadGroup[]; +export type LaunchpadPriorityGroup = Extract; + +export const launchpadGroupIconMap = new Map([ + ['current-branch', '$(git-branch)'], + ['pinned', '$(pinned)'], + ['mergeable', '$(rocket)'], + ['blocked', '$(error)'], //bracket-error + ['follow-up', '$(report)'], + ['needs-review', '$(comment-unresolved)'], // feedback + ['waiting-for-review', '$(gitlens-clock)'], + ['draft', '$(git-pull-request-draft)'], + ['other', '$(ellipsis)'], + ['snoozed', '$(bell-slash)'], +]); + +export const launchpadGroupLabelMap = new Map([ + ['current-branch', 'Current Branch'], + ['pinned', 'Pinned'], + ['mergeable', 'Ready to Merge'], + ['blocked', 'Blocked'], + ['follow-up', 'Requires Follow-up'], + ['needs-review', 'Needs Your Review'], + ['waiting-for-review', 'Waiting for Review'], + ['draft', 'Draft'], + ['other', 'Other'], + ['snoozed', 'Snoozed'], +]); + +export const launchpadCategoryToGroupMap = new Map([ + ['mergeable', 'mergeable'], + ['conflicts', 'blocked'], + ['failed-checks', 'blocked'], + ['unassigned-reviewers', 'blocked'], + ['needs-my-review', 'needs-review'], + ['code-suggestions', 'follow-up'], + ['changes-requested', 'follow-up'], + ['reviewer-commented', 'follow-up'], + ['waiting-for-review', 'waiting-for-review'], + ['draft', 'draft'], + ['other', 'other'], +]); + +export const sharedCategoryToLaunchpadActionCategoryMap = new Map([ + ['readyToMerge', 'mergeable'], + ['unassignedReviewers', 'unassigned-reviewers'], + ['failingCI', 'failed-checks'], + ['conflicts', 'conflicts'], + ['needsMyReview', 'needs-my-review'], + ['changesRequested', 'changes-requested'], + ['reviewerCommented', 'reviewer-commented'], + ['waitingForReview', 'waiting-for-review'], + ['draft', 'draft'], + ['other', 'other'], +]); + +export type LaunchpadAction = + | 'merge' + | 'open' + | 'soft-open' + | 'switch' + | 'switch-and-code-suggest' + | 'open-worktree' + | 'code-suggest' + | 'show-overview' + | 'open-changes' + | 'open-in-graph'; + +export type LaunchpadTargetAction = { + action: 'open-suggestion'; + target: string; +}; + +const prActionsMap = new Map([ + ['mergeable', ['merge']], + ['unassigned-reviewers', ['open']], + ['failed-checks', ['open']], + ['conflicts', ['open']], + ['needs-my-review', ['open']], + ['code-suggestions', ['open']], + ['changes-requested', ['open']], + ['reviewer-commented', ['open']], + ['waiting-for-review', ['open']], + ['draft', ['open']], + ['other', []], +]); + +export function getSuggestedActions(category: LaunchpadActionCategory, isCurrentBranch: boolean): LaunchpadAction[] { + const actions = [...prActionsMap.get(category)!]; + if (isCurrentBranch) { + actions.push('show-overview', 'open-changes', 'code-suggest', 'open-in-graph'); + } else { + actions.push('open-worktree', 'switch', 'switch-and-code-suggest', 'open-in-graph'); + } + return actions; +} + +export type LaunchpadPullRequest = EnrichablePullRequest & ProviderActionablePullRequest; + +export type LaunchpadItem = LaunchpadPullRequest & { + currentViewer: Account; + codeSuggestionsCount: number; + codeSuggestions?: TimedResult; + isNew: boolean; + actionableCategory: LaunchpadActionCategory; + suggestedActions: LaunchpadAction[]; + openRepository?: OpenRepository; + + underlyingPullRequest: PullRequest; +}; + +export type OpenRepository = { + repo: Repository; + remote: GitRemote; + localBranch?: GitBranch; +}; + +type CachedLaunchpadPromise = { + expiresAt: number; + promise: Promise; +}; + +const cacheExpiration = 1000 * 60 * 30; // 30 minutes + +type PullRequestsWithSuggestionCounts = { + prs: IntegrationResult | undefined; + suggestionCounts: TimedResult | undefined; +}; + +export type LaunchpadRefreshEvent = LaunchpadCategorizedResult; + +export const supportedLaunchpadIntegrations = [HostingIntegrationId.GitHub, HostingIntegrationId.GitLab]; +type SupportedLaunchpadIntegrationIds = (typeof supportedLaunchpadIntegrations)[number]; +function isSupportedLaunchpadIntegrationId(id: string): id is SupportedLaunchpadIntegrationIds { + return supportedLaunchpadIntegrations.includes(id as SupportedLaunchpadIntegrationIds); +} + +export type LaunchpadCategorizedResult = + | { + items: LaunchpadItem[]; + timings?: LaunchpadCategorizedTimings; + error?: never; + } + | { + error: Error; + items?: never; + }; + +export interface LaunchpadCategorizedTimings { + prs: number | undefined; + codeSuggestionCounts: number | undefined; + enrichedItems: number | undefined; +} + +export class LaunchpadProvider implements Disposable { + private readonly _onDidChange = new EventEmitter(); + get onDidChange() { + return this._onDidChange.event; + } + + private readonly _onDidRefresh = new EventEmitter(); + get onDidRefresh() { + return this._onDidRefresh.event; + } + + private readonly _disposable: Disposable; + + constructor(private readonly container: Container) { + this._disposable = Disposable.from( + configuration.onDidChange(this.onConfigurationChanged, this), + container.integrations.onDidChangeConnectionState(this.onIntegrationConnectionStateChanged, this), + ...this.registerCommands(), + ); + } + + dispose() { + this._disposable.dispose(); + } + + private _prs: CachedLaunchpadPromise | undefined; + @debug({ args: { 0: o => `force=${o?.force}` } }) + private async getPullRequestsWithSuggestionCounts(options?: { cancellation?: CancellationToken; force?: boolean }) { + if (options?.force || this._prs == null || this._prs.expiresAt < Date.now()) { + this._prs = { + promise: this.fetchPullRequestsWithSuggestionCounts(options?.cancellation), + expiresAt: Date.now() + cacheExpiration, + }; + } + + return this._prs?.promise; + } + + @debug({ args: false }) + private async fetchPullRequestsWithSuggestionCounts(cancellation?: CancellationToken) { + const scope = getLogScope(); + + const [prsResult, subscriptionResult] = await Promise.allSettled([ + withDurationAndSlowEventOnTimeout( + this.container.integrations.getMyPullRequests(supportedLaunchpadIntegrations, cancellation, true), + 'getMyPullRequests', + this.container, + ), + this.container.subscription.getSubscription(true), + ]); + + if (prsResult.status === 'rejected') { + Logger.error(prsResult.reason, scope, 'Failed to get pull requests'); + throw prsResult.reason; + } + + const prs = getSettledValue(prsResult)?.value; + if (prs?.error != null) { + Logger.error(prs.error, scope, 'Failed to get pull requests'); + throw prs.error; + } + + const subscription = getSettledValue(subscriptionResult); + + let suggestionCounts; + if (prs?.value?.length && subscription?.account != null) { + try { + suggestionCounts = await withDurationAndSlowEventOnTimeout( + this.container.drafts.getCodeSuggestionCounts(prs.value.map(pr => pr.pullRequest)), + 'getCodeSuggestionCounts', + this.container, + ); + } catch (ex) { + Logger.error(ex, scope, 'Failed to get code suggestion counts'); + } + } + + return { prs: prs, suggestionCounts: suggestionCounts }; + } + + private _enrichedItems: CachedLaunchpadPromise> | undefined; + @debug({ args: { 0: o => `force=${o?.force}` } }) + private async getEnrichedItems(options?: { cancellation?: CancellationToken; force?: boolean }) { + if (options?.force || this._enrichedItems == null || this._enrichedItems.expiresAt < Date.now()) { + this._enrichedItems = { + promise: withDurationAndSlowEventOnTimeout( + this.container.enrichments.get(undefined, options?.cancellation), + 'getEnrichedItems', + this.container, + ), + expiresAt: Date.now() + cacheExpiration, + }; + } + + return this._enrichedItems?.promise; + } + + private _codeSuggestions: Map>> | undefined; + @debug({ + args: { 0: i => `${i.id} (${i.provider.name} ${i.type})`, 1: o => `force=${o?.force}` }, + }) + private async getCodeSuggestions(item: LaunchpadItem, options?: { force?: boolean }) { + if (item.codeSuggestionsCount < 1) return undefined; + + if (this._codeSuggestions == null || options?.force) { + this._codeSuggestions = new Map>>(); + } + + if ( + options?.force || + !this._codeSuggestions.has(item.uuid) || + this._codeSuggestions.get(item.uuid)!.expiresAt < Date.now() + ) { + const providerId = item.provider.id; + if (!isSupportedLaunchpadIntegrationId(providerId)) { + return undefined; + } + + this._codeSuggestions.set(item.uuid, { + promise: withDurationAndSlowEventOnTimeout( + this.container.drafts.getCodeSuggestions(item, providerId, { + includeArchived: false, + }), + 'getCodeSuggestions', + this.container, + ), + expiresAt: Date.now() + cacheExpiration, + }); + } + + return this._codeSuggestions.get(item.uuid)!.promise; + } + + @log() + refresh() { + this._prs = undefined; + this._enrichedItems = undefined; + this._codeSuggestions = undefined; + + this._onDidChange.fire(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async pin(item: LaunchpadItem) { + item.viewer.pinned = true; + this._onDidChange.fire(); + + await this.container.enrichments.pinItem(item.enrichable); + this._enrichedItems = undefined; + this._onDidChange.fire(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async unpin(item: LaunchpadItem) { + item.viewer.pinned = false; + this._onDidChange.fire(); + + if (item.viewer.enrichedItems == null) return; + const pinned = item.viewer.enrichedItems.find(e => e.type === 'pin'); + if (pinned == null) return; + await this.container.enrichments.unpinItem(pinned.id); + this._enrichedItems = undefined; + this._onDidChange.fire(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async snooze(item: LaunchpadItem) { + item.viewer.snoozed = true; + this._onDidChange.fire(); + + await this.container.enrichments.snoozeItem(item.enrichable); + this._enrichedItems = undefined; + this._onDidChange.fire(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async unsnooze(item: LaunchpadItem) { + item.viewer.snoozed = false; + this._onDidChange.fire(); + + if (item.viewer.enrichedItems == null) return; + const snoozed = item.viewer.enrichedItems.find(e => e.type === 'snooze'); + if (snoozed == null) return; + await this.container.enrichments.unsnoozeItem(snoozed.id); + this._enrichedItems = undefined; + this._onDidChange.fire(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async merge(item: LaunchpadItem): Promise { + if (item.graphQLId == null || item.headRef?.oid == null) return; + const integrationId = item.provider.id; + if (!isSupportedLaunchpadIntegrationId(integrationId)) return; + const confirm = await window.showQuickPick(['Merge', 'Cancel'], { + placeHolder: `Are you sure you want to merge ${item.headRef?.name ?? 'this pull request'}${ + item.baseRef?.name ? ` into ${item.baseRef.name}` : '' + }? This cannot be undone.`, + }); + if (confirm !== 'Merge') return; + const integration = await this.container.integrations.get(integrationId); + const pr: PullRequest = fromProviderPullRequest(item, integration); + await integration.mergePullRequest(pr); + this.refresh(); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + open(item: LaunchpadItem): void { + if (item.url == null) return; + void openUrl(item.url); + this._prs = undefined; + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + openCodeSuggestion(item: LaunchpadItem, target: string) { + const draft = item.codeSuggestions?.value?.find(d => d.id === target); + if (draft == null) return; + this._codeSuggestions?.delete(item.uuid); + this._prs = undefined; + void executeCommand(Commands.OpenCloudPatch, { + type: 'code_suggestion', + draft: draft, + }); + } + + @log() + openCodeSuggestionInBrowser(target: string) { + void openUrl(this.container.drafts.generateWebUrl(target)); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async switchTo( + item: LaunchpadItem, + options?: { skipWorktreeConfirmations?: boolean; startCodeSuggestion?: boolean }, + ): Promise { + if (item.openRepository?.localBranch?.current) { + void showInspectView({ + type: 'wip', + inReview: options?.startCodeSuggestion, + repository: item.openRepository.repo, + source: 'launchpad', + } satisfies ShowWipArgs); + return; + } + + const deepLinkUrl = this.getItemBranchDeepLink( + item, + options?.startCodeSuggestion + ? DeepLinkActionType.SwitchToAndSuggestPullRequest + : options?.skipWorktreeConfirmations + ? DeepLinkActionType.SwitchToPullRequestWorktree + : DeepLinkActionType.SwitchToPullRequest, + ); + if (deepLinkUrl == null) return; + + this._codeSuggestions?.delete(item.uuid); + await this.container.deepLinks.processDeepLinkUri(deepLinkUrl, false); + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async openChanges(item: LaunchpadItem) { + if (!item.openRepository?.localBranch?.current) return; + + await this.switchTo(item); + if (item.refs != null) { + const refs = getComparisonRefsForPullRequest(item.openRepository.repo.path, item.refs); + await openComparisonChanges( + this.container, + { + repoPath: refs.repoPath, + lhs: refs.base.ref, + rhs: refs.head.ref, + }, + { title: `Changes in Pull Request #${item.id}` }, + ); + } + } + + @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) + async openInGraph(item: LaunchpadItem) { + const deepLinkUrl = this.getItemBranchDeepLink(item); + if (deepLinkUrl == null) return; + await this.container.deepLinks.processDeepLinkUri(deepLinkUrl, false); + } + + generateWebUrl(): string { + return this.container.generateWebGkDevUrl('/launchpad'); + } + + private getItemBranchDeepLink(item: LaunchpadItem, action?: DeepLinkActionType): Uri | undefined { + if (item.type !== 'pullrequest' || item.headRef == null || item.repoIdentity?.remote?.url == null) + return undefined; + + const branchName = + action == null && item.openRepository?.localBranch?.current + ? item.openRepository.localBranch.name + : item.headRef.name; + + return getPullRequestBranchDeepLink(this.container, branchName, item.repoIdentity.remote.url, action); + } + + private async getMatchingOpenRepository( + pr: EnrichablePullRequest, + matchingRemoteMap: Map, + ): Promise { + if (pr.repoIdentity.remote.url == null) return undefined; + + const match = + matchingRemoteMap.get(pr.repoIdentity.remote.url) ?? + (pr.underlyingPullRequest?.refs?.base?.url + ? matchingRemoteMap.get(pr.underlyingPullRequest.refs.base.url) + : undefined); + if (match == null) return undefined; + + const [repo, remote] = match; + + const remoteBranchName = `${remote.name}/${pr.refs?.head.branch ?? pr.headRef?.name}`; + const matchingLocalBranch = await getLocalBranchByUpstream(repo, remoteBranchName); + + return { repo: repo, remote: remote, localBranch: matchingLocalBranch }; + } + + private async getMatchingRemoteMap(actionableItems: LaunchpadPullRequest[]) { + const uniqueRemoteUrls = new Set(); + for (const item of actionableItems) { + if (item.repoIdentity.remote.url != null) { + uniqueRemoteUrls.add(item.repoIdentity.remote.url.replace(/\.git$/, '')); + } + } + + // Get the repo/remote pairs for the unique remote urls + const repoRemotes = new Map(); + + async function matchRemotes(repo: Repository) { + if (uniqueRemoteUrls.size === 0) return; + + const remotes = await repo.getRemotes(); + + for (const remote of remotes) { + if (uniqueRemoteUrls.size === 0) return; + + const remoteUrl = remote.url.replace(/\.git$/, ''); + if (uniqueRemoteUrls.has(remoteUrl)) { + repoRemotes.set(remoteUrl, [repo, remote]); + uniqueRemoteUrls.delete(remoteUrl); + + if (uniqueRemoteUrls.size === 0) return; + } else { + for (const [url] of uniqueRemoteUrls) { + if (remote.matches(url)) { + repoRemotes.set(url, [repo, remote]); + uniqueRemoteUrls.delete(url); + + if (uniqueRemoteUrls.size === 0) return; + + break; + } + } + } + } + } + + await Promise.allSettled(map(this.container.git.openRepositories, r => matchRemotes(r))); + + return repoRemotes; + } + + @gate(o => `${o?.force ?? false}`) + @log({ args: { 0: o => `force=${o?.force}`, 1: false } }) + async getCategorizedItems( + options?: { force?: boolean }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + + const fireRefresh = options?.force || this._prs == null; + + const ignoredRepositories = new Set( + (configuration.get('launchpad.ignoredRepositories') ?? []).map(r => r.toLowerCase()), + ); + const staleThreshold = configuration.get('launchpad.staleThreshold'); + let staleDate: Date | undefined; + if (staleThreshold != null) { + staleDate = new Date(); + // Subtract the number of days from the current date + staleDate.setDate(staleDate.getDate() - staleThreshold); + } + + // TODO: Since this is all repos we probably should order by repos you are a contributor on (or even filter out one you aren't) + + let result: LaunchpadCategorizedResult | undefined; + + try { + const [_, enrichedItemsResult, prsWithCountsResult] = await Promise.allSettled([ + this.container.git.isDiscoveringRepositories, + this.getEnrichedItems({ force: options?.force, cancellation: cancellation }), + this.getPullRequestsWithSuggestionCounts({ force: options?.force, cancellation: cancellation }), + ]); + + if (cancellation?.isCancellationRequested) throw new CancellationError(); + + if (prsWithCountsResult.status === 'rejected') { + Logger.error(prsWithCountsResult.reason, scope, 'Failed to get pull requests with suggestion counts'); + result = { + error: + prsWithCountsResult.reason instanceof Error + ? prsWithCountsResult.reason + : new Error(String(prsWithCountsResult.reason)), + }; + return result; + } + + const enrichedItems = getSettledValue(enrichedItemsResult); + const prsWithSuggestionCounts = getSettledValue(prsWithCountsResult); + + const prs = prsWithSuggestionCounts?.prs; + if (prs?.value == null) { + result = { + items: [], + timings: { + prs: prsWithSuggestionCounts?.prs?.duration, + codeSuggestionCounts: prsWithSuggestionCounts?.suggestionCounts?.duration, + enrichedItems: enrichedItems?.duration, + }, + }; + return result; + } + + // Multiple enriched items can have the same entityId. Map by entityId to an array of enriched items. + const enrichedItemsByEntityId: { [id: string]: EnrichedItem[] } = {}; + + if (enrichedItems?.value != null) { + for (const enrichedItem of enrichedItems.value) { + if (enrichedItem.entityId in enrichedItemsByEntityId) { + enrichedItemsByEntityId[enrichedItem.entityId].push(enrichedItem); + } else { + enrichedItemsByEntityId[enrichedItem.entityId] = [enrichedItem]; + } + } + } + + const filteredPrs = !ignoredRepositories.size + ? prs.value + : prs.value.filter( + pr => + !ignoredRepositories.has( + `${pr.pullRequest.repository.owner.toLowerCase()}/${pr.pullRequest.repository.repo.toLowerCase()}`, + ), + ); + + // There was a conversation https://github.com/gitkraken/vscode-gitlens/pull/3200#discussion_r1563347675 + // that was related to this piece of code. + // But since the code has changed it might be hard to find it, therefore I'm leaving the link here, + // because it's still relevant. + const myAccounts: Map = + await this.container.integrations.getMyCurrentAccounts(supportedLaunchpadIntegrations); + + const inputPrs: (EnrichablePullRequest | undefined)[] = filteredPrs.map(pr => { + const providerPr = toProviderPullRequestWithUniqueId(pr.pullRequest); + + const providerId = pr.pullRequest.provider.id; + + if (!isSupportedLaunchpadIntegrationId(providerId) || !isEnrichableRemoteProviderId(providerId)) { + Logger.warn(`Unsupported provider ${providerId}`); + return undefined; + } + + const enrichable = { + type: 'pr', + id: providerPr.uuid, + url: pr.pullRequest.url, + provider: convertRemoteProviderIdToEnrichProvider(providerId), + } satisfies EnrichableItem; + + const repoIdentity = getRepositoryIdentityForPullRequest(pr.pullRequest); + + return { + ...providerPr, + type: 'pullrequest', + uuid: providerPr.uuid, + provider: pr.pullRequest.provider, + enrichable: enrichable, + repoIdentity: repoIdentity, + refs: pr.pullRequest.refs, + underlyingPullRequest: pr.pullRequest, + } satisfies EnrichablePullRequest; + }) satisfies (EnrichablePullRequest | undefined)[]; + + // Note: The expected output of this is ActionablePullRequest[], but we are passing in EnrichablePullRequest, + // so we need to cast the output as LaunchpadPullRequest[]. + const actionableItems = this.getActionablePullRequests( + inputPrs.filter((i: EnrichablePullRequest | undefined): i is EnrichablePullRequest => i != null), + myAccounts, + { enrichedItemsByUniqueId: enrichedItemsByEntityId }, + ) as LaunchpadPullRequest[]; + + // Get the unique remote urls + const mappedRemotesPromise = await this.getMatchingRemoteMap(actionableItems); + + const { suggestionCounts } = prsWithSuggestionCounts!; + + // Map from shared category label to local actionable category, and get suggested actions + const categorized = await Promise.allSettled( + actionableItems.map>(async item => { + const codeSuggestionsCount = suggestionCounts?.value?.[item.uuid]?.count ?? 0; + + let actionableCategory = sharedCategoryToLaunchpadActionCategoryMap.get( + item.suggestedActionCategory, + )!; + // category overrides + if (staleDate != null && item.updatedDate.getTime() < staleDate.getTime()) { + actionableCategory = 'other'; + } else if (codeSuggestionsCount > 0 && item.viewer.isAuthor) { + actionableCategory = 'code-suggestions'; + } + + const openRepository = await this.getMatchingOpenRepository(item, mappedRemotesPromise); + + const suggestedActions = getSuggestedActions( + actionableCategory, + openRepository?.localBranch?.current ?? false, + ); + + return { + ...item, + currentViewer: myAccounts.get(item.provider.id)!, + codeSuggestionsCount: codeSuggestionsCount, + isNew: this.isItemNewInGroup(item, actionableCategory), + actionableCategory: actionableCategory, + suggestedActions: suggestedActions, + openRepository: openRepository, + underlyingPullRequest: item.underlyingPullRequest, + } satisfies LaunchpadItem; + }), + ); + + result = { + items: [...filterMap(categorized, i => getSettledValue(i))], + timings: { + prs: prsWithSuggestionCounts?.prs?.duration, + codeSuggestionCounts: prsWithSuggestionCounts?.suggestionCounts?.duration, + enrichedItems: enrichedItems?.duration, + }, + }; + return result; + } finally { + this.updateGroupedIds(result?.items ?? []); + if (result != null && fireRefresh) { + this._onDidRefresh.fire(result); + } + } + } + + // TODO: Switch to using getActionablePullRequests from the shared provider library + // once it supports passing in multiple current users, one for each provider + private getActionablePullRequests( + pullRequests: (PullRequestWithUniqueID & { provider: { id: string } })[], + currentUsers: Map, + options?: { + enrichedItemsByUniqueId?: EnrichedItemsByUniqueId; + codeSuggestionsCountByPrUuid?: CodeSuggestionsCountByPrUuid; + }, + ): ProviderActionablePullRequest[] { + const pullRequestsByIntegration = groupByMap( + pullRequests, + pr => pr.provider.id, + ); + + const actionablePullRequests: ProviderActionablePullRequest[] = []; + for (const [integrationId, prs] of pullRequestsByIntegration.entries()) { + const currentUser = currentUsers.get(integrationId); + if (currentUser == null) { + Logger.warn(`No current user for integration ${integrationId}`); + continue; + } + + const actionablePrs = getActionablePullRequests(prs, { id: currentUser.id }, options); + actionablePullRequests.push(...actionablePrs); + } + + return actionablePullRequests; + } + + private _groupedIds: Set | undefined; + + private isItemNewInGroup(item: LaunchpadPullRequest, actionableCategory: LaunchpadActionCategory) { + return ( + this._groupedIds != null && + !this._groupedIds.has(`${item.uuid}:${launchpadCategoryToGroupMap.get(actionableCategory)}`) + ); + } + + private updateGroupedIds(items: LaunchpadItem[]) { + const groupedIds = new Set(); + for (const item of items) { + const group = launchpadCategoryToGroupMap.get(item.actionableCategory)!; + const key = `${item.uuid}:${group}`; + if (!groupedIds.has(key)) { + groupedIds.add(key); + } + } + + this._groupedIds = groupedIds; + } + + async hasConnectedIntegration(): Promise { + for (const integrationId of supportedLaunchpadIntegrations) { + const integration = await this.container.integrations.get(integrationId); + if (integration.maybeConnected ?? (await integration.isConnected())) { + return true; + } + } + + void setContext('gitlens:launchpad:connect', true); + return false; + } + + async getConnectedIntegrations(): Promise> { + const connected = new Map(); + await Promise.allSettled( + supportedLaunchpadIntegrations.map(async integrationId => { + const integration = await this.container.integrations.get(integrationId); + connected.set(integrationId, integration.maybeConnected ?? (await integration.isConnected())); + }), + ); + + void setContext('gitlens:launchpad:connect', !some(connected.values(), c => c)); + return connected; + } + + @log({ + args: { 0: i => `${i.id} (${i.provider.name} ${i.type})`, 1: o => `force=${o?.force}` }, + }) + async ensureLaunchpadItemCodeSuggestions( + item: LaunchpadItem, + options?: { force?: boolean }, + ): Promise | undefined> { + item.codeSuggestions ??= await this.getCodeSuggestions(item, options); + return item.codeSuggestions; + } + + private registerCommands(): Disposable[] { + return [ + registerCommand(Commands.ToggleLaunchpadIndicator, () => { + const enabled = configuration.get('launchpad.indicator.enabled') ?? false; + void configuration.updateEffective('launchpad.indicator.enabled', !enabled); + }), + ]; + } + + private onConfigurationChanged(e: ConfigurationChangeEvent) { + if (!configuration.changed(e, 'launchpad')) return; + + const cfg = configuration.get('launchpad'); + this.container.telemetry.sendEvent('launchpad/configurationChanged', { + 'config.launchpad.staleThreshold': cfg.staleThreshold, + 'config.launchpad.ignoredOrganizations': cfg.ignoredOrganizations?.length ?? 0, + 'config.launchpad.ignoredRepositories': cfg.ignoredRepositories?.length ?? 0, + 'config.launchpad.indicator.enabled': cfg.indicator.enabled, + 'config.launchpad.indicator.icon': cfg.indicator.icon, + 'config.launchpad.indicator.label': cfg.indicator.label, + 'config.launchpad.indicator.useColors': cfg.indicator.useColors, + 'config.launchpad.indicator.groups': cfg.indicator.groups.join(','), + 'config.launchpad.indicator.polling.enabled': cfg.indicator.polling.enabled, + 'config.launchpad.indicator.polling.interval': cfg.indicator.polling.interval, + }); + + if ( + configuration.changed(e, 'launchpad.ignoredOrganizations') || + configuration.changed(e, 'launchpad.ignoredRepositories') || + configuration.changed(e, 'launchpad.staleThreshold') + ) { + this.refresh(); + void this.getCategorizedItems({ force: true }); + } + } + + private async onIntegrationConnectionStateChanged(e: ConnectionStateChangeEvent) { + if (isSupportedLaunchpadIntegrationId(e.key)) { + if (e.reason === 'connected') { + void setContext('gitlens:launchpad:connect', false); + } else { + void setContext('gitlens:launchpad:connect', await this.hasConnectedIntegration()); + } + } + } +} + +export function groupAndSortLaunchpadItems(items?: LaunchpadItem[]) { + if (items == null || items.length === 0) return new Map(); + const grouped = new Map(launchpadGroups.map(g => [g, []])); + + sortLaunchpadItems(items); + + for (const item of items) { + if (item.viewer.snoozed) { + grouped.get('snoozed')!.push(item); + + continue; + } else if (item.viewer.pinned) { + grouped.get('pinned')!.push(item); + } + + if (item.openRepository?.localBranch?.current) { + grouped.get('current-branch')!.push(item); + } + + if (item.isDraft) { + grouped.get('draft')!.push(item); + } else { + const group = launchpadCategoryToGroupMap.get(item.actionableCategory)!; + grouped.get(group)!.push(item); + } + } + + // Re-sort pinned and draft groups by updated date + grouped.get('pinned')!.sort((a, b) => b.updatedDate.getTime() - a.updatedDate.getTime()); + grouped.get('draft')!.sort((a, b) => b.updatedDate.getTime() - a.updatedDate.getTime()); + return grouped; +} + +export function countLaunchpadItemGroups(items?: LaunchpadItem[]) { + if (items == null || items.length === 0) return new Map(); + const grouped = new Map(launchpadGroups.map(g => [g, 0])); + + function incrementGroup(group: LaunchpadGroup) { + grouped.set(group, (grouped.get(group) ?? 0) + 1); + } + + for (const item of items) { + if (item.viewer.snoozed) { + incrementGroup('snoozed'); + continue; + } else if (item.viewer.pinned) { + incrementGroup('pinned'); + } + + if (item.openRepository?.localBranch?.current) { + incrementGroup('current-branch'); + } + + if (item.isDraft) { + incrementGroup('draft'); + } else { + incrementGroup(launchpadCategoryToGroupMap.get(item.actionableCategory)!); + } + } + + return grouped; +} + +export function sortLaunchpadItems(items: LaunchpadItem[]) { + return items.sort( + (a, b) => + (a.viewer.pinned ? -1 : 1) - (b.viewer.pinned ? -1 : 1) || + launchpadActionCategories.indexOf(a.actionableCategory) - + launchpadActionCategories.indexOf(b.actionableCategory) || + b.updatedDate.getTime() - a.updatedDate.getTime(), + ); +} + +function ensureRemoteUrl(url: string) { + if (url.startsWith('https')) { + return url.endsWith('.git') ? url : `${url}.git`; + } + + return url; +} + +export function getPullRequestBranchDeepLink( + container: Container, + headRefBranchName: string, + remoteUrl: string, + action?: DeepLinkActionType, +) { + const schemeOverride = configuration.get('deepLinks.schemeOverride'); + const scheme = typeof schemeOverride === 'string' ? schemeOverride : env.uriScheme; + // TODO: Get the proper pull URL from the provider, rather than tacking .git at the end of the + // url from the head ref. + return Uri.parse( + `${scheme}://${container.context.extension.id}/${'link' satisfies UriTypes}/${DeepLinkType.Repository}/-/${ + DeepLinkType.Branch + }/${encodeURIComponent(headRefBranchName)}?url=${encodeURIComponent(ensureRemoteUrl(remoteUrl))}${ + action != null ? `&action=${action}` : '' + }`, + ); +} + +export function getLaunchpadItemIdHash(item: LaunchpadItem) { + return md5(item.uuid); +} + +const slowEventTimeout = 1000 * 30; // 30 seconds + +function withDurationAndSlowEventOnTimeout( + promise: Promise, + name: 'getMyPullRequests' | 'getCodeSuggestionCounts' | 'getCodeSuggestions' | 'getEnrichedItems', + container: Container, +): Promise> { + return timedWithSlowThreshold(promise, { + timeout: slowEventTimeout, + onSlow: (duration: number) => { + container.telemetry.sendEvent('launchpad/operation/slow', { + timeout: slowEventTimeout, + operation: name, + duration: duration, + }); + }, + }); +} diff --git a/src/plus/remotehub.ts b/src/plus/remotehub.ts index 56dddda34aa17..e99cc3a28a274 100644 --- a/src/plus/remotehub.ts +++ b/src/plus/remotehub.ts @@ -1,9 +1,10 @@ import type { Uri } from 'vscode'; import { extensions } from 'vscode'; import { ExtensionNotFoundError } from '../errors'; -import { Logger } from '../logger'; +import { Logger } from '../system/logger'; export async function getRemoteHubApi(): Promise; +// eslint-disable-next-line @typescript-eslint/unified-signatures export async function getRemoteHubApi(silent: false): Promise; export async function getRemoteHubApi(silent: boolean): Promise; export async function getRemoteHubApi(silent?: boolean): Promise { diff --git a/src/plus/repos/repositoryIdentityService.ts b/src/plus/repos/repositoryIdentityService.ts new file mode 100644 index 0000000000000..158e8ddf65349 --- /dev/null +++ b/src/plus/repos/repositoryIdentityService.ts @@ -0,0 +1,173 @@ +import type { Disposable } from 'vscode'; +import { Uri, window } from 'vscode'; +import type { Container } from '../../container'; +import { RemoteResourceType } from '../../git/models/remoteResource'; +import type { Repository } from '../../git/models/repository'; +import { parseGitRemoteUrl } from '../../git/parsers/remoteParser'; +import type { GkProviderId, RepositoryIdentityDescriptor } from '../../gk/models/repositoryIdentities'; +import { missingRepositoryId } from '../../gk/models/repositoryIdentities'; +import { log } from '../../system/decorators/log'; +import type { ServerConnection } from '../gk/serverConnection'; + +export class RepositoryIdentityService implements Disposable { + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + dispose(): void {} + + @log() + getRepository( + identity: RepositoryIdentityDescriptor, + options?: { openIfNeeded?: boolean; keepOpen?: boolean; prompt?: boolean; skipRefValidation?: boolean }, + ): Promise { + return this.locateRepository(identity, options); + } + + @log() + private async locateRepository( + identity: RepositoryIdentityDescriptor, + options?: { openIfNeeded?: boolean; keepOpen?: boolean; prompt?: boolean; skipRefValidation?: boolean }, + ): Promise { + const hasInitialCommitSha = + identity.initialCommitSha != null && identity.initialCommitSha !== missingRepositoryId; + const hasRemoteUrl = identity?.remote?.url != null; + const hasProviderInfo = + identity.provider?.id != null && identity.provider.repoDomain != null && identity.provider.repoName != null; + + if (!hasInitialCommitSha && !hasRemoteUrl && !hasProviderInfo) { + return undefined; + } + + const matches = + hasRemoteUrl || hasProviderInfo + ? await this.container.repositoryPathMapping.getLocalRepoPaths({ + remoteUrl: identity.remote?.url, + repoInfo: + identity.provider != null + ? { + provider: identity.provider.id, + owner: identity.provider.repoDomain, + repoName: identity.provider.repoName, + } + : undefined, + }) + : []; + + let foundRepo: Repository | undefined; + if (matches.length) { + for (const match of matches) { + const repo = this.container.git.getRepository(Uri.file(match)); + if (repo != null) { + foundRepo = repo; + break; + } + } + + if (foundRepo == null && options?.openIfNeeded) { + foundRepo = await this.container.git.getOrOpenRepository(Uri.file(matches[0]), { + closeOnOpen: !options?.keepOpen, + }); + } + } else { + const [, remoteDomain, remotePath] = + identity.remote?.url != null ? parseGitRemoteUrl(identity.remote.url) : []; + + // Try to match a repo using the remote URL first, since that saves us some steps. + // As a fallback, try to match using the repo id. + for (const repo of this.container.git.repositories) { + if (remoteDomain != null && remotePath != null) { + const matchingRemotes = await repo.getRemotes({ + filter: r => r.matches(remoteDomain, remotePath), + }); + if (matchingRemotes.length > 0) { + foundRepo = repo; + break; + } + } + + if (!options?.skipRefValidation && hasInitialCommitSha) { + // Repo ID can be any valid SHA in the repo, though standard practice is to use the + // first commit SHA. + if (await this.container.git.validateReference(repo.uri, identity.initialCommitSha)) { + foundRepo = repo; + break; + } + } + } + } + + if (foundRepo == null && options?.prompt) { + const locate = { title: 'Locate Repository' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const decision = await window.showInformationMessage( + `Unable to find a repository for '${identity.name}'.\nWould you like to locate it?`, + { modal: true }, + locate, + cancel, + ); + + if (decision !== locate) return undefined; + + const repoLocatedUri = ( + await window.showOpenDialog({ + title: `Choose a location for ${identity.name}`, + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + }) + )?.[0]; + + if (repoLocatedUri == null) return undefined; + + const locatedRepo = await this.container.git.getOrOpenRepository(repoLocatedUri, { + closeOnOpen: !options?.keepOpen, + detectNested: false, + }); + + if (locatedRepo == null) return undefined; + if ( + identity.initialCommitSha == null || + (await this.container.git.validateReference(locatedRepo.uri, identity.initialCommitSha)) + ) { + foundRepo = locatedRepo; + await this.addFoundRepositoryToMap(foundRepo, identity); + } + } + + return foundRepo; + } + + private async addFoundRepositoryToMap( + repo: Repository, + identity?: RepositoryIdentityDescriptor, + ) { + const repoPath = repo.uri.fsPath; + + const remotes = await repo.getRemotes(); + for (const remote of remotes) { + const remoteUrl = remote.provider?.url({ type: RemoteResourceType.Repo }); + if (remoteUrl != null) { + await this.container.repositoryPathMapping.writeLocalRepoPath({ remoteUrl: remoteUrl }, repoPath); + } + } + + if ( + identity?.provider?.id != null && + identity?.provider?.repoDomain != null && + identity?.provider?.repoName != null + ) { + await this.container.repositoryPathMapping.writeLocalRepoPath( + { + repoInfo: { + provider: identity.provider.id, + owner: identity.provider.repoDomain, + repoName: identity.provider.repoName, + }, + }, + repoPath, + ); + } + } +} diff --git a/src/plus/subscription/serverConnection.ts b/src/plus/subscription/serverConnection.ts deleted file mode 100644 index f8ad6cb8ca803..0000000000000 --- a/src/plus/subscription/serverConnection.ts +++ /dev/null @@ -1,282 +0,0 @@ -import type { CancellationToken, Disposable, StatusBarItem } from 'vscode'; -import { CancellationTokenSource, env, StatusBarAlignment, Uri, window } from 'vscode'; -import { uuid } from '@env/crypto'; -import type { RequestInfo, RequestInit, Response } from '@env/fetch'; -import { fetch, getProxyAgent } from '@env/fetch'; -import type { Container } from '../../container'; -import { Logger } from '../../logger'; -import { getLogScope } from '../../logScope'; -import { debug } from '../../system/decorators/log'; -import { memoize } from '../../system/decorators/memoize'; -import type { DeferredEvent, DeferredEventExecutor } from '../../system/event'; -import { promisifyDeferred } from '../../system/event'; - -export const AuthenticationUriPathPrefix = 'did-authenticate'; -// TODO: What user-agent should we use? -const userAgent = 'Visual-Studio-Code-GitLens'; - -interface AccountInfo { - id: string; - accountName: string; -} - -interface GraphQLRequest { - query: string; - operationName?: string; - variables?: Record; -} - -export class ServerConnection implements Disposable { - private _cancellationSource: CancellationTokenSource | undefined; - private _deferredCodeExchanges = new Map>(); - private _pendingStates = new Map(); - private _statusBarItem: StatusBarItem | undefined; - - constructor(private readonly container: Container) {} - - dispose() {} - - @memoize() - private get baseApiUri(): Uri { - if (this.container.env === 'staging') { - return Uri.parse('https://stagingapi.gitkraken.com'); - } - - if (this.container.env === 'dev') { - return Uri.parse('https://devapi.gitkraken.com'); - } - - return Uri.parse('https://api.gitkraken.com'); - } - - @memoize() - private get baseAccountUri(): Uri { - if (this.container.env === 'staging') { - return Uri.parse('https://stagingapp.gitkraken.com'); - } - - if (this.container.env === 'dev') { - return Uri.parse('https://devapp.gitkraken.com'); - } - - return Uri.parse('https://app.gitkraken.com'); - } - - abort(): Promise { - if (this._cancellationSource == null) return Promise.resolve(); - - this._cancellationSource.cancel(); - // This should allow the current auth request to abort before continuing - return new Promise(resolve => setTimeout(resolve, 50)); - } - - @debug({ args: false }) - async getAccountInfo(token: string): Promise { - const scope = getLogScope(); - - let rsp: Response; - try { - rsp = await fetch(Uri.joinPath(this.baseApiUri, 'user').toString(), { - agent: getProxyAgent(), - headers: { - Authorization: `Bearer ${token}`, - 'User-Agent': userAgent, - }, - }); - } catch (ex) { - Logger.error(ex, scope); - throw ex; - } - - if (!rsp.ok) { - Logger.error(undefined, `Getting account info failed: (${rsp.status}) ${rsp.statusText}`); - throw new Error(rsp.statusText); - } - - const json: { id: string; username: string } = await rsp.json(); - return { id: json.id, accountName: json.username }; - } - - @debug() - async login(scopes: string[], scopeKey: string): Promise { - this.updateStatusBarItem(true); - - // Include a state parameter here to prevent CSRF attacks - const gkstate = uuid(); - const existingStates = this._pendingStates.get(scopeKey) ?? []; - this._pendingStates.set(scopeKey, [...existingStates, gkstate]); - - const callbackUri = await env.asExternalUri( - Uri.parse( - `${env.uriScheme}://${this.container.context.extension.id}/${AuthenticationUriPathPrefix}?gkstate=${gkstate}`, - ), - ); - - const uri = Uri.joinPath(this.baseAccountUri, 'register').with({ - query: `${ - scopes.includes('gitlens') ? 'referrer=gitlens&' : '' - }pass-token=true&return-url=${encodeURIComponent(callbackUri.toString())}`, - }); - void (await env.openExternal(uri)); - - // Ensure there is only a single listener for the URI callback, in case the user starts the login process multiple times before completing it - let deferredCodeExchange = this._deferredCodeExchanges.get(scopeKey); - if (deferredCodeExchange == null) { - deferredCodeExchange = promisifyDeferred( - this.container.uri.onDidReceiveAuthenticationUri, - this.getUriHandlerDeferredExecutor(scopeKey), - ); - this._deferredCodeExchanges.set(scopeKey, deferredCodeExchange); - } - - if (this._cancellationSource != null) { - this._cancellationSource.cancel(); - this._cancellationSource.dispose(); - this._cancellationSource = undefined; - } - - this._cancellationSource = new CancellationTokenSource(); - - void this.openCompletionInputFallback(this._cancellationSource.token); - - return Promise.race([ - deferredCodeExchange.promise, - new Promise((_, reject) => - this._cancellationSource?.token.onCancellationRequested(() => reject('Cancelled')), - ), - new Promise((_, reject) => setTimeout(reject, 120000, 'Cancelled')), - ]).finally(() => { - this._cancellationSource?.cancel(); - this._cancellationSource?.dispose(); - this._cancellationSource = undefined; - - this._pendingStates.delete(scopeKey); - deferredCodeExchange?.cancel(); - this._deferredCodeExchanges.delete(scopeKey); - this.updateStatusBarItem(false); - }); - } - - private async openCompletionInputFallback(cancellationToken: CancellationToken) { - const input = window.createInputBox(); - input.ignoreFocusOut = true; - - const disposables: Disposable[] = []; - - try { - if (cancellationToken.isCancellationRequested) return; - - const uri = await new Promise(resolve => { - disposables.push( - cancellationToken.onCancellationRequested(() => input.hide()), - input.onDidHide(() => resolve(undefined)), - input.onDidChangeValue(e => { - if (!e) { - input.validationMessage = undefined; - return; - } - - try { - const uri = Uri.parse(e.trim()); - if (uri.scheme && uri.scheme !== 'file') { - input.validationMessage = undefined; - return; - } - } catch {} - - input.validationMessage = 'Please enter a valid authorization URL'; - }), - input.onDidAccept(() => resolve(Uri.parse(input.value.trim()))), - ); - - input.title = 'GitLens+ Sign In'; - input.placeholder = 'Please enter the provided authorization URL'; - input.prompt = 'If the auto-redirect fails, paste the authorization URL'; - - input.show(); - }); - - if (uri != null) { - this.container.uri.handleUri(uri); - } - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); - } - } - - private getUriHandlerDeferredExecutor(_scopeKey: string): DeferredEventExecutor { - return (uri: Uri, resolve, reject) => { - // TODO: We should really support a code to token exchange, but just return the token from the query string - // await this.exchangeCodeForToken(uri.query); - // As the backend still doesn't implement yet the code to token exchange, we just validate the state returned - const queryParams: URLSearchParams = new URLSearchParams(uri.query); - - const acceptedStates = this._pendingStates.get(_scopeKey); - const state = queryParams.get('gkstate'); - - if (acceptedStates == null || !state || !acceptedStates.includes(state)) { - // A common scenario of this happening is if you: - // 1. Trigger a sign in with one set of scopes - // 2. Before finishing 1, you trigger a sign in with a different set of scopes - // In this scenario we should just return and wait for the next UriHandler event - // to run as we are probably still waiting on the user to hit 'Continue' - Logger.log('State not found in accepted state. Skipping this execution...'); - return; - } - - const accessToken = queryParams.get('access-token'); - const code = queryParams.get('code'); - const token = accessToken ?? code; - - if (token == null) { - reject('Token not returned'); - } else { - resolve(token); - } - }; - } - - private updateStatusBarItem(signingIn?: boolean) { - if (signingIn && this._statusBarItem == null) { - this._statusBarItem = window.createStatusBarItem('gitlens.plus.signIn', StatusBarAlignment.Left); - this._statusBarItem.name = 'GitLens+ Sign in'; - this._statusBarItem.text = 'Signing in to GitLens+...'; - this._statusBarItem.show(); - } - - if (!signingIn && this._statusBarItem != null) { - this._statusBarItem.dispose(); - this._statusBarItem = undefined; - } - } - - async fetchGraphql(data: GraphQLRequest, token: string, init?: RequestInit) { - return this.fetchCore(Uri.joinPath(this.baseAccountUri, 'api/projects/graphql').toString(), token, { - method: 'POST', - body: JSON.stringify(data), - ...init, - }); - } - - private async fetchCore(url: RequestInfo, token: string, init?: RequestInit): Promise { - const scope = getLogScope(); - - try { - const options = { - agent: getProxyAgent(), - ...init, - headers: { - Authorization: `Bearer ${token}`, - 'User-Agent': userAgent, - 'Content-Type': 'application/json', - ...init?.headers, - }, - }; - return await fetch(url, options); - } catch (ex) { - Logger.error(ex, scope); - throw ex; - } - } -} diff --git a/src/plus/subscription/subscriptionService.ts b/src/plus/subscription/subscriptionService.ts deleted file mode 100644 index f3660c493c788..0000000000000 --- a/src/plus/subscription/subscriptionService.ts +++ /dev/null @@ -1,1186 +0,0 @@ -import type { - AuthenticationProviderAuthenticationSessionsChangeEvent, - AuthenticationSession, - CancellationToken, - Event, - MessageItem, - StatusBarItem, -} from 'vscode'; -import { - authentication, - CancellationTokenSource, - version as codeVersion, - Disposable, - env, - EventEmitter, - MarkdownString, - ProgressLocation, - StatusBarAlignment, - ThemeColor, - Uri, - window, -} from 'vscode'; -import { fetch, getProxyAgent } from '@env/fetch'; -import { getPlatform } from '@env/platform'; -import { configuration } from '../../configuration'; -import { Commands, ContextKeys } from '../../constants'; -import type { Container } from '../../container'; -import { setContext } from '../../context'; -import { AccountValidationError } from '../../errors'; -import type { RepositoriesChangeEvent } from '../../git/gitProviderService'; -import { Logger } from '../../logger'; -import { getLogScope } from '../../logScope'; -import { showMessage } from '../../messages'; -import type { Subscription } from '../../subscription'; -import { - computeSubscriptionState, - getSubscriptionPlan, - getSubscriptionPlanName, - getSubscriptionPlanPriority, - getSubscriptionTimeRemaining, - getTimeRemaining, - isSubscriptionExpired, - isSubscriptionPaid, - isSubscriptionTrial, - SubscriptionPlanId, - SubscriptionState, -} from '../../subscription'; -import { executeCommand, registerCommand } from '../../system/command'; -import { createFromDateDelta } from '../../system/date'; -import { gate } from '../../system/decorators/gate'; -import { debug, log } from '../../system/decorators/log'; -import { memoize } from '../../system/decorators/memoize'; -import type { Deferrable } from '../../system/function'; -import { debounce, once } from '../../system/function'; -import { flatten } from '../../system/object'; -import { pluralize } from '../../system/string'; -import { openWalkthrough } from '../../system/utils'; -import { satisfies } from '../../system/version'; -import { ensurePlusFeaturesEnabled } from './utils'; - -// TODO: What user-agent should we use? -const userAgent = 'Visual-Studio-Code-GitLens'; - -export interface SubscriptionChangeEvent { - readonly current: Subscription; - readonly previous: Subscription; - readonly etag: number; -} - -export class SubscriptionService implements Disposable { - private static authenticationProviderId = 'gitlens+'; - private static authenticationScopes = ['gitlens']; - - private _onDidChange = new EventEmitter(); - get onDidChange(): Event { - return this._onDidChange.event; - } - - private _disposable: Disposable; - private _subscription!: Subscription; - private _statusBarSubscription: StatusBarItem | undefined; - private _validationTimer: ReturnType | undefined; - - constructor(private readonly container: Container, previousVersion: string | undefined) { - this._disposable = Disposable.from( - once(container.onReady)(this.onReady, this), - this.container.subscriptionAuthentication.onDidChangeSessions( - e => setTimeout(() => this.onAuthenticationChanged(e), 0), - this, - ), - configuration.onDidChange(e => { - if (configuration.changed(e, 'plusFeatures')) { - this.updateContext(); - } - }), - ); - - const subscription = this.getStoredSubscription(); - // Resets the preview trial state on the upgrade to 13.0 - if (subscription != null && satisfies(previousVersion, '< 13.0')) { - subscription.previewTrial = undefined; - } - - this.changeSubscription(subscription, true); - setTimeout(() => void this.ensureSession(false), 10000); - } - - dispose(): void { - this._statusBarSubscription?.dispose(); - - this._disposable.dispose(); - } - - private async onAuthenticationChanged(e: AuthenticationProviderAuthenticationSessionsChangeEvent) { - let session = this._session; - if (session == null && this._sessionPromise != null) { - session = await this._sessionPromise; - } - - if (session != null && e.removed?.some(s => s.id === session!.id)) { - this._session = undefined; - this._sessionPromise = undefined; - void this.logout(); - return; - } - - const updated = e.added?.[0] ?? e.changed?.[0]; - if (updated == null) return; - - if (updated.id === session?.id && updated.accessToken === session?.accessToken) { - return; - } - - this._session = session; - void this.validate(); - } - - @memoize() - private get baseApiUri(): Uri { - const { env } = this.container; - if (env === 'staging') { - return Uri.parse('https://stagingapi.gitkraken.com'); - } - - if (env === 'dev') { - return Uri.parse('https://devapi.gitkraken.com'); - } - - return Uri.parse('https://api.gitkraken.com'); - } - - @memoize() - private get baseAccountUri(): Uri { - const { env } = this.container; - if (env === 'staging') { - return Uri.parse('https://stagingapp.gitkraken.com'); - } - - if (env === 'dev') { - return Uri.parse('https://devapp.gitkraken.com'); - } - - return Uri.parse('https://app.gitkraken.com'); - } - - @memoize() - private get baseSiteUri(): Uri { - const { env } = this.container; - if (env === 'staging') { - return Uri.parse('https://staging.gitkraken.com'); - } - - if (env === 'dev') { - return Uri.parse('https://dev.gitkraken.com'); - } - - return Uri.parse('https://gitkraken.com'); - } - - private _etag: number = 0; - get etag(): number { - return this._etag; - } - - private onReady() { - this._disposable = Disposable.from( - this._disposable, - this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), - ...this.registerCommands(), - ); - this.updateContext(); - } - - private onRepositoriesChanged(_e: RepositoriesChangeEvent): void { - this.updateContext(); - } - - private registerCommands(): Disposable[] { - void this.container.viewCommands; - - return [ - registerCommand(Commands.PlusLearn, openToSide => this.learn(openToSide)), - registerCommand(Commands.PlusLoginOrSignUp, () => this.loginOrSignUp()), - registerCommand(Commands.PlusLogout, () => this.logout()), - - registerCommand(Commands.PlusStartPreviewTrial, () => this.startPreviewTrial()), - registerCommand(Commands.PlusManage, () => this.manage()), - registerCommand(Commands.PlusPurchase, () => this.purchase()), - - registerCommand(Commands.PlusResendVerification, () => this.resendVerification()), - registerCommand(Commands.PlusValidate, () => this.validate()), - - registerCommand(Commands.PlusShowPlans, () => this.showPlans()), - - registerCommand(Commands.PlusHide, () => configuration.updateEffective('plusFeatures.enabled', false)), - registerCommand(Commands.PlusRestore, () => configuration.updateEffective('plusFeatures.enabled', true)), - - registerCommand('gitlens.plus.reset', () => this.logout(true)), - ]; - } - - async getSubscription(cached = false): Promise { - const promise = this.ensureSession(false); - if (!cached) { - void (await promise); - } - return this._subscription; - } - - @debug() - learn(openToSide: boolean = true): void { - void openWalkthrough(this.container.context.extension.id, 'gitlens.plus', undefined, openToSide); - } - - @log() - async loginOrSignUp(): Promise { - if (!(await ensurePlusFeaturesEnabled())) return false; - - // Abort any waiting authentication to ensure we can start a new flow - await this.container.subscriptionAuthentication.abort(); - void this.showHomeView(); - - const session = await this.ensureSession(true); - const loggedIn = Boolean(session); - if (loggedIn) { - const { - account, - plan: { actual, effective }, - } = this._subscription; - - if (account?.verified === false) { - const confirm: MessageItem = { title: 'Resend Verification', isCloseAffordance: true }; - const cancel: MessageItem = { title: 'Cancel' }; - const result = await window.showInformationMessage( - `Before you can access ${effective.name}, you must verify your email address.`, - confirm, - cancel, - ); - - if (result === confirm) { - void this.resendVerification(); - } - } else if (isSubscriptionTrial(this._subscription)) { - const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); - - const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; - const learn: MessageItem = { title: 'Learn More' }; - const result = await window.showInformationMessage( - `Welcome to ${ - effective.name - } (Trial). You now have additional access to GitLens+ features on private repos for ${pluralize( - 'more day', - remaining ?? 0, - )}.`, - { modal: true }, - confirm, - learn, - ); - - if (result === learn) { - this.learn(); - } - } else if (isSubscriptionPaid(this._subscription)) { - void window.showInformationMessage( - `Welcome to ${actual.name}. You now have additional access to GitLens+ features on private repos.`, - 'OK', - ); - } else { - void window.showInformationMessage( - `Welcome to ${actual.name}. You have access to GitLens+ features on local & public repos.`, - 'OK', - ); - } - } - return loggedIn; - } - - @log() - async logout(reset: boolean = false): Promise { - return this.logoutCore(reset); - } - - private async logoutCore(reset: boolean = false): Promise { - if (this._validationTimer != null) { - clearInterval(this._validationTimer); - this._validationTimer = undefined; - } - - await this.container.subscriptionAuthentication.abort(); - - this._sessionPromise = undefined; - if (this._session != null) { - void this.container.subscriptionAuthentication.removeSession(this._session.id); - this._session = undefined; - } else { - // Even if we don't have a session, make sure to remove any other matching sessions - void this.container.subscriptionAuthentication.removeSessionsByScopes( - SubscriptionService.authenticationScopes, - ); - } - - if (reset && this.container.debugging) { - this.changeSubscription(undefined); - - return; - } - - this.changeSubscription({ - ...this._subscription, - plan: { - actual: getSubscriptionPlan( - SubscriptionPlanId.Free, - false, - undefined, - this._subscription.plan?.actual?.startedOn != null - ? new Date(this._subscription.plan.actual.startedOn) - : undefined, - ), - effective: getSubscriptionPlan( - SubscriptionPlanId.Free, - false, - undefined, - this._subscription.plan?.effective?.startedOn != null - ? new Date(this._subscription.plan.actual.startedOn) - : undefined, - ), - }, - account: undefined, - }); - } - - @log() - manage(): void { - void env.openExternal(this.baseAccountUri); - } - - @log() - async purchase(): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - - if (this._subscription.account == null) { - this.showPlans(); - } else { - void env.openExternal( - Uri.joinPath(this.baseAccountUri, 'purchase-license').with({ query: 'product=gitlens&license=PRO' }), - ); - } - await this.showHomeView(); - } - - @gate() - @log() - async resendVerification(): Promise { - if (this._subscription.account?.verified) return true; - - const scope = getLogScope(); - - void this.showHomeView(true); - - const session = await this.ensureSession(false); - if (session == null) return false; - - try { - const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'resend-email').toString(), { - method: 'POST', - agent: getProxyAgent(), - headers: { - Authorization: `Bearer ${session.accessToken}`, - 'User-Agent': userAgent, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ id: session.account.id }), - }); - - if (!rsp.ok) { - debugger; - Logger.error( - '', - scope, - `Unable to resend verification email; status=(${rsp.status}): ${rsp.statusText}`, - ); - - void window.showErrorMessage(`Unable to resend verification email; Status: ${rsp.statusText}`, 'OK'); - - return false; - } - - const confirm = { title: 'Recheck' }; - const cancel = { title: 'Cancel' }; - const result = await window.showInformationMessage( - "Once you have verified your email address, click 'Recheck'.", - confirm, - cancel, - ); - - if (result === confirm) { - await this.validate(); - return true; - } - } catch (ex) { - Logger.error(ex, scope); - debugger; - - void window.showErrorMessage('Unable to resend verification email', 'OK'); - } - - return false; - } - - @log() - async showHomeView(silent: boolean = false): Promise { - if (silent && !configuration.get('plusFeatures.enabled', undefined, true)) return; - - if (!this.container.homeView.visible) { - await executeCommand(Commands.ShowHomeView); - } - } - - private showPlans(): void { - void env.openExternal(Uri.joinPath(this.baseSiteUri, 'gitlens/pricing')); - } - - @gate() - @log() - async startPreviewTrial(silent?: boolean): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - - let { plan, previewTrial } = this._subscription; - if (previewTrial != null) { - void this.showHomeView(); - - if (!silent && plan.effective.id === SubscriptionPlanId.Free) { - const confirm: MessageItem = { title: 'Extend Your Trial', isCloseAffordance: true }; - const cancel: MessageItem = { title: 'Cancel' }; - const result = await window.showInformationMessage( - 'Your 3-day trial has ended.\nExtend your GitLens Pro trial to continue to use GitLens+ features on private repos, free for an additional 7-days.', - { modal: true }, - confirm, - cancel, - ); - - if (result === confirm) { - void this.loginOrSignUp(); - } - } - - return; - } - - const startedOn = new Date(); - - let days; - let expiresOn = new Date(startedOn); - if (!this.container.debugging) { - // Normalize the date to just before midnight on the same day - expiresOn.setHours(23, 59, 59, 999); - expiresOn = createFromDateDelta(expiresOn, { days: 3 }); - days = 3; - } else { - expiresOn = createFromDateDelta(expiresOn, { minutes: 1 }); - days = 0; - } - - previewTrial = { - startedOn: startedOn.toISOString(), - expiresOn: expiresOn.toISOString(), - }; - - this.changeSubscription({ - ...this._subscription, - plan: { - ...this._subscription.plan, - effective: getSubscriptionPlan(SubscriptionPlanId.Pro, false, undefined, startedOn, expiresOn), - }, - previewTrial: previewTrial, - }); - - if (!silent) { - const confirm: MessageItem = { title: 'OK', isCloseAffordance: true }; - const learn: MessageItem = { title: 'Learn More' }; - const result = await window.showInformationMessage( - `You have started a ${days}-day GitLens Pro trial of GitLens+ features on private repos.`, - { modal: true }, - confirm, - learn, - ); - - if (result === learn) { - this.learn(); - } - } - } - - @gate() - @log() - async validate(): Promise { - const scope = getLogScope(); - - const session = await this.ensureSession(false); - if (session == null) { - this.changeSubscription(this._subscription); - return; - } - - try { - await this.checkInAndValidate(session); - } catch (ex) { - Logger.error(ex, scope); - debugger; - } - } - - private _lastCheckInDate: Date | undefined; - @gate(s => s.account.id) - private async checkInAndValidate(session: AuthenticationSession, showSlowProgress: boolean = false): Promise { - if (!showSlowProgress) return this.checkInAndValidateCore(session); - - const validating = this.checkInAndValidateCore(session); - const result = await Promise.race([ - validating, - new Promise(resolve => setTimeout(resolve, 3000, true)), - ]); - - if (result) { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: 'Validating your GitLens+ account...', - }, - () => validating, - ); - } - } - - @debug({ args: { 0: s => s?.account.label } }) - private async checkInAndValidateCore(session: AuthenticationSession): Promise { - const scope = getLogScope(); - - try { - const checkInData = { - id: session.account.id, - platform: getPlatform(), - gitlensVersion: this.container.version, - machineId: env.machineId, - sessionId: env.sessionId, - vscodeEdition: env.appName, - vscodeHost: env.appHost, - vscodeVersion: codeVersion, - previewStartedOn: this._subscription.previewTrial?.startedOn, - previewExpiresOn: this._subscription.previewTrial?.expiresOn, - }; - - const rsp = await fetch(Uri.joinPath(this.baseApiUri, 'gitlens/checkin').toString(), { - method: 'POST', - agent: getProxyAgent(), - headers: { - Authorization: `Bearer ${session.accessToken}`, - 'User-Agent': userAgent, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(checkInData), - }); - - if (!rsp.ok) { - throw new AccountValidationError('Unable to validate account', undefined, rsp.status, rsp.statusText); - } - - const data: GKLicenseInfo = await rsp.json(); - this.validateSubscription(data); - this._lastCheckInDate = new Date(); - } catch (ex) { - Logger.error(ex, scope); - debugger; - if (ex instanceof AccountValidationError) throw ex; - - throw new AccountValidationError('Unable to validate account', ex); - } finally { - this.startDailyValidationTimer(); - } - } - - private startDailyValidationTimer(): void { - if (this._validationTimer != null) { - clearInterval(this._validationTimer); - } - - // Check 4 times a day to ensure we validate at least once a day - this._validationTimer = setInterval(() => { - if (this._lastCheckInDate == null || this._lastCheckInDate.getDate() !== new Date().getDate()) { - void this.ensureSession(false, true); - } - }, 1000 * 60 * 60 * 6); - } - - @debug() - private validateSubscription(data: GKLicenseInfo) { - const account: Subscription['account'] = { - id: data.user.id, - name: data.user.name, - email: data.user.email, - verified: data.user.status === 'activated', - createdOn: data.user.createdDate, - organizationIds: data.orgIds ?? [], - }; - - const effectiveLicenses = Object.entries(data.licenses.effectiveLicenses) as [GKLicenseType, GKLicense][]; - const paidLicenses = Object.entries(data.licenses.paidLicenses) as [GKLicenseType, GKLicense][]; - - let actual: Subscription['plan']['actual'] | undefined; - if (paidLicenses.length > 0) { - if (paidLicenses.length > 1) { - paidLicenses.sort( - (a, b) => - getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + - licenseStatusPriority(b[1].latestStatus) - - (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + - licenseStatusPriority(a[1].latestStatus)), - ); - } - - const [licenseType, license] = paidLicenses[0]; - actual = getSubscriptionPlan( - convertLicenseTypeToPlanId(licenseType), - isBundleLicenseType(licenseType), - license.organizationId, - new Date(license.latestStartDate), - new Date(license.latestEndDate), - ); - } - - if (actual == null) { - actual = getSubscriptionPlan( - SubscriptionPlanId.FreePlus, - false, - undefined, - data.user.firstGitLensCheckIn != null ? new Date(data.user.firstGitLensCheckIn) : undefined, - ); - } - - let effective: Subscription['plan']['effective'] | undefined; - if (effectiveLicenses.length > 0) { - if (effectiveLicenses.length > 1) { - effectiveLicenses.sort( - (a, b) => - getSubscriptionPlanPriority(convertLicenseTypeToPlanId(b[0])) + - licenseStatusPriority(b[1].latestStatus) - - (getSubscriptionPlanPriority(convertLicenseTypeToPlanId(a[0])) + - licenseStatusPriority(a[1].latestStatus)), - ); - } - - const [licenseType, license] = effectiveLicenses[0]; - effective = getSubscriptionPlan( - convertLicenseTypeToPlanId(licenseType), - isBundleLicenseType(licenseType), - license.organizationId, - new Date(license.latestStartDate), - new Date(license.latestEndDate), - ); - } - - if (effective == null) { - effective = { ...actual }; - } else if (getSubscriptionPlanPriority(actual.id) >= getSubscriptionPlanPriority(effective.id)) { - effective = { ...actual }; - } - - this.changeSubscription({ - ...this._subscription, - plan: { - actual: actual, - effective: effective, - }, - account: account, - }); - } - - private _sessionPromise: Promise | undefined; - private _session: AuthenticationSession | null | undefined; - - @gate() - @debug() - private async ensureSession(createIfNeeded: boolean, force?: boolean): Promise { - if (this._sessionPromise != null && this._session === undefined) { - void (await this._sessionPromise); - } - - if (!force && this._session != null) return this._session; - if (this._session === null && !createIfNeeded) return undefined; - - if (this._sessionPromise === undefined) { - this._sessionPromise = this.getOrCreateSession(createIfNeeded).then( - s => { - this._session = s; - this._sessionPromise = undefined; - return this._session; - }, - () => { - this._session = null; - this._sessionPromise = undefined; - return this._session; - }, - ); - } - - const session = await this._sessionPromise; - return session ?? undefined; - } - - @debug() - private async getOrCreateSession(createIfNeeded: boolean): Promise { - const scope = getLogScope(); - - let session: AuthenticationSession | null | undefined; - - try { - session = await authentication.getSession( - SubscriptionService.authenticationProviderId, - SubscriptionService.authenticationScopes, - { - createIfNone: createIfNeeded, - silent: !createIfNeeded, - }, - ); - } catch (ex) { - session = null; - - if (ex instanceof Error && ex.message.includes('User did not consent')) { - Logger.debug(scope, 'User declined authentication'); - await this.logoutCore(); - return null; - } - - Logger.error(ex, scope); - } - - // If we didn't find a session, check if we could migrate one from the GK auth provider - if (session === undefined) { - session = await this.container.subscriptionAuthentication.tryMigrateSession(); - } - - if (session == null) { - Logger.debug(scope, 'No valid session was found'); - await this.logoutCore(); - return session ?? null; - } - - try { - await this.checkInAndValidate(session, createIfNeeded); - } catch (ex) { - Logger.error(ex, scope); - debugger; - - this.container.telemetry.sendEvent('account/validation/failed', { - 'account.id': session.account.id, - exception: String(ex), - code: ex.original?.code, - statusCode: ex.statusCode, - }); - - Logger.debug(scope, `Account validation failed (${ex.statusCode ?? ex.original?.code})`); - - if (ex instanceof AccountValidationError) { - const name = session.account.label; - - // if ( - // (ex.statusCode != null && ex.statusCode < 500) || - // (ex.statusCode == null && (ex.original as any)?.code !== 'ENOTFOUND') - // ) { - if ( - (ex.original as any)?.code !== 'ENOTFOUND' && - ex.statusCode != null && - ex.statusCode < 500 && - ex.statusCode >= 400 - ) { - session = null; - await this.logoutCore(); - - if (createIfNeeded) { - const unauthorized = ex.statusCode === 401; - queueMicrotask(async () => { - const confirm: MessageItem = { title: 'Retry Sign In' }; - const result = await window.showErrorMessage( - `Unable to sign in to your (${name}) GitLens+ account. Please try again. If this issue persists, please contact support.${ - unauthorized ? '' : ` Error=${ex.message}` - }`, - confirm, - ); - - if (result === confirm) { - void this.loginOrSignUp(); - } - }); - } - } else { - session = session ?? null; - - // if ((ex.original as any)?.code !== 'ENOTFOUND') { - // void window.showErrorMessage( - // `Unable to sign in to your (${name}) GitLens+ account right now. Please try again in a few minutes. If this issue persists, please contact support. Error=${ex.message}`, - // 'OK', - // ); - // } - } - } - } - - return session; - } - - @debug() - private changeSubscription( - subscription: Optional | undefined, - silent: boolean = false, - ): void { - if (subscription == null) { - subscription = { - plan: { - actual: getSubscriptionPlan(SubscriptionPlanId.Free, false, undefined), - effective: getSubscriptionPlan(SubscriptionPlanId.Free, false, undefined), - }, - account: undefined, - state: SubscriptionState.Free, - }; - } - - // Check if the preview has expired, if not apply it - if (subscription.previewTrial != null && (getTimeRemaining(subscription.previewTrial.expiresOn) ?? 0) > 0) { - subscription = { - ...subscription, - plan: { - ...subscription.plan, - effective: getSubscriptionPlan( - SubscriptionPlanId.Pro, - false, - undefined, - new Date(subscription.previewTrial.startedOn), - new Date(subscription.previewTrial.expiresOn), - ), - }, - }; - } - - // If the effective plan has expired, then replace it with the actual plan - if (isSubscriptionExpired(subscription)) { - subscription = { - ...subscription, - plan: { - ...subscription.plan, - effective: subscription.plan.actual, - }, - }; - } - - subscription.state = computeSubscriptionState(subscription); - assertSubscriptionState(subscription); - - const previous = this._subscription as typeof this._subscription | undefined; // Can be undefined here, since we call this in the constructor - // Check the previous and new subscriptions are exactly the same - const matches = previous != null && JSON.stringify(previous) === JSON.stringify(subscription); - - // If the previous and new subscriptions are exactly the same, kick out - if (matches) return; - - queueMicrotask(() => { - let data = flattenSubscription(subscription); - this.container.telemetry.setGlobalAttributes(data); - - data = { - ...data, - ...(!matches ? flattenSubscription(previous, 'previous') : {}), - }; - - this.container.telemetry.sendEvent(previous == null ? 'subscription' : 'subscription/changed', data); - }); - - void this.storeSubscription(subscription); - - this._subscription = subscription; - this._etag = Date.now(); - - setTimeout(() => { - if ( - subscription?.account != null && - subscription.plan.actual.id === SubscriptionPlanId.Pro && - !subscription.plan.actual.bundle && - new Date(subscription.plan.actual.startedOn) >= new Date('2022-02-28T00:00:00.000Z') && - new Date(subscription.plan.actual.startedOn) <= new Date('2022-04-31T00:00:00.000Z') - ) { - showRenewalDiscountNotification(this.container); - } - }, 5000); - - if (!silent) { - this.updateContext(); - - if (previous != null) { - this._onDidChange.fire({ current: subscription, previous: previous, etag: this._etag }); - } - } - } - - private getStoredSubscription(): Subscription | undefined { - const storedSubscription = this.container.storage.get('premium:subscription'); - - const subscription = storedSubscription?.data; - if (subscription != null) { - // Migrate the plan names to the latest names - (subscription.plan.actual as Mutable).name = getSubscriptionPlanName( - subscription.plan.actual.id, - ); - (subscription.plan.effective as Mutable).name = getSubscriptionPlanName( - subscription.plan.effective.id, - ); - } - - return subscription; - } - - private async storeSubscription(subscription: Subscription): Promise { - return this.container.storage.store('premium:subscription', { - v: 1, - data: subscription, - }); - } - - private _cancellationSource: CancellationTokenSource | undefined; - private _updateAccessContextDebounced: Deferrable | undefined; - - private updateContext(): void { - this._updateAccessContextDebounced?.cancel(); - if (this._updateAccessContextDebounced == null) { - this._updateAccessContextDebounced = debounce(this.updateAccessContext.bind(this), 500); - } - - if (this._cancellationSource != null) { - this._cancellationSource.cancel(); - this._cancellationSource.dispose(); - } - this._cancellationSource = new CancellationTokenSource(); - - void this._updateAccessContextDebounced(this._cancellationSource.token); - this.updateStatusBar(); - - const { - plan: { actual }, - state, - } = this._subscription; - - void setContext(ContextKeys.Plus, actual.id != SubscriptionPlanId.Free ? actual.id : undefined); - void setContext(ContextKeys.PlusState, state); - } - - private async updateAccessContext(cancellation: CancellationToken): Promise { - let allowed: boolean | 'mixed' = false; - // For performance reasons, only check if we have any repositories - if (this.container.git.repositoryCount !== 0) { - ({ allowed } = await this.container.git.access()); - if (cancellation.isCancellationRequested) return; - } - - const plusFeatures = configuration.get('plusFeatures.enabled') ?? true; - - let disallowedRepos: string[] | undefined; - - if (!plusFeatures && allowed === 'mixed') { - disallowedRepos = []; - for (const repo of this.container.git.repositories) { - if (repo.closed) continue; - - const access = await this.container.git.access(undefined, repo.uri); - if (cancellation.isCancellationRequested) return; - - if (!access.allowed) { - disallowedRepos.push(repo.uri.toString()); - } - } - } - - void setContext(ContextKeys.PlusEnabled, Boolean(allowed) || plusFeatures); - void setContext(ContextKeys.PlusRequired, allowed === false); - void setContext(ContextKeys.PlusDisallowedRepos, disallowedRepos); - } - - private updateStatusBar(): void { - const { - account, - plan: { effective }, - } = this._subscription; - - if (effective.id === SubscriptionPlanId.Free) { - this._statusBarSubscription?.dispose(); - this._statusBarSubscription = undefined; - return; - } - - const trial = isSubscriptionTrial(this._subscription); - if (!trial && account?.verified !== false) { - this._statusBarSubscription?.dispose(); - this._statusBarSubscription = undefined; - return; - } - - if (this._statusBarSubscription == null) { - this._statusBarSubscription = window.createStatusBarItem( - 'gitlens.plus.subscription', - StatusBarAlignment.Left, - 1, - ); - } - - this._statusBarSubscription.name = 'GitLens+ Subscription'; - this._statusBarSubscription.command = Commands.ShowHomeView; - - if (account?.verified === false) { - this._statusBarSubscription.text = `$(warning) ${effective.name} (Unverified)`; - this._statusBarSubscription.backgroundColor = new ThemeColor('statusBarItem.warningBackground'); - this._statusBarSubscription.tooltip = new MarkdownString( - trial - ? `**Please verify your email**\n\nBefore you can start your **${effective.name}** trial, please verify your email address.\n\nClick for details` - : `**Please verify your email**\n\nBefore you can also use GitLens+ features on private repos, please verify your email address.\n\nClick for details`, - true, - ); - } else { - const remaining = getSubscriptionTimeRemaining(this._subscription, 'days'); - - this._statusBarSubscription.text = `${effective.name} (Trial)`; - this._statusBarSubscription.tooltip = new MarkdownString( - `You have ${pluralize('day', remaining ?? 0)} left in your **${ - effective.name - }** trial, which gives you additional access to GitLens+ features on private repos.\n\nClick for details`, - true, - ); - } - - this._statusBarSubscription.show(); - } -} - -function flattenSubscription(subscription: Optional | undefined, prefix?: string) { - if (subscription == null) return {}; - - return { - ...flatten(subscription.account, { - arrays: 'join', - prefix: `${prefix ? `${prefix}.` : ''}account`, - skipPaths: ['name', 'email'], - skipNulls: true, - stringify: true, - }), - ...flatten(subscription.plan, { - prefix: `${prefix ? `${prefix}.` : ''}subscription`, - skipPaths: ['actual.name', 'effective.name'], - skipNulls: true, - stringify: true, - }), - ...flatten(subscription.previewTrial, { - prefix: `${prefix ? `${prefix}.` : ''}subscription.previewTrial`, - skipPaths: ['actual.name', 'effective.name'], - skipNulls: true, - stringify: true, - }), - 'subscription.state': subscription.state, - }; -} - -function assertSubscriptionState(subscription: Optional): asserts subscription is Subscription {} - -interface GKLicenseInfo { - readonly user: GKUser; - readonly licenses: { - readonly paidLicenses: Record; - readonly effectiveLicenses: Record; - }; - readonly orgIds?: string[]; -} - -interface GKLicense { - readonly latestStatus: 'active' | 'canceled' | 'cancelled' | 'expired' | 'in_trial' | 'non_renewing' | 'trial'; - readonly latestStartDate: string; - readonly latestEndDate: string; - readonly organizationId: string | undefined; -} - -type GKLicenseType = - | 'gitlens-pro' - | 'gitlens-teams' - | 'gitlens-hosted-enterprise' - | 'gitlens-self-hosted-enterprise' - | 'gitlens-standalone-enterprise' - | 'bundle-pro' - | 'bundle-teams' - | 'bundle-hosted-enterprise' - | 'bundle-self-hosted-enterprise' - | 'bundle-standalone-enterprise'; - -interface GKUser { - readonly id: string; - readonly name: string; - readonly email: string; - readonly status: 'activated' | 'pending'; - readonly createdDate: string; - readonly firstGitLensCheckIn?: string; -} - -function convertLicenseTypeToPlanId(licenseType: GKLicenseType): SubscriptionPlanId { - switch (licenseType) { - case 'gitlens-pro': - case 'bundle-pro': - return SubscriptionPlanId.Pro; - case 'gitlens-teams': - case 'bundle-teams': - return SubscriptionPlanId.Teams; - case 'gitlens-hosted-enterprise': - case 'gitlens-self-hosted-enterprise': - case 'gitlens-standalone-enterprise': - case 'bundle-hosted-enterprise': - case 'bundle-self-hosted-enterprise': - case 'bundle-standalone-enterprise': - return SubscriptionPlanId.Enterprise; - default: - return SubscriptionPlanId.FreePlus; - } -} - -function isBundleLicenseType(licenseType: GKLicenseType): boolean { - switch (licenseType) { - case 'bundle-pro': - case 'bundle-teams': - case 'bundle-hosted-enterprise': - case 'bundle-self-hosted-enterprise': - case 'bundle-standalone-enterprise': - return true; - default: - return false; - } -} - -function licenseStatusPriority(status: GKLicense['latestStatus']): number { - switch (status) { - case 'active': - return 100; - case 'expired': - case 'cancelled': - return -100; - case 'in_trial': - case 'trial': - return 1; - case 'canceled': - case 'non_renewing': - return 0; - } -} - -function showRenewalDiscountNotification(container: Container): void { - if (container.storage.get('plus:renewalDiscountNotificationShown', false)) return; - - void container.storage.store('plus:renewalDiscountNotificationShown', true); - - void showMessage( - 'info', - '60% off your GitLens Pro renewal — as a thank you for being an early adopter of GitLens+. So there will be no change to your price for an additional year!', - undefined, - undefined, - ); -} diff --git a/src/plus/utils.ts b/src/plus/utils.ts new file mode 100644 index 0000000000000..3ba9dccda6deb --- /dev/null +++ b/src/plus/utils.ts @@ -0,0 +1,176 @@ +import type { MessageItem } from 'vscode'; +import { window } from 'vscode'; +import { urls } from '../constants'; +import type { Source } from '../constants.telemetry'; +import type { Container } from '../container'; +import { openUrl } from '../system/vscode/utils'; +import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from './gk/account/subscription'; + +export async function ensurePaidPlan( + container: Container, + title: string, + source: Source, + options?: { allowPreview?: boolean }, +): Promise { + while (true) { + const subscription = await container.subscription.getSubscription(); + if (subscription.account?.verified === false) { + const resend = { title: 'Resend Email' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nYou must verify your email before you can continue.`, + { modal: true }, + resend, + cancel, + ); + + if (result === resend) { + if (await container.subscription.resendVerification(source)) { + continue; + } + } + + return false; + } + + const plan = subscription.plan.effective.id; + if (isSubscriptionPaidPlan(plan)) break; + + if (options?.allowPreview && subscription.account == null && !isSubscriptionPreviewTrialExpired(subscription)) { + const startTrial = { title: 'Continue' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nDo you want to continue to get immediate access to preview local Pro features for 3 days?`, + { modal: true }, + startTrial, + cancel, + ); + + if (result !== startTrial) return false; + + void container.subscription.startPreviewTrial(source); + break; + } else if (subscription.account == null) { + const signUp = { title: 'Start Pro Trial' }; + const signIn = { title: 'Sign In' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nDo you want to start your free 7-day Pro trial for full access to Pro features?`, + { modal: true }, + signUp, + signIn, + cancel, + ); + + if (result === signUp || result === signIn) { + if (await container.subscription.loginOrSignUp(result === signUp, source)) { + continue; + } + } + } else { + const upgrade = { title: 'Upgrade to Pro' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nDo you want to upgrade for full access to Pro features?`, + { modal: true }, + upgrade, + cancel, + ); + + if (result === upgrade) { + void container.subscription.upgrade(source); + } + } + + return false; + } + + return true; +} + +export async function ensureAccount(container: Container, title: string, source: Source): Promise { + while (true) { + const subscription = await container.subscription.getSubscription(); + if (subscription.account?.verified === false) { + const resend = { title: 'Resend Email' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nYou must verify your email before you can continue.`, + { modal: true }, + resend, + cancel, + ); + + if (result === resend) { + if (await container.subscription.resendVerification(source)) { + continue; + } + } + + return false; + } + + if (subscription.account != null) break; + + const signUp = { title: 'Sign Up' }; + const signIn = { title: 'Sign In' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nSign up for access to Pro features and our DevEx platform, or sign in`, + { modal: true }, + signUp, + signIn, + cancel, + ); + + if (result === signIn) { + if (await container.subscription.loginOrSignUp(false, source)) { + continue; + } + } else if (result === signUp) { + if (await container.subscription.loginOrSignUp(true, source)) { + continue; + } + } + + return false; + } + + return true; +} + +export async function confirmDraftStorage(container: Container): Promise { + if (container.storage.get('confirm:draft:storage', false)) return true; + + while (true) { + const accept: MessageItem = { title: 'Continue' }; + const decline: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + const moreInfo: MessageItem = { title: 'Learn More' }; + const security: MessageItem = { title: 'Security' }; + const result = await window.showInformationMessage( + `Cloud Patches are securely stored by GitKraken and can be accessed by anyone with the link and a GitKraken account.`, + { modal: true }, + accept, + moreInfo, + security, + decline, + ); + + if (result === accept) { + void container.storage.store('confirm:draft:storage', true); + return true; + } + + if (result === security) { + void openUrl(urls.security); + continue; + } + + if (result === moreInfo) { + void openUrl(urls.cloudPatches); + continue; + } + + return false; + } +} diff --git a/src/plus/webviews/account/accountWebview.ts b/src/plus/webviews/account/accountWebview.ts new file mode 100644 index 0000000000000..4807fe512a8d9 --- /dev/null +++ b/src/plus/webviews/account/accountWebview.ts @@ -0,0 +1,130 @@ +import { Disposable, window } from 'vscode'; +import { getAvatarUriFromGravatarEmail } from '../../../avatars'; +import type { Container } from '../../../container'; +import type { Deferrable } from '../../../system/function'; +import { debounce } from '../../../system/function'; +import { registerCommand } from '../../../system/vscode/command'; +import type { WebviewHost, WebviewProvider } from '../../../webviews/webviewProvider'; +import type { Subscription } from '../../gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; +import type { State } from './protocol'; +import { DidChangeSubscriptionNotification } from './protocol'; + +export class AccountWebviewProvider implements WebviewProvider { + private readonly _disposable: Disposable; + + constructor( + private readonly container: Container, + private readonly host: WebviewHost, + ) { + this._disposable = Disposable.from(this.container.subscription.onDidChange(this.onSubscriptionChanged, this)); + } + + dispose() { + this._disposable.dispose(); + } + + private onSubscriptionChanged(e: SubscriptionChangeEvent) { + void this.notifyDidChangeSubscription(e.current); + } + + registerCommands(): Disposable[] { + return [ + registerCommand( + `${this.host.id}.refresh`, + async () => { + await this.validateSubscriptionCore(true); + await this.host.refresh(true); + }, + this, + ), + ]; + } + + includeBootstrap(): Promise { + return this.getState(); + } + + onReloaded(): void { + void this.notifyDidChangeSubscription(); + } + + onVisibilityChanged(visible: boolean): void { + if (!visible) { + this._validateSubscriptionDebounced?.cancel(); + return; + } + + queueMicrotask(() => void this.validateSubscription()); + } + + onWindowFocusChanged(focused: boolean): void { + if (!focused || !this.host.visible) { + this._validateSubscriptionDebounced?.cancel(); + return; + } + + queueMicrotask(() => void this.validateSubscription()); + } + + private async getSubscription(subscription?: Subscription) { + const sub = subscription ?? (await this.container.subscription.getSubscription(true)); + + let avatar; + if (sub.account?.email) { + avatar = getAvatarUriFromGravatarEmail(sub.account.email, 34).toString(); + } else { + avatar = `${this.host.getWebRoot() ?? ''}/media/gitlens-logo.webp`; + } + + return { + subscription: sub, + avatar: avatar, + organizationsCount: ((await this.container.organizations.getOrganizations()) ?? []).length, + }; + } + + private async getState(subscription?: Subscription): Promise { + const subscriptionResult = await this.getSubscription(subscription); + + return { + ...this.host.baseWebviewState, + webroot: this.host.getWebRoot(), + subscription: subscriptionResult.subscription, + avatar: subscriptionResult.avatar, + organizationsCount: subscriptionResult.organizationsCount, + }; + } + + private notifyDidChangeSubscription(subscription?: Subscription) { + return window.withProgress({ location: { viewId: this.host.id } }, async () => { + const sub = await this.getSubscription(subscription); + return this.host.notify(DidChangeSubscriptionNotification, { + ...sub, + }); + }); + } + + private _validateSubscriptionDebounced: Deferrable | undefined = + undefined; + + private async validateSubscription(): Promise { + if (this._validateSubscriptionDebounced == null) { + this._validateSubscriptionDebounced = debounce(this.validateSubscriptionCore, 1000); + } + + await this._validateSubscriptionDebounced(); + } + + private _validating: Promise | undefined; + private async validateSubscriptionCore(force?: boolean) { + if (this._validating == null || force) { + this._validating = this.container.subscription.validate({ force: force }); + try { + await this._validating; + } finally { + this._validating = undefined; + } + } + } +} diff --git a/src/plus/webviews/account/protocol.ts b/src/plus/webviews/account/protocol.ts new file mode 100644 index 0000000000000..3ba7ea04a1983 --- /dev/null +++ b/src/plus/webviews/account/protocol.ts @@ -0,0 +1,24 @@ +import type { IpcScope, WebviewState } from '../../../webviews/protocol'; +import { IpcNotification } from '../../../webviews/protocol'; +import type { Subscription } from '../../gk/account/subscription'; + +export const scope: IpcScope = 'account'; + +export interface State extends WebviewState { + webroot?: string; + subscription: Subscription; + avatar?: string; + organizationsCount?: number; +} + +// NOTIFICATIONS + +export interface DidChangeSubscriptionParams { + subscription: Subscription; + avatar?: string; + organizationsCount?: number; +} +export const DidChangeSubscriptionNotification = new IpcNotification( + scope, + 'subscription/didChange', +); diff --git a/src/plus/webviews/account/registration.ts b/src/plus/webviews/account/registration.ts new file mode 100644 index 0000000000000..ff16be8862fad --- /dev/null +++ b/src/plus/webviews/account/registration.ts @@ -0,0 +1,24 @@ +import type { WebviewsController } from '../../../webviews/webviewsController'; +import type { State } from './protocol'; + +export function registerAccountWebviewView(controller: WebviewsController) { + return controller.registerWebviewView( + { + id: 'gitlens.views.account', + fileName: 'account.html', + title: 'GitKraken Account', + contextKeyPrefix: `gitlens:webviewView:account`, + trackingFeature: 'accountView', + plusFeature: false, + webviewHostOptions: { + retainContextWhenHidden: false, + }, + }, + async (container, host) => { + const { AccountWebviewProvider } = await import( + /* webpackChunkName: "webview-account" */ './accountWebview' + ); + return new AccountWebviewProvider(container, host); + }, + ); +} diff --git a/src/plus/webviews/focus/focusWebview.ts b/src/plus/webviews/focus/focusWebview.ts index 6f4986b1b2f22..bb41b254b315a 100644 --- a/src/plus/webviews/focus/focusWebview.ts +++ b/src/plus/webviews/focus/focusWebview.ts @@ -1,178 +1,624 @@ -import { Disposable } from 'vscode'; -import { Commands, ContextKeys } from '../../../constants'; +import { EntityIdentifierUtils } from '@gitkraken/provider-apis'; +import { Disposable, Uri, window } from 'vscode'; +import type { GHPRPullRequest } from '../../../commands/ghpr/openOrCreateWorktree'; +import { Commands } from '../../../constants.commands'; import type { Container } from '../../../container'; -import { setContext } from '../../../context'; +import type { FeatureAccess, RepoFeatureAccess } from '../../../features'; import { PlusFeatures } from '../../../features'; +import { add as addRemote } from '../../../git/actions/remote'; +import * as RepoActions from '../../../git/actions/repository'; +import type { GitBranch } from '../../../git/models/branch'; +import { getLocalBranchByUpstream } from '../../../git/models/branch'; import type { SearchedIssue } from '../../../git/models/issue'; import { serializeIssue } from '../../../git/models/issue'; -import type { SearchedPullRequest } from '../../../git/models/pullRequest'; +import type { PullRequestShape, SearchedPullRequest } from '../../../git/models/pullRequest'; import { PullRequestMergeableState, PullRequestReviewDecision, serializePullRequest, } from '../../../git/models/pullRequest'; +import { createReference } from '../../../git/models/reference'; import type { GitRemote } from '../../../git/models/remote'; import type { Repository, RepositoryChangeEvent } from '../../../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; -import type { RichRemoteProvider } from '../../../git/remotes/richRemoteProvider'; -import type { Subscription } from '../../../subscription'; -import { SubscriptionState } from '../../../subscription'; -import { registerCommand } from '../../../system/command'; -import { WebviewBase } from '../../../webviews/webviewBase'; -import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; -import { ensurePlusFeaturesEnabled } from '../../subscription/utils'; -import type { State } from './protocol'; -import { DidChangeStateNotificationType, DidChangeSubscriptionNotificationType } from './protocol'; +import type { GitWorktree } from '../../../git/models/worktree'; +import { getWorktreeForBranch } from '../../../git/models/worktree'; +import { parseGitRemoteUrl } from '../../../git/parsers/remoteParser'; +import type { RemoteProvider } from '../../../git/remotes/remoteProvider'; +import { debug } from '../../../system/decorators/log'; +import { Logger } from '../../../system/logger'; +import { getLogScope } from '../../../system/logger.scope'; +import { PageableResult } from '../../../system/paging'; +import { getSettledValue } from '../../../system/promise'; +import { executeCommand } from '../../../system/vscode/command'; +import type { IpcMessage } from '../../../webviews/protocol'; +import type { WebviewHost, WebviewProvider } from '../../../webviews/webviewProvider'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; +import { getEntityIdentifierInput } from '../../integrations/providers/utils'; +import type { EnrichableItem, EnrichedItem } from '../../launchpad/enrichmentService'; +import { convertRemoteProviderToEnrichProvider } from '../../launchpad/enrichmentService'; +import type { ShowInCommitGraphCommandArgs } from '../graph/protocol'; +import type { + OpenBranchParams, + OpenWorktreeParams, + PinIssueParams, + PinPrParams, + SnoozeIssueParams, + SnoozePrParams, + State, + SwitchToBranchParams, +} from './protocol'; +import { + DidChangeNotification, + OpenBranchCommand, + OpenWorktreeCommand, + PinIssueCommand, + PinPRCommand, + SnoozeIssueCommand, + SnoozePRCommand, + SwitchToBranchCommand, +} from './protocol'; interface RepoWithRichRemote { repo: Repository; - remote: GitRemote; + remote: GitRemote; isConnected: boolean; isGitHub: boolean; } -export class FocusWebview extends WebviewBase { - private _bootstrapping = true; - private _pullRequests: SearchedPullRequest[] = []; - private _issues: SearchedIssue[] = []; +interface SearchedPullRequestWithRemote extends SearchedPullRequest { + repoAndRemote: RepoWithRichRemote; + branch?: GitBranch; + hasLocalBranch?: boolean; + isCurrentBranch?: boolean; + hasWorktree?: boolean; + isCurrentWorktree?: boolean; + rank: number; + enriched?: EnrichedItem[]; +} + +interface SearchedIssueWithRank extends SearchedIssue { + repoAndRemote: RepoWithRichRemote; + rank: number; + enriched?: EnrichedItem[]; +} + +export class FocusWebviewProvider implements WebviewProvider { + private _pullRequests: SearchedPullRequestWithRemote[] = []; + private _issues: SearchedIssueWithRank[] = []; + private _discovering: Promise | undefined; + private readonly _disposable: Disposable; + private _etag?: number; private _etagSubscription?: number; private _repositoryEventsDisposable?: Disposable; private _repos?: RepoWithRichRemote[]; - - constructor(container: Container) { - super( - container, - 'gitlens.focus', - 'focus.html', - 'images/gitlens-icon.png', - 'Focus View', - `${ContextKeys.WebviewPrefix}focus`, - 'focusWebview', - Commands.ShowFocusPage, + private _enrichedItems?: EnrichedItem[]; + + constructor( + private readonly container: Container, + private readonly host: WebviewHost, + ) { + this._disposable = Disposable.from( + this.container.subscription.onDidChange(this.onSubscriptionChanged, this), + this.container.git.onDidChangeRepositories(async () => { + if (this._etag !== this.container.git.etag) { + if (this._discovering != null) { + this._etag = await this._discovering; + if (this._etag === this.container.git.etag) return; + } + + void this.host.refresh(true); + } + }), ); + } + + dispose() { + if (this.enrichmentExpirationTimeout != null) { + clearTimeout(this.enrichmentExpirationTimeout); + this.enrichmentExpirationTimeout = undefined; + } + this._disposable.dispose(); + } + + onMessageReceived(e: IpcMessage) { + switch (true) { + case OpenBranchCommand.is(e): + void this.onOpenBranch(e.params); + break; + + case SwitchToBranchCommand.is(e): + void this.onSwitchBranch(e.params); + break; + + case OpenWorktreeCommand.is(e): + void this.onOpenWorktree(e.params); + break; + + case SnoozePRCommand.is(e): + void this.onSnoozePr(e.params); + break; + + case PinPRCommand.is(e): + void this.onPinPr(e.params); + break; + + case SnoozeIssueCommand.is(e): + void this.onSnoozeIssue(e.params); + break; + + case PinIssueCommand.is(e): + void this.onPinIssue(e.params); + break; + } + } + + @debug({ args: false }) + private async onPinIssue({ issue, pin }: PinIssueParams) { + const issueWithRemote = this._issues?.find(r => r.issue.nodeId === issue.nodeId); + if (issueWithRemote == null) return; + + if (pin) { + await this.container.enrichments.unpinItem(pin); + this._enrichedItems = this._enrichedItems?.filter(e => e.id !== pin); + issueWithRemote.enriched = issueWithRemote.enriched?.filter(e => e.id !== pin); + } else { + const focusItem: EnrichableItem = { + type: 'issue', + id: EntityIdentifierUtils.encode(getEntityIdentifierInput(issueWithRemote.issue)), + provider: convertRemoteProviderToEnrichProvider(issueWithRemote.repoAndRemote.remote.provider), + url: issueWithRemote.issue.url, + }; + const enrichedItem = await this.container.enrichments.pinItem(focusItem); + if (enrichedItem == null) return; + if (this._enrichedItems == null) { + this._enrichedItems = []; + } + this._enrichedItems.push(enrichedItem); + if (issueWithRemote.enriched == null) { + issueWithRemote.enriched = []; + } + issueWithRemote.enriched.push(enrichedItem); + } + + void this.notifyDidChangeState(); + } + + @debug({ args: false }) + private async onSnoozeIssue({ issue, snooze, expiresAt }: SnoozeIssueParams) { + const issueWithRemote = this._issues?.find(r => r.issue.nodeId === issue.nodeId); + if (issueWithRemote == null) return; + + if (snooze) { + await this.container.enrichments.unsnoozeItem(snooze); + this._enrichedItems = this._enrichedItems?.filter(e => e.id !== snooze); + issueWithRemote.enriched = issueWithRemote.enriched?.filter(e => e.id !== snooze); + } else { + const focusItem: EnrichableItem = { + type: 'issue', + id: EntityIdentifierUtils.encode(getEntityIdentifierInput(issueWithRemote.issue)), + provider: convertRemoteProviderToEnrichProvider(issueWithRemote.repoAndRemote.remote.provider), + url: issueWithRemote.issue.url, + }; + if (expiresAt != null) { + focusItem.expiresAt = expiresAt; + } + const enrichedItem = await this.container.enrichments.snoozeItem(focusItem); + if (enrichedItem == null) return; + if (this._enrichedItems == null) { + this._enrichedItems = []; + } + this._enrichedItems.push(enrichedItem); + if (issueWithRemote.enriched == null) { + issueWithRemote.enriched = []; + } + issueWithRemote.enriched.push(enrichedItem); + } + + void this.notifyDidChangeState(); + } + + @debug({ args: false }) + private async onPinPr({ pullRequest, pin }: PinPrParams) { + const prWithRemote = this._pullRequests?.find(r => r.pullRequest.nodeId === pullRequest.nodeId); + if (prWithRemote == null) return; + + if (pin) { + await this.container.enrichments.unpinItem(pin); + this._enrichedItems = this._enrichedItems?.filter(e => e.id !== pin); + prWithRemote.enriched = prWithRemote.enriched?.filter(e => e.id !== pin); + } else { + const focusItem: EnrichableItem = { + type: 'pr', + id: EntityIdentifierUtils.encode(getEntityIdentifierInput(prWithRemote.pullRequest)), + provider: convertRemoteProviderToEnrichProvider(prWithRemote.repoAndRemote.remote.provider), + url: prWithRemote.pullRequest.url, + }; + const enrichedItem = await this.container.enrichments.pinItem(focusItem); + if (enrichedItem == null) return; + if (this._enrichedItems == null) { + this._enrichedItems = []; + } + this._enrichedItems.push(enrichedItem); + if (prWithRemote.enriched == null) { + prWithRemote.enriched = []; + } + prWithRemote.enriched.push(enrichedItem); + } - this.disposables.push(this.container.subscription.onDidChange(this.onSubscriptionChanged, this)); - this.disposables.push(this.container.git.onDidChangeRepositories(() => void this.refresh(true))); + void this.notifyDidChangeState(); } - protected override registerCommands(): Disposable[] { - return [registerCommand(Commands.RefreshFocus, () => this.refresh(true))]; + @debug({ args: false }) + private async onSnoozePr({ pullRequest, snooze, expiresAt }: SnoozePrParams) { + const prWithRemote = this._pullRequests?.find(r => r.pullRequest.nodeId === pullRequest.nodeId); + if (prWithRemote == null) return; + + if (snooze) { + await this.container.enrichments.unsnoozeItem(snooze); + this._enrichedItems = this._enrichedItems?.filter(e => e.id !== snooze); + prWithRemote.enriched = prWithRemote.enriched?.filter(e => e.id !== snooze); + } else { + const focusItem: EnrichableItem = { + type: 'pr', + id: EntityIdentifierUtils.encode(getEntityIdentifierInput(prWithRemote.pullRequest)), + provider: convertRemoteProviderToEnrichProvider(prWithRemote.repoAndRemote.remote.provider), + url: prWithRemote.pullRequest.url, + }; + if (expiresAt != null) { + focusItem.expiresAt = expiresAt; + } + const enrichedItem = await this.container.enrichments.snoozeItem(focusItem); + if (enrichedItem == null) return; + if (this._enrichedItems == null) { + this._enrichedItems = []; + } + this._enrichedItems.push(enrichedItem); + if (prWithRemote.enriched == null) { + prWithRemote.enriched = []; + } + prWithRemote.enriched.push(enrichedItem); + } + + void this.notifyDidChangeState(); } - protected override onFocusChanged(focused: boolean): void { - if (focused) { - // If we are becoming focused, delay it a bit to give the UI time to update - setTimeout(() => void setContext(ContextKeys.FocusFocused, focused), 0); + private findSearchedPullRequest(pullRequest: PullRequestShape): SearchedPullRequestWithRemote | undefined { + return this._pullRequests?.find(r => r.pullRequest.id === pullRequest.id); + } + private async getRemoteBranch(searchedPullRequest: SearchedPullRequestWithRemote) { + const pullRequest = searchedPullRequest.pullRequest; + const repoAndRemote = searchedPullRequest.repoAndRemote; + const localUri = repoAndRemote.repo.uri; + + const repo = await repoAndRemote.repo.getCommonRepository(); + if (repo == null) { + void window.showWarningMessage( + `Unable to find main repository(${localUri.toString()}) for PR #${pullRequest.id}`, + ); return; } - void setContext(ContextKeys.FocusFocused, focused); + const rootOwner = pullRequest.refs!.base.owner; + const rootUri = Uri.parse(pullRequest.refs!.base.url); + const ref = pullRequest.refs!.head.branch; + + const remoteUri = Uri.parse(pullRequest.refs!.head.url); + const remoteUrl = remoteUri.toString(); + const [, remoteDomain, remotePath] = parseGitRemoteUrl(remoteUrl); + + let remote: GitRemote | undefined; + [remote] = await repo.getRemotes({ filter: r => r.matches(remoteDomain, remotePath) }); + let remoteBranchName; + if (remote != null) { + remoteBranchName = `${remote.name}/${ref}`; + // Ensure we have the latest from the remote + await this.container.git.fetch(repo.path, { remote: remote.name }); + } else { + const result = await window.showInformationMessage( + `Unable to find a remote for '${remoteUrl}'. Would you like to add a new remote?`, + { modal: true }, + { title: 'Add Remote' }, + { title: 'Cancel', isCloseAffordance: true }, + ); + if (result?.title !== 'Yes') return; + + const remoteOwner = pullRequest.refs!.head.owner; + await addRemote(repo, remoteOwner, remoteUrl, { + confirm: false, + fetch: true, + reveal: false, + }); + [remote] = await repo.getRemotes({ filter: r => r.url === remoteUrl }); + if (remote == null) return; + + remoteBranchName = `${remote.name}/${ref}`; + const rootRepository = pullRequest.refs!.base.repo; + const localBranchName = `pr/${rootUri.toString() === remoteUri.toString() ? ref : remoteBranchName}`; + // Save the PR number in the branch config + // https://github.com/Microsoft/vscode-pull-request-github/blob/0c556c48c69a3df2f9cf9a45ed2c40909791b8ab/src/github/pullRequestGitHelper.ts#L18 + void this.container.git.setConfig( + repo.path, + `branch.${localBranchName}.github-pr-owner-number`, + `${rootOwner}#${rootRepository}#${pullRequest.id}`, + ); + } + + const reference = createReference(remoteBranchName, repo.path, { + refType: 'branch', + name: remoteBranchName, + remote: true, + }); + + return { + remote: remote, + reference: reference, + }; + } + + @debug({ args: false }) + private async onOpenBranch({ pullRequest }: OpenBranchParams) { + const prWithRemote = this.findSearchedPullRequest(pullRequest); + if (prWithRemote == null) return; + + const remoteBranch = await this.getRemoteBranch(prWithRemote); + if (remoteBranch == null) { + void window.showErrorMessage( + `Unable to find remote branch for '${prWithRemote.pullRequest.refs?.head.owner}:${prWithRemote.pullRequest.refs?.head.branch}'`, + ); + return; + } + + void executeCommand(Commands.ShowInCommitGraph, { ref: remoteBranch.reference }); + } + + @debug({ args: false }) + private async onSwitchBranch({ pullRequest }: SwitchToBranchParams) { + const prWithRemote = this.findSearchedPullRequest(pullRequest); + if (prWithRemote == null || prWithRemote.isCurrentBranch) return; + + if (prWithRemote.branch != null) { + return RepoActions.switchTo(prWithRemote.branch.repoPath, prWithRemote.branch); + } + + const remoteBranch = await this.getRemoteBranch(prWithRemote); + if (remoteBranch == null) { + void window.showErrorMessage( + `Unable to find remote branch for '${prWithRemote.pullRequest.refs?.head.owner}:${prWithRemote.pullRequest.refs?.head.branch}'`, + ); + return; + } + + return RepoActions.switchTo(remoteBranch.remote.repoPath, remoteBranch.reference); + } + + @debug({ args: false }) + private async onOpenWorktree({ pullRequest }: OpenWorktreeParams) { + const searchedPullRequestWithRemote = this.findSearchedPullRequest(pullRequest); + if (searchedPullRequestWithRemote?.repoAndRemote == null) { + return; + } + + const baseUri = Uri.parse(pullRequest.refs!.base.url); + const localUri = searchedPullRequestWithRemote.repoAndRemote.repo.uri; + return executeCommand(Commands.OpenOrCreateWorktreeForGHPR, { + base: { + repositoryCloneUrl: { + repositoryName: pullRequest.refs!.base.repo, + owner: pullRequest.refs!.base.owner, + url: baseUri, + }, + }, + githubRepository: { + rootUri: localUri, + }, + head: { + ref: pullRequest.refs!.head.branch, + sha: pullRequest.refs!.head.sha, + repositoryCloneUrl: { + repositoryName: pullRequest.refs!.head.repo, + owner: pullRequest.refs!.head.owner, + url: Uri.parse(pullRequest.refs!.head.url), + }, + }, + item: { + number: parseInt(pullRequest.id, 10), + }, + }); } - private async onSubscriptionChanged(e: SubscriptionChangeEvent) { + private onSubscriptionChanged(e: SubscriptionChangeEvent) { if (e.etag === this._etagSubscription) return; this._etagSubscription = e.etag; + this._access = undefined; + void this.notifyDidChangeState(); + } - const access = await this.container.git.access(PlusFeatures.Focus); - const { subscription, isPlus } = await this.getSubscription(access.subscription.current); - if (isPlus) { - void this.notifyDidChangeState(); + private _access: FeatureAccess | RepoFeatureAccess | undefined; + @debug() + private async getAccess(force?: boolean) { + if (force || this._access == null) { + this._access = await this.container.git.access(PlusFeatures.Launchpad); } - return this.notify(DidChangeSubscriptionNotificationType, { - subscription: subscription, - isPlus: isPlus, - }); + return this._access; } - private async getSubscription(subscription?: Subscription) { - const currentSubscription = subscription ?? (await this.container.subscription.getSubscription(true)); - const isPlus = ![ - SubscriptionState.Free, - SubscriptionState.FreePreviewTrialExpired, - SubscriptionState.FreePlusTrialExpired, - SubscriptionState.VerificationRequired, - ].includes(currentSubscription.state); + private enrichmentExpirationTimeout?: ReturnType; + private ensureEnrichmentExpirationCore(appliedEnrichments?: EnrichedItem[]) { + if (this.enrichmentExpirationTimeout != null) { + clearTimeout(this.enrichmentExpirationTimeout); + this.enrichmentExpirationTimeout = undefined; + } - return { - subscription: currentSubscription, - isPlus: isPlus, - }; + if (appliedEnrichments == null || appliedEnrichments.length === 0) return; + + const nowTime = Date.now(); + let expirableEnrichmentTime: number | undefined; + for (const item of appliedEnrichments) { + if (item.expiresAt == null) continue; + + const expiresAtTime = new Date(item.expiresAt).getTime(); + if ( + expirableEnrichmentTime == null || + (expiresAtTime > nowTime && expiresAtTime < expirableEnrichmentTime) + ) { + expirableEnrichmentTime = expiresAtTime; + } + } + + if (expirableEnrichmentTime == null) return; + const debounceTime = expirableEnrichmentTime + 1000 * 60 * 15; // 15 minutes + // find the item in appliedEnrichments with largest expiresAtTime that is less than the debounce time + expirableEnrichmentTime + for (const item of appliedEnrichments) { + if (item.expiresAt == null) continue; + + const expiresAtTime = new Date(item.expiresAt).getTime(); + if (expiresAtTime > expirableEnrichmentTime && expiresAtTime < debounceTime) { + expirableEnrichmentTime = expiresAtTime; + } + } + + const expiresTimeout = expirableEnrichmentTime - nowTime + 60000; + this.enrichmentExpirationTimeout = setTimeout(() => { + void this.notifyDidChangeState(true); + }, expiresTimeout); } - private async getState(deferState = false): Promise { - const { subscription, isPlus } = await this.getSubscription(); - if (!isPlus) { + @debug() + private async getState(force?: boolean, deferState?: boolean): Promise { + const baseState = this.host.baseWebviewState; + + this._etag = this.container.git.etag; + if (this.container.git.isDiscoveringRepositories) { + this._discovering = this.container.git.isDiscoveringRepositories.then(r => { + this._discovering = undefined; + return r; + }); + this._etag = await this._discovering; + } + + const access = await this.getAccess(force); + if (access.allowed !== true) { return { - isPlus: isPlus, - subscription: subscription, + ...baseState, + access: access, }; } - const allRichRepos = await this.getRichRepos(); + const allRichRepos = await this.getRichRepos(force); const githubRepos = filterGithubRepos(allRichRepos); const connectedRepos = filterUsableRepos(githubRepos); const hasConnectedRepos = connectedRepos.length > 0; - if (deferState || !hasConnectedRepos) { + if (!hasConnectedRepos) { return { - isPlus: isPlus, - subscription: subscription, - repos: (hasConnectedRepos ? connectedRepos : githubRepos).map(r => serializeRepoWithRichRemote(r)), + ...baseState, + access: access, + repos: githubRepos.map(r => serializeRepoWithRichRemote(r)), }; } - const prs = await this.getMyPullRequests(connectedRepos); - const serializedPrs = prs.map(pr => ({ - pullRequest: serializePullRequest(pr.pullRequest), - reasons: pr.reasons, - })); + const repos = connectedRepos.map(r => serializeRepoWithRichRemote(r)); - const issues = await this.getMyIssues(connectedRepos); - const serializedIssues = issues.map(issue => ({ - issue: serializeIssue(issue.issue), - reasons: issue.reasons, - })); + const statePromise = Promise.allSettled([ + this.getMyPullRequests(connectedRepos, force), + this.getMyIssues(connectedRepos, force), + this.getEnrichedItems(force), + ]); - return { - isPlus: isPlus, - subscription: subscription, - pullRequests: serializedPrs, - issues: serializedIssues, - repos: connectedRepos.map(r => serializeRepoWithRichRemote(r)), + const getStateCore = async () => { + const [prsResult, issuesResult, enrichedItems] = await statePromise; + + const appliedEnrichments: EnrichedItem[] = []; + const pullRequests = getSettledValue(prsResult)?.map(pr => { + const itemEnrichments = findEnrichedItems(pr, getSettledValue(enrichedItems)); + if (itemEnrichments != null) { + appliedEnrichments.push(...itemEnrichments); + } + + return { + pullRequest: serializePullRequest(pr.pullRequest), + reasons: pr.reasons, + isCurrentBranch: pr.isCurrentBranch ?? false, + isCurrentWorktree: pr.isCurrentWorktree ?? false, + hasWorktree: pr.hasWorktree ?? false, + hasLocalBranch: pr.hasLocalBranch ?? false, + enriched: serializeEnrichedItems(itemEnrichments), + rank: pr.rank, + }; + }); + + const issues = getSettledValue(issuesResult)?.map(issue => { + const itemEnrichments = findEnrichedItems(issue, getSettledValue(enrichedItems)); + if (itemEnrichments != null) { + appliedEnrichments.push(...itemEnrichments); + } + + return { + issue: serializeIssue(issue.issue), + reasons: issue.reasons, + enriched: serializeEnrichedItems(itemEnrichments), + rank: issue.rank, + }; + }); + + this.ensureEnrichmentExpirationCore(appliedEnrichments); + + return { + ...baseState, + access: access, + repos: repos, + pullRequests: pullRequests, + issues: issues, + }; }; - } - protected override async includeBootstrap(): Promise { - if (this._bootstrapping) { - const state = await this.getState(true); - if (state.isPlus) { - void this.notifyDidChangeState(); - } - return state; + if (deferState) { + queueMicrotask(async () => { + const state = await getStateCore(); + void this.host.notify(DidChangeNotification, { state: state }); + }); + + return { + ...baseState, + access: access, + repos: repos, + }; } - return this.getState(); + const state = await getStateCore(); + return state; + } + + async includeBootstrap(): Promise { + return this.getState(true, true); } + @debug() private async getRichRepos(force?: boolean): Promise { - if (this._repos == null || force === true) { + if (force || this._repos == null) { const repos = []; const disposables = []; for (const repo of this.container.git.openRepositories) { - const richRemote = await repo.getRichRemote(); - if (richRemote == null || repos.findIndex(repo => repo.remote === richRemote) > -1) { + const remoteWithIntegration = await repo.getBestRemoteWithIntegration({ includeDisconnected: true }); + if ( + remoteWithIntegration == null || + repos.findIndex(repo => repo.remote === remoteWithIntegration) > -1 + ) { continue; } disposables.push(repo.onDidChange(this.onRepositoryChanged, this)); + const integration = await this.container.integrations.getByRemote(remoteWithIntegration); + repos.push({ repo: repo, - remote: richRemote, - isConnected: await richRemote.provider.isConnected(), - isGitHub: richRemote.provider.name === 'GitHub', + remote: remoteWithIntegration, + isConnected: integration?.maybeConnected ?? (await integration?.isConnected()) ?? false, + isGitHub: remoteWithIntegration.provider.id === 'github', }); } if (this._repositoryEventsDisposable) { @@ -186,96 +632,245 @@ export class FocusWebview extends WebviewBase { return this._repos; } - private async onRepositoryChanged(e: RepositoryChangeEvent) { + private onRepositoryChanged(e: RepositoryChangeEvent) { if (e.changed(RepositoryChange.RemoteProviders, RepositoryChangeComparisonMode.Any)) { - await this.getRichRepos(true); - void this.notifyDidChangeState(); + void this.notifyDidChangeState(true); } } - private async getMyPullRequests(richRepos: RepoWithRichRemote[]): Promise { - const allPrs = []; - for (const { remote } of richRepos) { - const prs = await this.container.git.getMyPullRequests(remote); - if (prs == null) { - continue; - } - allPrs.push(...prs.filter(pr => pr.reasons.length > 0)); - } + @debug({ args: { 0: false } }) + private async getMyPullRequests( + richRepos: RepoWithRichRemote[], + force?: boolean, + ): Promise { + const scope = getLogScope(); + + if (force || this._pullRequests == null) { + const allPrs: SearchedPullRequestWithRemote[] = []; + + const branchesByRepo = new Map>(); + const worktreesByRepo = new Map(); + + const queries = richRepos.map( + r => [r, this.container.integrations.getMyPullRequestsForRemotes(r.remote)] as const, + ); + for (const [r, query] of queries) { + let prs; + try { + prs = await query; + } catch (ex) { + Logger.error(ex, scope, `Failed to get prs for '${r.remote.url}'`); + } - function getScore(pr: SearchedPullRequest) { - let score = 0; - if (pr.reasons.includes('authored')) { - score += 1000; - } else if (pr.reasons.includes('assigned')) { - score += 900; - } else if (pr.reasons.includes('review-requested')) { - score += 800; - } else if (pr.reasons.includes('mentioned')) { - score += 700; - } + if (prs?.error != null) { + Logger.error(prs.error, scope, `Failed to get prs for '${r.remote.url}'`); + continue; + } - if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.Approved) { - if (pr.pullRequest.mergeableState === PullRequestMergeableState.Mergeable) { - score += 100; - } else if (pr.pullRequest.mergeableState === PullRequestMergeableState.Conflicting) { - score += 90; - } else { - score += 80; + if (prs?.value == null) continue; + + for (const pr of prs.value) { + if (pr.reasons.length === 0) continue; + + const entry: SearchedPullRequestWithRemote = { + ...pr, + repoAndRemote: r, + isCurrentWorktree: false, + isCurrentBranch: false, + rank: getPrRank(pr), + }; + + const remoteBranchName = `${entry.pullRequest.refs!.head.owner}/${ + entry.pullRequest.refs!.head.branch + }`; // TODO@eamodio really need to check for upstream url rather than name + + let branches = branchesByRepo.get(entry.repoAndRemote.repo); + if (branches == null) { + branches = new PageableResult(paging => + entry.repoAndRemote.repo.getBranches(paging != null ? { paging: paging } : undefined), + ); + branchesByRepo.set(entry.repoAndRemote.repo, branches); + } + + let worktrees = worktreesByRepo.get(entry.repoAndRemote.repo); + if (worktrees == null) { + worktrees = await entry.repoAndRemote.repo.getWorktrees(); + worktreesByRepo.set(entry.repoAndRemote.repo, worktrees); + } + + const worktree = await getWorktreeForBranch( + entry.repoAndRemote.repo, + entry.pullRequest.refs!.head.branch, + remoteBranchName, + worktrees, + branches, + ); + + entry.hasWorktree = worktree != null; + entry.isCurrentWorktree = worktree?.opened === true; + + const branch = await getLocalBranchByUpstream(r.repo, remoteBranchName, branches); + if (branch) { + entry.branch = branch; + entry.hasLocalBranch = true; + entry.isCurrentBranch = branch.current; + } + + allPrs.push(entry); } - } else if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.ChangesRequested) { - score += 70; } - return score; - } - - this._pullRequests = allPrs.sort((a, b) => { - const scoreA = getScore(a); - const scoreB = getScore(b); + this._pullRequests = allPrs.sort((a, b) => { + const scoreA = a.rank; + const scoreB = b.rank; - if (scoreA === scoreB) { - return a.pullRequest.date.getTime() - b.pullRequest.date.getTime(); - } - return (scoreB ?? 0) - (scoreA ?? 0); - }); + if (scoreA === scoreB) { + return a.pullRequest.updatedDate.getTime() - b.pullRequest.updatedDate.getTime(); + } + return (scoreB ?? 0) - (scoreA ?? 0); + }); + } return this._pullRequests; } - private async getMyIssues(richRepos: RepoWithRichRemote[]): Promise { - const allIssues = []; - for (const { remote } of richRepos) { - const issues = await this.container.git.getMyIssues(remote); - if (issues == null) { - continue; + @debug({ args: { 0: false } }) + private async getMyIssues(richRepos: RepoWithRichRemote[], force?: boolean): Promise { + const scope = getLogScope(); + + if (force || this._pullRequests == null) { + const allIssues = []; + + const queries = richRepos.map( + r => [r, this.container.integrations.getMyIssuesForRemotes(r.remote)] as const, + ); + for (const [r, query] of queries) { + let issues; + try { + issues = await query; + } catch (ex) { + Logger.error(ex, scope, `Failed to get issues for '${r.remote.url}'`); + } + if (issues == null) continue; + + for (const issue of issues) { + if (issue.reasons.length === 0) continue; + + allIssues.push({ + ...issue, + repoAndRemote: r, + rank: 0, // getIssueRank(issue), + }); + } } - allIssues.push(...issues.filter(pr => pr.reasons.length > 0)); - } - this._issues = allIssues.sort((a, b) => b.issue.updatedDate.getTime() - a.issue.updatedDate.getTime()); + // this._issues = allIssues.sort((a, b) => { + // const scoreA = a.rank; + // const scoreB = b.rank; + + // if (scoreA === scoreB) { + // return b.issue.updatedDate.getTime() - a.issue.updatedDate.getTime(); + // } + // return (scoreB ?? 0) - (scoreA ?? 0); + // }); + + this._issues = allIssues.sort((a, b) => b.issue.updatedDate.getTime() - a.issue.updatedDate.getTime()); + } return this._issues; } - override async show(options?: { - preserveFocus?: boolean | undefined; - preserveVisibility?: boolean | undefined; - }): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; + @debug() + private async getEnrichedItems(force?: boolean): Promise { + // TODO needs cache invalidation + if (force || this._enrichedItems == null) { + const enrichedItems = await this.container.enrichments.get(); + this._enrichedItems = enrichedItems; + } + return this._enrichedItems; + } - return super.show(options); + private async notifyDidChangeState(force?: boolean, deferState?: boolean) { + void this.host.notify(DidChangeNotification, { state: await this.getState(force, deferState) }); } +} + +function findEnrichedItems( + item: SearchedPullRequestWithRemote | SearchedIssueWithRank, + enrichedItems?: EnrichedItem[], +) { + if (enrichedItems == null || enrichedItems.length === 0) { + item.enriched = undefined; + return; + } + + let result; + // TODO: filter by entity id, type, and gitRepositoryId + if ((item as SearchedPullRequestWithRemote).pullRequest != null) { + result = enrichedItems.filter(e => e.entityUrl === (item as SearchedPullRequestWithRemote).pullRequest.url); + } else { + result = enrichedItems.filter(e => e.entityUrl === (item as SearchedIssueWithRank).issue.url); + } + + if (result.length === 0) return; + + item.enriched = result; + + return result; +} - private async notifyDidChangeState() { - if (!this.visible) return; +function serializeEnrichedItems(enrichedItems: EnrichedItem[] | undefined) { + if (enrichedItems == null || enrichedItems.length === 0) return; - const state = await this.getState(); - this._bootstrapping = false; - void this.notify(DidChangeStateNotificationType, { state: state }); + return enrichedItems.map(enrichedItem => { + return { + id: enrichedItem.id, + type: enrichedItem.type, + expiresAt: enrichedItem.expiresAt, + }; + }); +} + +function getPrRank(pr: SearchedPullRequest) { + let score = 0; + if (pr.reasons.includes('authored')) { + score += 1000; + } else if (pr.reasons.includes('assigned')) { + score += 900; + } else if (pr.reasons.includes('review-requested')) { + score += 800; + } else if (pr.reasons.includes('mentioned')) { + score += 700; } + + if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.Approved) { + if (pr.pullRequest.mergeableState === PullRequestMergeableState.Mergeable) { + score += 100; + } else if (pr.pullRequest.mergeableState === PullRequestMergeableState.Conflicting) { + score += 90; + } else { + score += 80; + } + } else if (pr.pullRequest.reviewDecision === PullRequestReviewDecision.ChangesRequested) { + score += 70; + } + + return score; } +// function getIssueRank(issue: SearchedIssue) { +// let score = 0; +// if (issue.reasons.includes('authored')) { +// score += 1000; +// } else if (issue.reasons.includes('assigned')) { +// score += 900; +// } else if (issue.reasons.includes('mentioned')) { +// score += 700; +// } + +// return score; +// } + function filterGithubRepos(list: RepoWithRichRemote[]): RepoWithRichRemote[] { return list.filter(entry => entry.isGitHub); } diff --git a/src/plus/webviews/focus/protocol.ts b/src/plus/webviews/focus/protocol.ts index b922dc2b17a81..388a8758c88b2 100644 --- a/src/plus/webviews/focus/protocol.ts +++ b/src/plus/webviews/focus/protocol.ts @@ -1,19 +1,28 @@ +import type { FeatureAccess } from '../../../features'; import type { IssueShape } from '../../../git/models/issue'; import type { PullRequestShape } from '../../../git/models/pullRequest'; -import type { Subscription } from '../../../subscription'; -import { IpcNotificationType } from '../../../webviews/protocol'; +import type { IpcScope, WebviewState } from '../../../webviews/protocol'; +import { IpcCommand, IpcNotification } from '../../../webviews/protocol'; +import type { EnrichedItem } from '../../launchpad/enrichmentService'; -export type State = { - isPlus: boolean; - subscription: Subscription; +export const scope: IpcScope = 'focus'; + +export interface State extends WebviewState { + access: FeatureAccess; pullRequests?: PullRequestResult[]; issues?: IssueResult[]; - repos?: RepoWithRichProvider[]; - [key: string]: unknown; -}; + repos?: RepoWithIntegration[]; +} export interface SearchResultBase { reasons: string[]; + rank?: number; + enriched?: EnrichedItemSummary[]; +} + +export interface EnrichedItemSummary { + id: EnrichedItem['id']; + type: EnrichedItem['type']; } export interface IssueResult extends SearchResultBase { @@ -22,28 +31,64 @@ export interface IssueResult extends SearchResultBase { export interface PullRequestResult extends SearchResultBase { pullRequest: PullRequestShape; + isCurrentBranch: boolean; + isCurrentWorktree: boolean; + hasWorktree: boolean; + hasLocalBranch: boolean; } -export interface RepoWithRichProvider { +export interface RepoWithIntegration { repo: string; isGitHub: boolean; isConnected: boolean; } -export interface DidChangeStateNotificationParams { - state: State; +// COMMANDS + +export interface OpenWorktreeParams { + pullRequest: PullRequestShape; +} +export const OpenWorktreeCommand = new IpcCommand(scope, 'pr/openWorktree'); + +export interface OpenBranchParams { + pullRequest: PullRequestShape; +} +export const OpenBranchCommand = new IpcCommand(scope, 'pr/openBranch'); + +export interface SwitchToBranchParams { + pullRequest: PullRequestShape; } +export const SwitchToBranchCommand = new IpcCommand(scope, 'pr/switchToBranch'); -export const DidChangeStateNotificationType = new IpcNotificationType( - 'focus/state/didChange', - true, -); +export interface SnoozePrParams { + pullRequest: PullRequestShape; + expiresAt?: string; + snooze?: string; +} +export const SnoozePRCommand = new IpcCommand(scope, 'pr/snooze'); -export interface DidChangeSubscriptionParams { - subscription: Subscription; - isPlus: boolean; +export interface PinPrParams { + pullRequest: PullRequestShape; + pin?: string; +} +export const PinPRCommand = new IpcCommand(scope, 'pr/pin'); + +export interface SnoozeIssueParams { + issue: IssueShape; + expiresAt?: string; + snooze?: string; +} +export const SnoozeIssueCommand = new IpcCommand(scope, 'issue/snooze'); + +export interface PinIssueParams { + issue: IssueShape; + pin?: string; +} +export const PinIssueCommand = new IpcCommand(scope, 'issue/pin'); + +// NOTIFICATIONS + +export interface DidChangeParams { + state: State; } -export const DidChangeSubscriptionNotificationType = new IpcNotificationType( - 'graph/subscription/didChange', - true, -); +export const DidChangeNotification = new IpcNotification(scope, 'didChange', true); diff --git a/src/plus/webviews/focus/registration.ts b/src/plus/webviews/focus/registration.ts new file mode 100644 index 0000000000000..70a0f2df15daf --- /dev/null +++ b/src/plus/webviews/focus/registration.ts @@ -0,0 +1,41 @@ +import { Disposable, ViewColumn } from 'vscode'; +import { Commands } from '../../../constants.commands'; +import { registerCommand } from '../../../system/vscode/command'; +import { configuration } from '../../../system/vscode/configuration'; +import type { WebviewPanelsProxy, WebviewsController } from '../../../webviews/webviewsController'; +import type { State } from './protocol'; + +export function registerFocusWebviewPanel(controller: WebviewsController) { + return controller.registerWebviewPanel( + { id: Commands.ShowFocusPage, options: { preserveInstance: true } }, + { + id: 'gitlens.focus', + fileName: 'focus.html', + iconPath: 'images/gitlens-icon.png', + title: 'GitLens Launchpad', + contextKeyPrefix: `gitlens:webview:focus`, + trackingFeature: 'focusWebview', + plusFeature: true, + column: ViewColumn.Active, + webviewHostOptions: { + retainContextWhenHidden: true, + enableFindWidget: true, + }, + allowMultipleInstances: configuration.get('launchpad.allowMultiple'), + }, + async (container, host) => { + const { FocusWebviewProvider } = await import(/* webpackChunkName: "webview-focus" */ './focusWebview'); + return new FocusWebviewProvider(container, host); + }, + ); +} + +export function registerFocusWebviewCommands(panels: WebviewPanelsProxy) { + return Disposable.from( + registerCommand(`gitlens.launchpad.refresh`, () => void panels.getActiveInstance()?.refresh(true)), + registerCommand( + `gitlens.launchpad.split`, + () => void panels.splitActiveInstance({ preserveInstance: false, column: ViewColumn.Beside }), + ), + ); +} diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index d892b88dcfa2e..922bb62363542 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/src/plus/webviews/graph/graphWebview.ts @@ -1,195 +1,247 @@ -import type { - ColorTheme, - ConfigurationChangeEvent, - Event, - StatusBarItem, - WebviewOptions, - WebviewPanelOptions, -} from 'vscode'; -import { - CancellationTokenSource, - Disposable, - env, - EventEmitter, - MarkdownString, - StatusBarAlignment, - ViewColumn, - window, -} from 'vscode'; -import type { CreatePullRequestActionContext } from '../../../api/gitlens'; +import type { CancellationToken, ColorTheme, ConfigurationChangeEvent, Uri } from 'vscode'; +import { CancellationTokenSource, Disposable, env, window } from 'vscode'; +import type { CreatePullRequestActionContext, OpenPullRequestActionContext } from '../../../api/gitlens'; import { getAvatarUri } from '../../../avatars'; -import type { - CopyDeepLinkCommandArgs, - CopyMessageToClipboardCommandArgs, - CopyShaToClipboardCommandArgs, - OpenOnRemoteCommandArgs, - OpenPullRequestOnRemoteCommandArgs, - ShowCommitsInViewCommandArgs, -} from '../../../commands'; import { parseCommandContext } from '../../../commands/base'; -import type { Config } from '../../../configuration'; -import { configuration } from '../../../configuration'; -import { Commands, ContextKeys, CoreCommands, CoreGitCommands, GlyphChars } from '../../../constants'; +import type { CopyDeepLinkCommandArgs } from '../../../commands/copyDeepLink'; +import type { CopyMessageToClipboardCommandArgs } from '../../../commands/copyMessageToClipboard'; +import type { CopyShaToClipboardCommandArgs } from '../../../commands/copyShaToClipboard'; +import type { InspectCommandArgs } from '../../../commands/inspect'; +import type { OpenOnRemoteCommandArgs } from '../../../commands/openOnRemote'; +import type { OpenPullRequestOnRemoteCommandArgs } from '../../../commands/openPullRequestOnRemote'; +import type { CreatePatchCommandArgs } from '../../../commands/patches'; +import type { + Config, + GraphBranchesVisibility, + GraphMinimapMarkersAdditionalTypes, + GraphScrollMarkersAdditionalTypes, +} from '../../../config'; +import { GlyphChars } from '../../../constants'; +import { Commands } from '../../../constants.commands'; +import type { StoredGraphFilters, StoredGraphRefType } from '../../../constants.storage'; import type { Container } from '../../../container'; -import { getContext, onDidChangeContext } from '../../../context'; +import { CancellationError } from '../../../errors'; +import type { CommitSelectedEvent } from '../../../eventBus'; import { PlusFeatures } from '../../../features'; +import { executeGitCommand } from '../../../git/actions'; import * as BranchActions from '../../../git/actions/branch'; +import { + getOrderedComparisonRefs, + openAllChanges, + openAllChangesIndividually, + openAllChangesWithWorking, + openAllChangesWithWorkingIndividually, + openComparisonChanges, + openFiles, + openFilesAtRevision, + openOnlyChangedFiles, + showGraphDetailsView, + undoCommit, +} from '../../../git/actions/commit'; import * as ContributorActions from '../../../git/actions/contributor'; import * as RepoActions from '../../../git/actions/repository'; import * as StashActions from '../../../git/actions/stash'; import * as TagActions from '../../../git/actions/tag'; import * as WorktreeActions from '../../../git/actions/worktree'; import { GitSearchError } from '../../../git/errors'; -import { getBranchId, getBranchNameWithoutRemote, getRemoteNameFromBranchName } from '../../../git/models/branch'; +import { CommitFormatter } from '../../../git/formatters/commitFormatter'; +import type { GitBranch } from '../../../git/models/branch'; +import { + getBranchId, + getBranchNameWithoutRemote, + getDefaultBranchName, + getLocalBranchByUpstream, + getRemoteNameFromBranchName, + getTargetBranchName, +} from '../../../git/models/branch'; +import type { GitCommit } from '../../../git/models/commit'; +import { isStash } from '../../../git/models/commit'; +import { uncommitted } from '../../../git/models/constants'; import { GitContributor } from '../../../git/models/contributor'; -import type { GitGraph } from '../../../git/models/graph'; -import { GitGraphRowType } from '../../../git/models/graph'; +import type { GitGraph, GitGraphRowType } from '../../../git/models/graph'; +import { getGkProviderThemeIconString } from '../../../git/models/graph'; +import type { PullRequest } from '../../../git/models/pullRequest'; +import { getComparisonRefsForPullRequest, serializePullRequest } from '../../../git/models/pullRequest'; import type { GitBranchReference, + GitReference, GitRevisionReference, GitStashReference, GitTagReference, } from '../../../git/models/reference'; -import { GitReference, GitRevision } from '../../../git/models/reference'; +import { + createReference, + getReferenceFromBranch, + isGitReference, + isSha, + shortenRevision, +} from '../../../git/models/reference'; import { getRemoteIconUri } from '../../../git/models/remote'; import { RemoteResourceType } from '../../../git/models/remoteResource'; import type { RepositoryChangeEvent, RepositoryFileSystemChangeEvent } from '../../../git/models/repository'; -import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; +import { + isRepository, + Repository, + RepositoryChange, + RepositoryChangeComparisonMode, +} from '../../../git/models/repository'; +import { getWorktreesByBranch } from '../../../git/models/worktree'; import type { GitSearch } from '../../../git/search'; import { getSearchQueryComparisonKey } from '../../../git/search'; -import { RepositoryPicker } from '../../../quickpicks/repositoryPicker'; -import type { StoredGraphFilters, StoredGraphIncludeOnlyRef } from '../../../storage'; +import { splitGitCommitMessage } from '../../../git/utils/commit-utils'; +import { ReferencesQuickPickIncludes, showReferencePicker } from '../../../quickpicks/referencePicker'; +import { showRepositoryPicker } from '../../../quickpicks/repositoryPicker'; +import { gate } from '../../../system/decorators/gate'; +import { debug, log } from '../../../system/decorators/log'; +import type { Deferrable } from '../../../system/function'; +import { debounce, disposableInterval } from '../../../system/function'; +import { count, find, last, map } from '../../../system/iterable'; +import { updateRecordValue } from '../../../system/object'; +import { + getSettledValue, + pauseOnCancelOrTimeout, + pauseOnCancelOrTimeoutMapTuplePromise, +} from '../../../system/promise'; import { executeActionCommand, executeCommand, executeCoreCommand, - executeCoreGitCommand, registerCommand, -} from '../../../system/command'; -import { gate } from '../../../system/decorators/gate'; -import { debug } from '../../../system/decorators/log'; -import type { Deferrable } from '../../../system/function'; -import { debounce, disposableInterval, once } from '../../../system/function'; -import { find, last } from '../../../system/iterable'; -import { updateRecordValue } from '../../../system/object'; -import { getSettledValue } from '../../../system/promise'; -import { isDarkTheme, isLightTheme } from '../../../system/utils'; -import type { WebviewItemContext, WebviewItemGroupContext } from '../../../system/webview'; +} from '../../../system/vscode/command'; +import { configuration } from '../../../system/vscode/configuration'; +import { getContext, onDidChangeContext } from '../../../system/vscode/context'; +import type { OpenWorkspaceLocation } from '../../../system/vscode/utils'; +import { isDarkTheme, isLightTheme, openWorkspace } from '../../../system/vscode/utils'; import { isWebviewItemContext, isWebviewItemGroupContext, serializeWebviewItemContext } from '../../../system/webview'; -import type { BranchNode } from '../../../views/nodes/branchNode'; -import type { CommitFileNode } from '../../../views/nodes/commitFileNode'; -import type { CommitNode } from '../../../views/nodes/commitNode'; -import type { StashNode } from '../../../views/nodes/stashNode'; -import type { TagNode } from '../../../views/nodes/tagNode'; -import { RepositoryFolderNode } from '../../../views/nodes/viewNode'; -import type { IpcMessage, IpcMessageParams, IpcNotificationType } from '../../../webviews/protocol'; -import { onIpc } from '../../../webviews/protocol'; -import { WebviewBase } from '../../../webviews/webviewBase'; -import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; -import { arePlusFeaturesEnabled, ensurePlusFeaturesEnabled } from '../../subscription/utils'; +import { RepositoryFolderNode } from '../../../views/nodes/abstract/repositoryFolderNode'; +import type { IpcCallMessageType, IpcMessage, IpcNotification } from '../../../webviews/protocol'; +import type { WebviewHost, WebviewProvider, WebviewShowingArgs } from '../../../webviews/webviewProvider'; +import type { WebviewPanelShowCommandArgs, WebviewShowOptions } from '../../../webviews/webviewsController'; +import { isSerializedState } from '../../../webviews/webviewsController'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; +import type { ConnectionStateChangeEvent } from '../../integrations/integrationService'; import type { - DimMergeCommitsParams, - DismissBannerParams, + BranchState, + DidChangeRefsVisibilityParams, + DidGetCountParams, + DidGetRowHoverParams, + DidSearchParams, DoubleClickedParams, - EnsureRowParams, GetMissingAvatarsParams, GetMissingRefsMetadataParams, GetMoreRowsParams, + GraphBranchContextValue, GraphColumnConfig, GraphColumnName, GraphColumnsConfig, GraphColumnsSettings, + GraphCommitContextValue, GraphComponentConfig, + GraphContributorContextValue, GraphExcludedRef, GraphExcludeRefs, GraphExcludeTypes, GraphHostingServiceType, GraphIncludeOnlyRef, + GraphIncludeOnlyRefs, + GraphItemContext, + GraphItemGroupContext, + GraphItemRefContext, + GraphItemRefGroupContext, + GraphItemTypedContext, + GraphItemTypedContextValue, + GraphMinimapMarkerTypes, GraphMissingRefsMetadataType, + GraphPullRequestContextValue, GraphPullRequestMetadata, GraphRefMetadata, GraphRefMetadataType, GraphRepository, + GraphScrollMarkerTypes, GraphSelectedRows, + GraphStashContextValue, + GraphTagContextValue, GraphUpstreamMetadata, + GraphUpstreamStatusContextValue, GraphWorkingTreeStats, + OpenPullRequestDetailsParams, SearchOpenInViewParams, SearchParams, State, UpdateColumnsParams, - UpdateExcludeTypeParams, + UpdateExcludeTypesParams, UpdateGraphConfigurationParams, + UpdateIncludedRefsParams, UpdateRefsVisibilityParams, UpdateSelectionParams, } from './protocol'; import { - ChooseRepositoryCommandType, - DidChangeAvatarsNotificationType, - DidChangeColumnsNotificationType, - DidChangeGraphConfigurationNotificationType, - DidChangeNotificationType, - DidChangeRefsMetadataNotificationType, - DidChangeRefsVisibilityNotificationType, - DidChangeRowsNotificationType, - DidChangeSelectionNotificationType, - DidChangeSubscriptionNotificationType, - DidChangeWindowFocusNotificationType, - DidChangeWorkingTreeNotificationType, - DidEnsureRowNotificationType, - DidFetchNotificationType, - DidSearchNotificationType, - DimMergeCommitsCommandType, - DismissBannerCommandType, + ChooseRefRequest, + ChooseRepositoryCommand, + DidChangeAvatarsNotification, + DidChangeBranchStateNotification, + DidChangeColumnsNotification, + DidChangeGraphConfigurationNotification, + DidChangeNotification, + DidChangeRefsMetadataNotification, + DidChangeRefsVisibilityNotification, + DidChangeRepoConnectionNotification, + DidChangeRowsNotification, + DidChangeRowsStatsNotification, + DidChangeScrollMarkersNotification, + DidChangeSelectionNotification, + DidChangeSubscriptionNotification, + DidChangeWorkingTreeNotification, + DidFetchNotification, + DidSearchNotification, DoubleClickedCommandType, - EnsureRowCommandType, - GetMissingAvatarsCommandType, - GetMissingRefsMetadataCommandType, - GetMoreRowsCommandType, - GraphMinimapMarkerTypes, - GraphRefMetadataTypes, - GraphScrollMarkerTypes, - SearchCommandType, - SearchOpenInViewCommandType, + EnsureRowRequest, + GetCountsRequest, + GetMissingAvatarsCommand, + GetMissingRefsMetadataCommand, + GetMoreRowsCommand, + GetRowHoverRequest, + OpenPullRequestDetailsCommand, + SearchOpenInViewCommand, + SearchRequest, supportedRefMetadataTypes, - UpdateColumnsCommandType, - UpdateExcludeTypeCommandType, - UpdateGraphConfigurationCommandType, - UpdateIncludeOnlyRefsCommandType, - UpdateRefsVisibilityCommandType, - UpdateSelectionCommandType, + UpdateColumnsCommand, + UpdateExcludeTypesCommand, + UpdateGraphConfigurationCommand, + UpdateIncludedRefsCommand, + UpdateRefsVisibilityCommand, + UpdateSelectionCommand, } from './protocol'; - -export interface ShowInCommitGraphCommandArgs { - ref: GitReference; - preserveFocus?: boolean; -} - -export interface GraphSelectionChangeEvent { - readonly selection: GitRevisionReference[]; -} +import type { GraphWebviewShowingArgs } from './registration'; const defaultGraphColumnsSettings: GraphColumnsSettings = { - ref: { width: 150, isHidden: false }, - graph: { width: 150, isHidden: false }, - message: { width: 300, isHidden: false }, - author: { width: 130, isHidden: false }, - datetime: { width: 130, isHidden: false }, - sha: { width: 130, isHidden: false }, - changes: { width: 130, isHidden: true }, + ref: { width: 130, isHidden: false, order: 0 }, + graph: { width: 150, mode: undefined, isHidden: false, order: 1 }, + message: { width: 300, isHidden: false, order: 2 }, + author: { width: 130, isHidden: false, order: 3 }, + changes: { width: 200, isHidden: false, order: 4 }, + datetime: { width: 130, isHidden: false, order: 5 }, + sha: { width: 130, isHidden: false, order: 6 }, }; -export class GraphWebview extends WebviewBase { - private _onDidChangeSelection = new EventEmitter(); - get onDidChangeSelection(): Event { - return this._onDidChangeSelection.event; - } +const compactGraphColumnsSettings: GraphColumnsSettings = { + ref: { width: 32, isHidden: false }, + graph: { width: 150, mode: 'compact', isHidden: false }, + author: { width: 32, isHidden: false, order: 2 }, + message: { width: 500, isHidden: false, order: 3 }, + changes: { width: 200, isHidden: false, order: 4 }, + datetime: { width: 130, isHidden: true, order: 5 }, + sha: { width: 130, isHidden: false, order: 6 }, +}; + +type CancellableOperations = 'hover' | 'computeIncludedRefs' | 'search' | 'state'; +export class GraphWebviewProvider implements WebviewProvider { private _repository?: Repository; - get repository(): Repository | undefined { + private get repository(): Repository | undefined { return this._repository; } - - set repository(value: Repository | undefined) { + private set repository(value: Repository | undefined) { if (this._repository === value) { this.ensureRepositorySubscriptions(); return; @@ -199,307 +251,504 @@ export class GraphWebview extends WebviewBase { this.resetRepositoryState(); this.ensureRepositorySubscriptions(true); - if (this.isReady) { + if (this.host.ready) { this.updateState(); } } private _selection: readonly GitRevisionReference[] | undefined; - get selection(): readonly GitRevisionReference[] | undefined { - return this._selection; - } - - get activeSelection(): GitRevisionReference | undefined { + private get activeSelection(): GitRevisionReference | undefined { return this._selection?.[0]; } + private _cancellations = new Map(); + private _discovering: Promise | undefined; + private readonly _disposable: Disposable; + private _etag?: number; private _etagSubscription?: number; private _etagRepository?: number; private _firstSelection = true; private _graph?: GitGraph; - private _pendingIpcNotifications = new Map Promise)>(); + private _hoverCache = new Map>(); + + private readonly _ipcNotificationMap = new Map, () => Promise>([ + [DidChangeColumnsNotification, this.notifyDidChangeColumns], + [DidChangeGraphConfigurationNotification, this.notifyDidChangeConfiguration], + [DidChangeNotification, this.notifyDidChangeState], + [DidChangeRefsVisibilityNotification, this.notifyDidChangeRefsVisibility], + [DidChangeScrollMarkersNotification, this.notifyDidChangeScrollMarkers], + [DidChangeSelectionNotification, this.notifyDidChangeSelection], + [DidChangeSubscriptionNotification, this.notifyDidChangeSubscription], + [DidChangeWorkingTreeNotification, this.notifyDidChangeWorkingTree], + [DidFetchNotification, this.notifyDidFetch], + ]); private _refsMetadata: Map | null | undefined; private _search: GitSearch | undefined; - private _searchCancellation: CancellationTokenSource | undefined; private _selectedId?: string; private _selectedRows: GraphSelectedRows | undefined; private _showDetailsView: Config['graph']['showDetailsView']; - private _statusBarItem: StatusBarItem | undefined; private _theme: ColorTheme | undefined; private _repositoryEventsDisposable: Disposable | undefined; private _lastFetchedDisposable: Disposable | undefined; - private trialBanner?: boolean; private isWindowFocused: boolean = true; - constructor(container: Container) { - super( - container, - 'gitlens.graph', - 'graph.html', - 'images/gitlens-icon.png', - 'Commit Graph', - `${ContextKeys.WebviewPrefix}graph`, - 'graphWebview', - Commands.ShowGraphPage, - ); - + constructor( + private readonly container: Container, + private readonly host: WebviewHost, + ) { this._showDetailsView = configuration.get('graph.showDetailsView'); + this._theme = window.activeColorTheme; + this.ensureRepositorySubscriptions(); - this.disposables.push( + this._disposable = Disposable.from( configuration.onDidChange(this.onConfigurationChanged, this), - once(container.onReady)(() => queueMicrotask(() => this.updateStatusBar())), - onDidChangeContext(key => { - if (key !== ContextKeys.Enabled && key !== ContextKeys.PlusEnabled) return; - this.updateStatusBar(); - }), - { dispose: () => this._statusBarItem?.dispose() }, - registerCommand( - Commands.ShowInCommitGraph, - async ( - args: - | ShowInCommitGraphCommandArgs - | Repository - | BranchNode - | CommitNode - | CommitFileNode - | StashNode - | TagNode, - ) => { - let id; - if (args instanceof Repository) { - this.repository = args; - } else { - this.repository = this.container.git.getRepository(args.ref.repoPath); - id = args.ref.ref; - if (!GitRevision.isSha(id)) { - id = await this.container.git.resolveReference(args.ref.repoPath, id, undefined, { - force: true, - }); - } - this.setSelectedRows(id); + this.container.subscription.onDidChange(this.onSubscriptionChanged, this), + this.container.git.onDidChangeRepositories(async () => { + if (this._etag !== this.container.git.etag) { + if (this._discovering != null) { + this._etag = await this._discovering; + if (this._etag === this.container.git.etag) return; } - const preserveFocus = 'preserveFocus' in args ? args.preserveFocus ?? false : false; - if (this._panel == null) { - void this.show({ preserveFocus: preserveFocus }); - } else if (id) { - this._panel.reveal(this._panel.viewColumn ?? ViewColumn.Active, preserveFocus ?? false); - if (this._graph?.ids.has(id)) { - void this.notifyDidChangeSelection(); - return; - } - - this.setSelectedRows(id); - void this.onGetMoreRows({ id: id }, true); - } + void this.host.refresh(true); + } + }), + window.onDidChangeActiveColorTheme(this.onThemeChanged, this), + { + dispose: () => { + if (this._repositoryEventsDisposable == null) return; + this._repositoryEventsDisposable.dispose(); + this._repositoryEventsDisposable = undefined; }, - ), + }, + this.container.integrations.onDidChangeConnectionState(this.onIntegrationConnectionChanged, this), ); } - protected override onWindowFocusChanged(focused: boolean): void { - this.isWindowFocused = focused; - void this.notifyDidChangeWindowFocus(); + dispose() { + this._disposable.dispose(); } - protected override get options(): WebviewPanelOptions & WebviewOptions { - return { - retainContextWhenHidden: true, - enableFindWidget: false, - enableCommandUris: true, - enableScripts: true, - }; + canReuseInstance(...args: WebviewShowingArgs): boolean | undefined { + if (this.container.git.openRepositoryCount === 1) return true; + + const [arg] = args; + + let repository: Repository | undefined; + if (isRepository(arg)) { + repository = arg; + } else if (hasGitReference(arg)) { + repository = this.container.git.getRepository(arg.ref.repoPath); + } else if (isSerializedState(arg) && arg.state.selectedRepository != null) { + repository = this.container.git.getRepository(arg.state.selectedRepository); + } + + return repository?.uri.toString() === this.repository?.uri.toString() ? true : undefined; + } + + getSplitArgs(): WebviewShowingArgs { + return this.repository != null ? [this.repository] : []; } - override async show(options?: { column?: ViewColumn; preserveFocus?: boolean }, ...args: unknown[]): Promise { + async onShowing( + loading: boolean, + _options?: WebviewShowOptions, + ...args: WebviewShowingArgs + ): Promise { this._firstSelection = true; - if (!(await ensurePlusFeaturesEnabled())) return; - if (this.container.git.repositoryCount > 1) { - const [contexts] = parseCommandContext(Commands.ShowGraphPage, undefined, ...args); - const context = Array.isArray(contexts) ? contexts[0] : contexts; + this._etag = this.container.git.etag; + if (this.container.git.isDiscoveringRepositories) { + this._discovering = this.container.git.isDiscoveringRepositories.then(r => { + this._discovering = undefined; + return r; + }); + this._etag = await this._discovering; + } + + const [arg] = args; + if (isRepository(arg)) { + this.repository = arg; + } else if (hasGitReference(arg)) { + this.repository = this.container.git.getRepository(arg.ref.repoPath); - if (context.type === 'scm' && context.scm.rootUri != null) { - this.repository = this.container.git.getRepository(context.scm.rootUri); - } else if (context.type === 'viewItem' && context.node instanceof RepositoryFolderNode) { - this.repository = context.node.repo; + let id = arg.ref.ref; + if (!isSha(id)) { + id = await this.container.git.resolveReference(arg.ref.repoPath, id, undefined, { + force: true, + }); } - if (this.repository != null && this.isReady) { - this.updateState(); + this.setSelectedRows(id); + + if (this._graph != null) { + if (this._graph?.ids.has(id)) { + void this.notifyDidChangeSelection(); + return true; + } + + void this.onGetMoreRows({ id: id }, true); + } + } else { + if (isSerializedState(arg) && arg.state.selectedRepository != null) { + this.repository = this.container.git.getRepository(arg.state.selectedRepository); + } + + if (this.repository == null && this.container.git.repositoryCount > 1) { + const [contexts] = parseCommandContext(Commands.ShowGraph, undefined, ...args); + const context = Array.isArray(contexts) ? contexts[0] : contexts; + + if (context.type === 'scm' && context.scm.rootUri != null) { + this.repository = this.container.git.getRepository(context.scm.rootUri); + } else if (context.type === 'viewItem' && context.node instanceof RepositoryFolderNode) { + this.repository = context.node.repo; + } + + if (this.repository != null && !loading && this.host.ready) { + this.updateState(); + } } } - return super.show({ column: ViewColumn.Active, ...options }, ...args); + return true; } - protected override refresh(force?: boolean): Promise { - this.resetRepositoryState(); + onRefresh(force?: boolean) { if (force) { - this._pendingIpcNotifications.clear(); + this.resetRepositoryState(); } - return super.refresh(force); } - protected override async includeBootstrap(): Promise { + includeBootstrap(): Promise { return this.getState(true); } - protected override registerCommands(): Disposable[] { - return [ - registerCommand(Commands.RefreshGraph, () => this.refresh(true)), - - registerCommand('gitlens.graph.push', this.push, this), - registerCommand('gitlens.graph.pull', this.pull, this), - registerCommand('gitlens.graph.fetch', this.fetch, this), - registerCommand('gitlens.graph.switchToAnotherBranch', this.switchToAnother, this), - - registerCommand('gitlens.graph.createBranch', this.createBranch, this), - registerCommand('gitlens.graph.deleteBranch', this.deleteBranch, this), - registerCommand('gitlens.graph.copyRemoteBranchUrl', item => this.openBranchOnRemote(item, true), this), - registerCommand('gitlens.graph.openBranchOnRemote', this.openBranchOnRemote, this), - registerCommand('gitlens.graph.mergeBranchInto', this.mergeBranchInto, this), - registerCommand('gitlens.graph.rebaseOntoBranch', this.rebase, this), - registerCommand('gitlens.graph.rebaseOntoUpstream', this.rebaseToRemote, this), - registerCommand('gitlens.graph.renameBranch', this.renameBranch, this), - - registerCommand('gitlens.graph.switchToBranch', this.switchTo, this), - - registerCommand('gitlens.graph.hideLocalBranch', this.hideRef, this), - registerCommand('gitlens.graph.hideRemoteBranch', this.hideRef, this), - registerCommand('gitlens.graph.hideRemote', item => this.hideRef(item, { remote: true }), this), - registerCommand('gitlens.graph.hideRefGroup', item => this.hideRef(item, { group: true }), this), - registerCommand('gitlens.graph.hideTag', this.hideRef, this), - - registerCommand('gitlens.graph.cherryPick', this.cherryPick, this), - registerCommand('gitlens.graph.copyRemoteCommitUrl', item => this.openCommitOnRemote(item, true), this), - registerCommand('gitlens.graph.showInDetailsView', this.openInDetailsView, this), - registerCommand('gitlens.graph.openCommitOnRemote', this.openCommitOnRemote, this), - registerCommand('gitlens.graph.openSCM', this.openSCM, this), - registerCommand('gitlens.graph.rebaseOntoCommit', this.rebase, this), - registerCommand('gitlens.graph.resetCommit', this.resetCommit, this), - registerCommand('gitlens.graph.resetToCommit', this.resetToCommit, this), - registerCommand('gitlens.graph.revert', this.revertCommit, this), - registerCommand('gitlens.graph.switchToCommit', this.switchTo, this), - registerCommand('gitlens.graph.undoCommit', this.undoCommit, this), - - registerCommand('gitlens.graph.saveStash', this.saveStash, this), - registerCommand('gitlens.graph.applyStash', this.applyStash, this), - registerCommand('gitlens.graph.deleteStash', this.deleteStash, this), - - registerCommand('gitlens.graph.createTag', this.createTag, this), - registerCommand('gitlens.graph.deleteTag', this.deleteTag, this), - registerCommand('gitlens.graph.switchToTag', this.switchTo, this), - - registerCommand('gitlens.graph.createWorktree', this.createWorktree, this), - - registerCommand('gitlens.graph.createPullRequest', this.createPullRequest, this), - registerCommand('gitlens.graph.openPullRequestOnRemote', this.openPullRequestOnRemote, this), - - registerCommand('gitlens.graph.compareWithUpstream', this.compareWithUpstream, this), - registerCommand('gitlens.graph.compareWithHead', this.compareHeadWith, this), - registerCommand('gitlens.graph.compareWithWorking', this.compareWorkingWith, this), - registerCommand('gitlens.graph.compareAncestryWithWorking', this.compareAncestryWithWorking, this), - - registerCommand('gitlens.graph.copy', this.copy, this), - registerCommand('gitlens.graph.copyMessage', this.copyMessage, this), - registerCommand('gitlens.graph.copySha', this.copySha, this), - - registerCommand('gitlens.graph.addAuthor', this.addAuthor, this), - - registerCommand('gitlens.graph.columnAuthorOn', () => this.toggleColumn('author', true)), - registerCommand('gitlens.graph.columnAuthorOff', () => this.toggleColumn('author', false)), - registerCommand('gitlens.graph.columnDateTimeOn', () => this.toggleColumn('datetime', true)), - registerCommand('gitlens.graph.columnDateTimeOff', () => this.toggleColumn('datetime', false)), - registerCommand('gitlens.graph.columnShaOn', () => this.toggleColumn('sha', true)), - registerCommand('gitlens.graph.columnShaOff', () => this.toggleColumn('sha', false)), - registerCommand('gitlens.graph.columnChangesOn', () => this.toggleColumn('changes', true)), - registerCommand('gitlens.graph.columnChangesOff', () => this.toggleColumn('changes', false)), - - registerCommand('gitlens.graph.copyDeepLinkToBranch', this.copyDeepLinkToBranch, this), - registerCommand('gitlens.graph.copyDeepLinkToCommit', this.copyDeepLinkToCommit, this), - registerCommand('gitlens.graph.copyDeepLinkToRepo', this.copyDeepLinkToRepo, this), - registerCommand('gitlens.graph.copyDeepLinkToTag', this.copyDeepLinkToTag, this), - ]; + registerCommands(): Disposable[] { + const commands: Disposable[] = []; + + if (this.host.isHost('view')) { + commands.push( + registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true)), + registerCommand( + `${this.host.id}.openInTab`, + () => + void executeCommand( + Commands.ShowGraphPage, + undefined, + this.repository, + ), + ), + ); + } + + commands.push( + this.host.registerWebviewCommand('gitlens.graph.push', this.push), + this.host.registerWebviewCommand('gitlens.graph.pull', this.pull), + this.host.registerWebviewCommand('gitlens.graph.fetch', this.fetch), + this.host.registerWebviewCommand('gitlens.graph.publishBranch', this.publishBranch), + this.host.registerWebviewCommand('gitlens.graph.switchToAnotherBranch', this.switchToAnother), + + this.host.registerWebviewCommand('gitlens.graph.createBranch', this.createBranch), + this.host.registerWebviewCommand('gitlens.graph.deleteBranch', this.deleteBranch), + this.host.registerWebviewCommand('gitlens.graph.copyRemoteBranchUrl', item => + this.openBranchOnRemote(item, true), + ), + this.host.registerWebviewCommand('gitlens.graph.openBranchOnRemote', this.openBranchOnRemote), + this.host.registerWebviewCommand('gitlens.graph.mergeBranchInto', this.mergeBranchInto), + this.host.registerWebviewCommand('gitlens.graph.rebaseOntoBranch', this.rebase), + this.host.registerWebviewCommand('gitlens.graph.rebaseOntoUpstream', this.rebaseToRemote), + this.host.registerWebviewCommand('gitlens.graph.renameBranch', this.renameBranch), + + this.host.registerWebviewCommand('gitlens.graph.switchToBranch', this.switchTo), + + this.host.registerWebviewCommand('gitlens.graph.hideLocalBranch', this.hideRef), + this.host.registerWebviewCommand('gitlens.graph.hideRemoteBranch', this.hideRef), + this.host.registerWebviewCommand('gitlens.graph.hideRemote', item => + this.hideRef(item, { remote: true }), + ), + this.host.registerWebviewCommand('gitlens.graph.hideRefGroup', item => + this.hideRef(item, { group: true }), + ), + this.host.registerWebviewCommand('gitlens.graph.hideTag', this.hideRef), + + this.host.registerWebviewCommand('gitlens.graph.cherryPick', this.cherryPick), + this.host.registerWebviewCommand('gitlens.graph.copyRemoteCommitUrl', item => + this.openCommitOnRemote(item, true), + ), + this.host.registerWebviewCommand('gitlens.graph.copyRemoteCommitUrl.multi', item => + this.openCommitOnRemote(item, true), + ), + this.host.registerWebviewCommand('gitlens.graph.openCommitOnRemote', this.openCommitOnRemote), + this.host.registerWebviewCommand('gitlens.graph.openCommitOnRemote.multi', this.openCommitOnRemote), + this.host.registerWebviewCommand('gitlens.graph.openSCM', this.openSCM), + this.host.registerWebviewCommand('gitlens.graph.rebaseOntoCommit', this.rebase), + this.host.registerWebviewCommand('gitlens.graph.resetCommit', this.resetCommit), + this.host.registerWebviewCommand('gitlens.graph.resetToCommit', this.resetToCommit), + this.host.registerWebviewCommand('gitlens.graph.resetToTip', this.resetToTip), + this.host.registerWebviewCommand('gitlens.graph.revert', this.revertCommit), + this.host.registerWebviewCommand('gitlens.graph.showInDetailsView', this.openInDetailsView), + this.host.registerWebviewCommand('gitlens.graph.switchToCommit', this.switchTo), + this.host.registerWebviewCommand('gitlens.graph.undoCommit', this.undoCommit), + + this.host.registerWebviewCommand('gitlens.graph.stash.save', this.saveStash), + this.host.registerWebviewCommand('gitlens.graph.stash.apply', this.applyStash), + this.host.registerWebviewCommand('gitlens.graph.stash.delete', this.deleteStash), + this.host.registerWebviewCommand('gitlens.graph.stash.rename', this.renameStash), + + this.host.registerWebviewCommand('gitlens.graph.createTag', this.createTag), + this.host.registerWebviewCommand('gitlens.graph.deleteTag', this.deleteTag), + this.host.registerWebviewCommand('gitlens.graph.switchToTag', this.switchTo), + + this.host.registerWebviewCommand('gitlens.graph.createWorktree', this.createWorktree), + + this.host.registerWebviewCommand('gitlens.graph.createPullRequest', this.createPullRequest), + this.host.registerWebviewCommand('gitlens.graph.openPullRequest', this.openPullRequest), + this.host.registerWebviewCommand('gitlens.graph.openPullRequestChanges', this.openPullRequestChanges), + this.host.registerWebviewCommand('gitlens.graph.openPullRequestComparison', this.openPullRequestComparison), + this.host.registerWebviewCommand('gitlens.graph.openPullRequestOnRemote', this.openPullRequestOnRemote), + + this.host.registerWebviewCommand( + 'gitlens.graph.openChangedFileDiffsWithMergeBase', + this.openChangedFileDiffsWithMergeBase, + ), + + this.host.registerWebviewCommand('gitlens.graph.compareWithUpstream', this.compareWithUpstream), + this.host.registerWebviewCommand('gitlens.graph.compareWithHead', this.compareHeadWith), + this.host.registerWebviewCommand('gitlens.graph.compareBranchWithHead', this.compareBranchWithHead), + this.host.registerWebviewCommand('gitlens.graph.compareWithWorking', this.compareWorkingWith), + this.host.registerWebviewCommand('gitlens.graph.compareWithMergeBase', this.compareWithMergeBase), + this.host.registerWebviewCommand( + 'gitlens.graph.compareAncestryWithWorking', + this.compareAncestryWithWorking, + ), + + this.host.registerWebviewCommand('gitlens.graph.copy', this.copy), + this.host.registerWebviewCommand('gitlens.graph.copyMessage', this.copyMessage), + this.host.registerWebviewCommand('gitlens.graph.copySha', this.copySha), + + this.host.registerWebviewCommand('gitlens.graph.addAuthor', this.addAuthor), + + this.host.registerWebviewCommand('gitlens.graph.columnAuthorOn', () => this.toggleColumn('author', true)), + this.host.registerWebviewCommand('gitlens.graph.columnAuthorOff', () => this.toggleColumn('author', false)), + this.host.registerWebviewCommand('gitlens.graph.columnDateTimeOn', () => + this.toggleColumn('datetime', true), + ), + this.host.registerWebviewCommand('gitlens.graph.columnDateTimeOff', () => + this.toggleColumn('datetime', false), + ), + this.host.registerWebviewCommand('gitlens.graph.columnShaOn', () => this.toggleColumn('sha', true)), + this.host.registerWebviewCommand('gitlens.graph.columnShaOff', () => this.toggleColumn('sha', false)), + this.host.registerWebviewCommand('gitlens.graph.columnChangesOn', () => this.toggleColumn('changes', true)), + this.host.registerWebviewCommand('gitlens.graph.columnChangesOff', () => + this.toggleColumn('changes', false), + ), + this.host.registerWebviewCommand('gitlens.graph.columnGraphOn', () => this.toggleColumn('graph', true)), + this.host.registerWebviewCommand('gitlens.graph.columnGraphOff', () => this.toggleColumn('graph', false)), + this.host.registerWebviewCommand('gitlens.graph.columnMessageOn', () => this.toggleColumn('message', true)), + this.host.registerWebviewCommand('gitlens.graph.columnMessageOff', () => + this.toggleColumn('message', false), + ), + this.host.registerWebviewCommand('gitlens.graph.columnRefOn', () => this.toggleColumn('ref', true)), + this.host.registerWebviewCommand('gitlens.graph.columnRefOff', () => this.toggleColumn('ref', false)), + this.host.registerWebviewCommand('gitlens.graph.columnGraphCompact', () => + this.setColumnMode('graph', 'compact'), + ), + this.host.registerWebviewCommand('gitlens.graph.columnGraphDefault', () => + this.setColumnMode('graph', undefined), + ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerLocalBranchOn', () => + this.toggleScrollMarker('localBranches', true), + ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerLocalBranchOff', () => + this.toggleScrollMarker('localBranches', false), + ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerRemoteBranchOn', () => + this.toggleScrollMarker('remoteBranches', true), + ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerRemoteBranchOff', () => + this.toggleScrollMarker('remoteBranches', false), + ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerStashOn', () => + this.toggleScrollMarker('stashes', true), + ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerStashOff', () => + this.toggleScrollMarker('stashes', false), + ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerTagOn', () => + this.toggleScrollMarker('tags', true), + ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerTagOff', () => + this.toggleScrollMarker('tags', false), + ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerPullRequestOn', () => + this.toggleScrollMarker('pullRequests', true), + ), + this.host.registerWebviewCommand('gitlens.graph.scrollMarkerPullRequestOff', () => + this.toggleScrollMarker('pullRequests', false), + ), + + this.host.registerWebviewCommand('gitlens.graph.copyDeepLinkToBranch', this.copyDeepLinkToBranch), + this.host.registerWebviewCommand('gitlens.graph.copyDeepLinkToCommit', this.copyDeepLinkToCommit), + this.host.registerWebviewCommand('gitlens.graph.copyDeepLinkToRepo', this.copyDeepLinkToRepo), + this.host.registerWebviewCommand('gitlens.graph.copyDeepLinkToTag', this.copyDeepLinkToTag), + this.host.registerWebviewCommand('gitlens.graph.shareAsCloudPatch', this.shareAsCloudPatch), + this.host.registerWebviewCommand('gitlens.graph.createPatch', this.shareAsCloudPatch), + this.host.registerWebviewCommand('gitlens.graph.createCloudPatch', this.shareAsCloudPatch), + + this.host.registerWebviewCommand('gitlens.graph.openChangedFiles', this.openFiles), + this.host.registerWebviewCommand('gitlens.graph.openOnlyChangedFiles', this.openOnlyChangedFiles), + this.host.registerWebviewCommand('gitlens.graph.openChangedFileDiffs', item => + this.openAllChanges(item), + ), + this.host.registerWebviewCommand('gitlens.graph.openChangedFileDiffsWithWorking', item => + this.openAllChangesWithWorking(item), + ), + this.host.registerWebviewCommand('gitlens.graph.openChangedFileDiffsIndividually', item => + this.openAllChanges(item, true), + ), + this.host.registerWebviewCommand( + 'gitlens.graph.openChangedFileDiffsWithWorkingIndividually', + item => this.openAllChangesWithWorking(item, true), + ), + this.host.registerWebviewCommand('gitlens.graph.openChangedFileRevisions', this.openRevisions), + + this.host.registerWebviewCommand('gitlens.graph.resetColumnsDefault', () => + this.updateColumns(defaultGraphColumnsSettings), + ), + this.host.registerWebviewCommand('gitlens.graph.resetColumnsCompact', () => + this.updateColumns(compactGraphColumnsSettings), + ), + + this.host.registerWebviewCommand('gitlens.graph.openInWorktree', this.openInWorktree), + this.host.registerWebviewCommand('gitlens.graph.openWorktree', this.openWorktree), + this.host.registerWebviewCommand('gitlens.graph.openWorktreeInNewWindow', item => + this.openWorktree(item, { location: 'newWindow' }), + ), + this.host.registerWebviewCommand( + 'gitlens.graph.copyWorkingChangesToWorktree', + this.copyWorkingChangesToWorktree, + ), + ); + + return commands; } - protected override onInitializing(): Disposable[] | undefined { - this._theme = window.activeColorTheme; - this.ensureRepositorySubscriptions(); + onWindowFocusChanged(focused: boolean): void { + this.isWindowFocused = focused; + } - return [ - this.container.subscription.onDidChange(this.onSubscriptionChanged, this), - this.container.git.onDidChangeRepositories(() => void this.refresh(true)), - window.onDidChangeActiveColorTheme(this.onThemeChanged, this), - { - dispose: () => { - if (this._repositoryEventsDisposable == null) return; - this._repositoryEventsDisposable.dispose(); - this._repositoryEventsDisposable = undefined; - }, - }, - ]; + onFocusChanged(focused: boolean): void { + this._showActiveSelectionDetailsDebounced?.cancel(); + + if (!focused || this.activeSelection == null || !this.container.commitDetailsView.visible) { + return; + } + + this.showActiveSelectionDetails(); } - protected override onReady(): void { - this.sendPendingIpcNotifications(); + onVisibilityChanged(visible: boolean): void { + if (!visible) { + this._showActiveSelectionDetailsDebounced?.cancel(); + } + + if ( + visible && + ((this.repository != null && this.repository.etag !== this._etagRepository) || + this.container.subscription.etag !== this._etagSubscription) + ) { + this.updateState(true); + return; + } + + if (visible) { + this.host.sendPendingIpcNotifications(); + + const { activeSelection } = this; + if (activeSelection == null) return; + + this.showActiveSelectionDetails(); + } } - protected override onMessageReceived(e: IpcMessage) { - switch (e.method) { - case ChooseRepositoryCommandType.method: - onIpc(ChooseRepositoryCommandType, e, () => this.onChooseRepository()); + onMessageReceived(e: IpcMessage) { + switch (true) { + case ChooseRepositoryCommand.is(e): + void this.onChooseRepository(); break; - case DimMergeCommitsCommandType.method: - onIpc(DimMergeCommitsCommandType, e, params => this.dimMergeCommits(params)); + case ChooseRefRequest.is(e): + void this.onChooseRef(ChooseRefRequest, e); break; - case DismissBannerCommandType.method: - onIpc(DismissBannerCommandType, e, params => this.dismissBanner(params)); + case DoubleClickedCommandType.is(e): + void this.onDoubleClick(e.params); break; - case DoubleClickedCommandType.method: - onIpc(DoubleClickedCommandType, e, params => this.onDoubleClick(params)); + case EnsureRowRequest.is(e): + void this.onEnsureRowRequest(EnsureRowRequest, e); break; - case EnsureRowCommandType.method: - onIpc(EnsureRowCommandType, e, params => this.onEnsureRow(params, e.completionId)); + case GetCountsRequest.is(e): + void this.onGetCounts(GetCountsRequest, e); break; - case GetMissingAvatarsCommandType.method: - onIpc(GetMissingAvatarsCommandType, e, params => this.onGetMissingAvatars(params)); + case GetMissingAvatarsCommand.is(e): + void this.onGetMissingAvatars(e.params); break; - case GetMissingRefsMetadataCommandType.method: - onIpc(GetMissingRefsMetadataCommandType, e, params => this.onGetMissingRefMetadata(params)); + case GetMissingRefsMetadataCommand.is(e): + void this.onGetMissingRefMetadata(e.params); break; - case GetMoreRowsCommandType.method: - onIpc(GetMoreRowsCommandType, e, params => this.onGetMoreRows(params)); + case GetMoreRowsCommand.is(e): + void this.onGetMoreRows(e.params); break; - case SearchCommandType.method: - onIpc(SearchCommandType, e, params => this.onSearch(params, e.completionId)); + case GetRowHoverRequest.is(e): + void this.onHoverRowRequest(GetRowHoverRequest, e); break; - case SearchOpenInViewCommandType.method: - onIpc(SearchOpenInViewCommandType, e, params => this.onSearchOpenInView(params)); + case OpenPullRequestDetailsCommand.is(e): + void this.onOpenPullRequestDetails(e.params); break; - case UpdateColumnsCommandType.method: - onIpc(UpdateColumnsCommandType, e, params => this.onColumnsChanged(params)); + case SearchRequest.is(e): + void this.onSearchRequest(SearchRequest, e); break; - case UpdateGraphConfigurationCommandType.method: - onIpc(UpdateGraphConfigurationCommandType, e, params => this.updateGraphConfig(params)); + case SearchOpenInViewCommand.is(e): + this.onSearchOpenInView(e.params); break; - case UpdateRefsVisibilityCommandType.method: - onIpc(UpdateRefsVisibilityCommandType, e, params => this.onRefsVisibilityChanged(params)); + case UpdateColumnsCommand.is(e): + this.onColumnsChanged(e.params); break; - case UpdateSelectionCommandType.method: - onIpc(UpdateSelectionCommandType, e, this.onSelectionChanged.bind(this)); + case UpdateGraphConfigurationCommand.is(e): + this.updateGraphConfig(e.params); break; - case UpdateExcludeTypeCommandType.method: - onIpc(UpdateExcludeTypeCommandType, e, params => this.updateExcludedType(this._graph, params)); + case UpdateExcludeTypesCommand.is(e): + this.updateExcludedTypes(this._graph?.repoPath, e.params); break; - case UpdateIncludeOnlyRefsCommandType.method: - onIpc(UpdateIncludeOnlyRefsCommandType, e, params => - this.updateIncludeOnlyRefs(this._graph, params.refs), - ); + case UpdateIncludedRefsCommand.is(e): + this.updateIncludeOnlyRefs(this._graph?.repoPath, e.params); + break; + case UpdateRefsVisibilityCommand.is(e): + this.onRefsVisibilityChanged(e.params); + break; + case UpdateSelectionCommand.is(e): + this.onSelectionChanged(e.params); break; } } + private async onGetCounts(requestType: T, msg: IpcCallMessageType) { + let counts: DidGetCountParams; + if (this._graph != null) { + const tags = await this.container.git.getTags(this._graph.repoPath); + counts = { + branches: count(this._graph.branches?.values(), b => !b.remote), + remotes: this._graph.remotes.size, + stashes: this._graph.stashes?.size, + // Subtract the default worktree + worktrees: this._graph.worktrees != null ? this._graph.worktrees.length - 1 : undefined, + tags: tags.values.length, + }; + } else { + counts = undefined; + } + + void this.host.respond(requestType, msg, counts); + } + updateGraphConfig(params: UpdateGraphConfigurationParams) { const config = this.getComponentConfig(); @@ -508,7 +757,34 @@ export class GraphWebview extends WebviewBase { if (config[key] !== params.changes[key]) { switch (key) { case 'minimap': - void configuration.updateEffective('graph.experimental.minimap.enabled', params.changes[key]); + void configuration.updateEffective('graph.minimap.enabled', params.changes[key]); + break; + case 'minimapDataType': + void configuration.updateEffective('graph.minimap.dataType', params.changes[key]); + break; + case 'minimapMarkerTypes': { + const additionalTypes: GraphMinimapMarkersAdditionalTypes[] = []; + + const markers = params.changes[key] ?? []; + for (const marker of markers) { + switch (marker) { + case 'localBranches': + case 'remoteBranches': + case 'stashes': + case 'tags': + case 'pullRequests': + additionalTypes.push(marker); + break; + } + } + void configuration.updateEffective('graph.minimap.additionalTypes', additionalTypes); + break; + } + case 'dimMergeCommits': + void configuration.updateEffective('graph.dimMergeCommits', params.changes[key]); + break; + case 'onlyFollowFirstParent': + void configuration.updateEffective('graph.onlyFollowFirstParent', params.changes[key]); break; default: // TODO:@eamodio add more config options as needed @@ -519,17 +795,9 @@ export class GraphWebview extends WebviewBase { } } - protected override onFocusChanged(focused: boolean): void { - if (!focused || this.activeSelection == null || !this.container.commitDetailsView.visible) { - this._showActiveSelectionDetailsDebounced?.cancel(); - return; - } - - this.showActiveSelectionDetails(); - } - - private _showActiveSelectionDetailsDebounced: Deferrable | undefined = - undefined; + private _showActiveSelectionDetailsDebounced: + | Deferrable + | undefined = undefined; private showActiveSelectionDetails() { if (this._showActiveSelectionDetailsDebounced == null) { @@ -541,56 +809,27 @@ export class GraphWebview extends WebviewBase { private showActiveSelectionDetailsCore() { const { activeSelection } = this; - if (activeSelection == null) return; + if (activeSelection == null || !this.host.active) return; this.container.events.fire( 'commit:selected', { commit: activeSelection, - pin: false, + interaction: 'passive', preserveFocus: true, preserveVisibility: this._showDetailsView === false, }, { - source: this.id, + source: this.host.id, }, ); } - protected override onVisibilityChanged(visible: boolean): void { - if (!visible) { - this._showActiveSelectionDetailsDebounced?.cancel(); - } - - if (visible && this.repository != null && this.repository.etag !== this._etagRepository) { - this.updateState(true); - return; - } - - if (visible) { - if (this.isReady) { - this.sendPendingIpcNotifications(); - } - - const { activeSelection } = this; - if (activeSelection == null) return; - - this.showActiveSelectionDetails(); - } - } - private onConfigurationChanged(e: ConfigurationChangeEvent) { if (configuration.changed(e, 'graph.showDetailsView')) { this._showDetailsView = configuration.get('graph.showDetailsView'); } - if (configuration.changed(e, 'graph.statusBar.enabled') || configuration.changed(e, 'plusFeatures.enabled')) { - this.updateStatusBar(); - } - - // If we don't have an open webview ignore the rest - if (this._panel == null) return; - if (configuration.changed(e, 'graph.commitOrdering')) { this.updateState(); @@ -601,34 +840,24 @@ export class GraphWebview extends WebviewBase { configuration.changed(e, 'defaultDateFormat') || configuration.changed(e, 'defaultDateStyle') || configuration.changed(e, 'advanced.abbreviatedShaLength') || - configuration.changed(e, 'graph.avatars') || - configuration.changed(e, 'graph.dateFormat') || - configuration.changed(e, 'graph.dateStyle') || - configuration.changed(e, 'graph.dimMergeCommits') || - configuration.changed(e, 'graph.highlightRowsOnRefHover') || - configuration.changed(e, 'graph.scrollRowPadding') || - configuration.changed(e, 'graph.scrollMarkers.enabled') || - configuration.changed(e, 'graph.scrollMarkers.additionalTypes') || - configuration.changed(e, 'graph.showGhostRefsOnRowHover') || - configuration.changed(e, 'graph.pullRequests.enabled') || - configuration.changed(e, 'graph.showRemoteNames') || - configuration.changed(e, 'graph.showUpstreamStatus') || - configuration.changed(e, 'graph.experimental.minimap.enabled') || - configuration.changed(e, 'graph.experimental.minimap.additionalTypes') + configuration.changed(e, 'graph') ) { void this.notifyDidChangeConfiguration(); if ( - configuration.changed(e, 'graph.experimental.minimap.enabled') && - configuration.get('graph.experimental.minimap.enabled') && - !this._graph?.includes?.stats + configuration.changed(e, 'graph.onlyFollowFirstParent') || + ((configuration.changed(e, 'graph.minimap.enabled') || + configuration.changed(e, 'graph.minimap.dataType')) && + configuration.get('graph.minimap.enabled') && + configuration.get('graph.minimap.dataType') === 'lines' && + !this._graph?.includes?.stats) ) { this.updateState(); } } } - @debug({ args: { 0: e => e.toString() } }) + @debug({ args: { 0: e => e.toString() } }) private onRepositoryChanged(e: RepositoryChangeEvent) { if ( !e.changed( @@ -669,43 +898,26 @@ export class GraphWebview extends WebviewBase { this._etagSubscription = e.etag; void this.notifyDidChangeSubscription(); - this.updateStatusBar(); } private onThemeChanged(theme: ColorTheme) { - if (this._theme != null) { - if ( - (isDarkTheme(theme) && isDarkTheme(this._theme)) || - (isLightTheme(theme) && isLightTheme(this._theme)) - ) { - return; - } + if ( + this._theme != null && + ((isDarkTheme(theme) && isDarkTheme(this._theme)) || (isLightTheme(theme) && isLightTheme(this._theme))) + ) { + return; } this._theme = theme; this.updateState(); } - private dimMergeCommits(e: DimMergeCommitsParams) { - void configuration.updateEffective('graph.dimMergeCommits', e.dim); - } - - private dismissBanner(e: DismissBannerParams) { - if (e.key === 'trial') { - this.trialBanner = false; - } - - let banners = this.container.storage.getWorkspace('graph:banners:dismissed'); - banners = updateRecordValue(banners, e.key, true); - void this.container.storage.storeWorkspace('graph:banners:dismissed', banners); - } - private onColumnsChanged(e: UpdateColumnsParams) { this.updateColumns(e.config); } private onRefsVisibilityChanged(e: UpdateRefsVisibilityParams) { - this.updateExcludedRefs(this._graph, e.refs, e.visible); + this.updateExcludedRefs(this._graph?.repoPath, e.refs, e.visible); } private onDoubleClick(e: DoubleClickedParams) { @@ -742,43 +954,212 @@ export class GraphWebview extends WebviewBase { ); } } else if (e.type === 'row' && e.row) { + this._showActiveSelectionDetailsDebounced?.cancel(); + const commit = this.getRevisionReference(this.repository?.path, e.row.id, e.row.type); if (commit != null) { this.container.events.fire( 'commit:selected', { commit: commit, + interaction: 'active', preserveFocus: e.preserveFocus, preserveVisibility: false, }, { - source: this.id, + source: this.host.id, }, ); + + const details = this.host.isHost('editor') + ? this.container.commitDetailsView + : this.container.graphDetailsView; + if (!details.ready) { + void details.show({ preserveFocus: e.preserveFocus }, { + commit: commit, + interaction: 'active', + preserveVisibility: false, + } satisfies CommitSelectedEvent['data']); + } } } return Promise.resolve(); } + private async onHoverRowRequest(requestType: T, msg: IpcCallMessageType) { + const hover: DidGetRowHoverParams = { + id: msg.params.id, + markdown: undefined!, + }; + + this.cancelOperation('hover'); + + if (this._graph != null) { + const id = msg.params.id; + + let markdown = this._hoverCache.get(id); + if (markdown == null) { + const cancellation = this.createCancellation('hover'); + + let cache = true; + let commit; + switch (msg.params.type) { + case 'work-dir-changes': + cache = false; + commit = await this.container.git.getCommit(this._graph.repoPath, uncommitted); + break; + case 'stash-node': { + const stash = await this.container.git.getStash(this._graph.repoPath); + commit = stash?.commits.get(msg.params.id); + break; + } + default: { + commit = await this.container.git.getCommit(this._graph.repoPath, msg.params.id); + break; + } + } + + if (commit != null && !cancellation.token.isCancellationRequested) { + // Check if we have calculated stats for the row and if so apply it to the commit + const stats = this._graph.rowsStats?.get(commit.sha); + if (stats != null) { + commit = commit.with({ + stats: { + ...commit.stats, + additions: stats.additions, + deletions: stats.deletions, + // If `changedFiles` already exists, then use it, otherwise use the files count + changedFiles: commit.stats?.changedFiles ? commit.stats.changedFiles : stats.files, + }, + }); + } + + markdown = this.getCommitTooltip(commit, cancellation.token).catch((ex: unknown) => { + this._hoverCache.delete(id); + throw ex; + }); + if (cache) { + this._hoverCache.set(id, markdown); + } + } + } + + if (markdown != null) { + try { + hover.markdown = { + status: 'fulfilled' as const, + value: await markdown, + }; + } catch (ex) { + hover.markdown = { status: 'rejected' as const, reason: ex }; + } + } + } + + hover.markdown ??= { status: 'rejected' as const, reason: new CancellationError() }; + void this.host.respond(requestType, msg, hover); + } + + private async getCommitTooltip(commit: GitCommit, cancellation: CancellationToken) { + const [remotesResult, _] = await Promise.allSettled([ + this.container.git.getBestRemotesWithProviders(commit.repoPath), + commit.ensureFullDetails(), + ]); + + if (cancellation.isCancellationRequested) throw new CancellationError(); + + const remotes = getSettledValue(remotesResult, []); + const [remote] = remotes; + + let enrichedAutolinks; + let pr; + + if (remote?.hasIntegration()) { + const [enrichedAutolinksResult, prResult] = await Promise.allSettled([ + pauseOnCancelOrTimeoutMapTuplePromise(commit.getEnrichedAutolinks(remote), cancellation), + commit.getAssociatedPullRequest(remote), + ]); + + if (cancellation.isCancellationRequested) throw new CancellationError(); + + const enrichedAutolinksMaybeResult = getSettledValue(enrichedAutolinksResult); + if (!enrichedAutolinksMaybeResult?.paused) { + enrichedAutolinks = enrichedAutolinksMaybeResult?.value; + } + pr = getSettledValue(prResult); + } + + let template; + if (isStash(commit)) { + template = configuration.get('views.formats.stashes.tooltip'); + } else { + template = configuration.get('views.formats.commits.tooltip'); + } + + const tooltip = await CommitFormatter.fromTemplateAsync(template, commit, { + enrichedAutolinks: enrichedAutolinks, + dateFormat: configuration.get('defaultDateFormat'), + getBranchAndTagTips: this.getBranchAndTagTips.bind(this), + messageAutolinks: true, + messageIndent: 4, + pullRequest: pr, + outputFormat: 'markdown', + remotes: remotes, + // unpublished: this.unpublished, + }); + + return tooltip; + } + + private getBranchAndTagTips(sha: string, options?: { compact?: boolean; icons?: boolean }): string | undefined { + if (this._graph == null) return undefined; + + const row = this._graph.rows.find(r => r.sha === sha); + if (row == null) return undefined; + + const tips = []; + if (row.heads?.length) { + tips.push(...row.heads.map(h => (options?.icons ? `$(git-branch) ${h.name}` : h.name))); + } + + if (row.remotes?.length) { + tips.push( + ...row.remotes.map(h => { + const name = `${h.owner ? `${h.owner}/` : ''}${h.name}`; + return options?.icons ? `$(${getGkProviderThemeIconString(h.hostingServiceType)}) ${name}` : name; + }), + ); + } + if (row.tags?.length) { + tips.push(...row.tags.map(h => (options?.icons ? `$(tag) ${h.name}` : h.name))); + } + + return tips.join(', ') || undefined; + } + @debug() - private async onEnsureRow(e: EnsureRowParams, completionId?: string) { + private async onEnsureRowRequest(requestType: T, msg: IpcCallMessageType) { if (this._graph == null) return; + const e = msg.params; + const ensureId = this._graph.remappedIds?.get(e.id) ?? e.id; + let id: string | undefined; - if (!this._graph.skippedIds?.has(e.id)) { - if (this._graph.ids.has(e.id)) { + let remapped: string | undefined; + if (this._graph.ids.has(ensureId)) { + id = e.id; + remapped = e.id !== ensureId ? ensureId : undefined; + } else { + await this.updateGraphWithMoreRows(this._graph, ensureId, this._search); + void this.notifyDidChangeRows(); + if (this._graph.ids.has(ensureId)) { id = e.id; - } else { - await this.updateGraphWithMoreRows(this._graph, e.id, this._search); - void this.notifyDidChangeRows(); - if (this._graph.ids.has(e.id)) { - id = e.id; - } + remapped = e.id !== ensureId ? ensureId : undefined; } } - void this.notify(DidEnsureRowNotificationType, { id: id }, completionId); + void this.host.respond(requestType, msg, { id: id, remapped: remapped }); } private async onGetMissingAvatars(e: GetMissingAvatarsParams) { @@ -786,7 +1167,7 @@ export class GraphWebview extends WebviewBase { const repoPath = this._graph.repoPath; - async function getAvatar(this: GraphWebview, email: string, id: string) { + async function getAvatar(this: GraphWebviewProvider, email: string, id: string) { const uri = await getAvatarUri(email, { ref: id, repoPath: repoPath }); this._graph!.avatars.set(email, uri.toString(true)); } @@ -806,11 +1187,21 @@ export class GraphWebview extends WebviewBase { } private async onGetMissingRefMetadata(e: GetMissingRefsMetadataParams) { - if (this._graph == null || this._refsMetadata === null || !getContext(ContextKeys.HasConnectedRemotes)) return; + if ( + this._graph == null || + this._refsMetadata === null || + !getContext('gitlens:repos:withHostingIntegrationsConnected')?.includes(this._graph.repoPath) + ) { + return; + } const repoPath = this._graph.repoPath; - async function getRefMetadata(this: GraphWebview, id: string, missingTypes: GraphMissingRefsMetadataType[]) { + async function getRefMetadata( + this: GraphWebviewProvider, + id: string, + missingTypes: GraphMissingRefsMetadataType[], + ) { if (this._refsMetadata == null) { this._refsMetadata = new Map(); } @@ -835,7 +1226,7 @@ export class GraphWebview extends WebviewBase { continue; } - if (type === GraphRefMetadataTypes.PullRequest) { + if (type === 'pullRequest') { const pr = await branch?.getAssociatedPullRequest(); if (pr == null) { @@ -853,15 +1244,23 @@ export class GraphWebview extends WebviewBase { id: Number.parseInt(pr.id) || 0, title: pr.title, author: pr.author.name, - date: (pr.mergedDate ?? pr.closedDate ?? pr.date)?.getTime(), + date: (pr.mergedDate ?? pr.closedDate ?? pr.updatedDate)?.getTime(), state: pr.state, url: pr.url, context: serializeWebviewItemContext({ - webviewItem: 'gitlens:pullrequest', + webviewItem: `gitlens:pullrequest${pr.refs ? '+refs' : ''}`, webviewItemValue: { type: 'pullrequest', id: pr.id, url: pr.url, + repoPath: repoPath, + refs: pr.refs, + provider: { + id: pr.provider.id, + name: pr.provider.name, + domain: pr.provider.domain, + icon: pr.provider.icon, + }, }, }), }; @@ -869,10 +1268,13 @@ export class GraphWebview extends WebviewBase { metadata.pullRequest = [prMetadata]; this._refsMetadata.set(id, metadata); + if (branch?.upstream?.missing) { + this._refsMetadata.set(getBranchId(repoPath, true, branch.upstream.name), metadata); + } continue; } - if (type === GraphRefMetadataTypes.Upstream) { + if (type === 'upstream') { const upstream = branch?.upstream; if (upstream == null || upstream == undefined || upstream.missing) { @@ -890,7 +1292,7 @@ export class GraphWebview extends WebviewBase { webviewItem: 'gitlens:upstreamStatus', webviewItemValue: { type: 'upstreamStatus', - ref: GitReference.fromBranch(branch), + ref: getReferenceFromBranch(branch), ahead: branch.state.ahead, behind: branch.state.behind, }, @@ -930,16 +1332,40 @@ export class GraphWebview extends WebviewBase { void this.notifyDidChangeRows(sendSelectedRows); } + @log() + async onOpenPullRequestDetails(_params: OpenPullRequestDetailsParams) { + // TODO: a hack for now, since we aren't using the params at all right now and always opening the current branch's PR + const repo = this.repository; + if (repo == null) return undefined; + + const branch = await repo.getBranch(); + if (branch == null) return undefined; + + const pr = await branch.getAssociatedPullRequest(); + if (pr == null) return undefined; + + return this.container.pullRequestView.showPullRequest(pr, branch); + } + @debug() - private async onSearch(e: SearchParams, completionId?: string) { + private async onSearchRequest(requestType: T, msg: IpcCallMessageType) { + try { + const results = await this.getSearchResults(msg.params); + void this.host.respond(requestType, msg, results); + } catch (ex) { + void this.host.respond(requestType, msg, { + results: + ex instanceof CancellationError + ? undefined + : { error: ex instanceof GitSearchError ? 'Invalid search pattern' : 'Unexpected error' }, + }); + } + } + + private async getSearchResults(e: SearchParams): Promise { if (e.search == null) { this.resetSearchState(); - - // This shouldn't happen, but just in case - if (completionId != null) { - debugger; - } - return; + return { results: undefined }; } let search: GitSearch | undefined = this._search; @@ -950,39 +1376,31 @@ export class GraphWebview extends WebviewBase { this._search = search; void (await this.ensureSearchStartsInRange(this._graph!, search)); - void this.notify( - DidSearchNotificationType, - { - results: - search.results.size > 0 - ? { - ids: Object.fromEntries(search.results), - count: search.results.size, - paging: { hasMore: search.paging?.hasMore ?? false }, - } - : undefined, - }, - completionId, - ); + return { + results: + search.results.size > 0 + ? { + ids: Object.fromEntries( + map(search.results, ([k, v]) => [this._graph?.remappedIds?.get(k) ?? k, v]), + ), + count: search.results.size, + paging: { hasMore: search.paging?.hasMore ?? false }, + } + : undefined, + }; } - return; + return { results: undefined }; } if (search == null || search.comparisonKey !== getSearchQueryComparisonKey(e.search)) { - if (this.repository == null) return; + if (this.repository == null) return { results: { error: 'No repository' } }; if (this.repository.etag !== this._etagRepository) { this.updateState(true); } - if (this._searchCancellation != null) { - this._searchCancellation.cancel(); - this._searchCancellation.dispose(); - } - - const cancellation = new CancellationTokenSource(); - this._searchCancellation = cancellation; + const cancellation = this.createCancellation('search'); try { search = await this.repository.searchCommits(e.search, { @@ -992,24 +1410,17 @@ export class GraphWebview extends WebviewBase { }); } catch (ex) { this._search = undefined; - - void this.notify( - DidSearchNotificationType, - { - results: { - error: ex instanceof GitSearchError ? 'Invalid search pattern' : 'Unexpected error', - }, - }, - completionId, - ); - return; + throw ex; + // return { + // results: { + // error: ex instanceof GitSearchError ? 'Invalid search pattern' : 'Unexpected error', + // }, + // }; } if (cancellation.token.isCancellationRequested) { - if (completionId != null) { - void this.notify(DidSearchNotificationType, { results: undefined }, completionId); - } - return; + throw new CancellationError(); + // return { results: undefined }; } this._search = search; @@ -1025,21 +1436,19 @@ export class GraphWebview extends WebviewBase { this.setSelectedRows(firstResult); } - void this.notify( - DidSearchNotificationType, - { - results: - search.results.size === 0 - ? { count: 0 } - : { - ids: Object.fromEntries(search.results), - count: search.results.size, - paging: { hasMore: search.paging?.hasMore ?? false }, - }, - selectedRows: sendSelectedRows ? this._selectedRows : undefined, - }, - completionId, - ); + return { + results: + search.results.size === 0 + ? { count: 0 } + : { + ids: Object.fromEntries( + map(search.results, ([k, v]) => [this._graph?.remappedIds?.get(k) ?? k, v]), + ), + count: search.results.size, + paging: { hasMore: search.paging?.hasMore ?? false }, + }, + selectedRows: sendSelectedRows ? this._selectedRows : undefined, + }; } private onSearchOpenInView(e: SearchOpenInViewParams) { @@ -1064,24 +1473,54 @@ export class GraphWebview extends WebviewBase { a.index - b.index, ); - const pick = await RepositoryPicker.show( - `Switch Repository ${GlyphChars.Dot} ${this.repository?.name}`, - 'Choose a repository to switch to', - repositories, - ); - if (pick == null) return; + const pick = await showRepositoryPicker( + `Switch Repository ${GlyphChars.Dot} ${this.repository?.name}`, + 'Choose a repository to switch to', + repositories, + ); + if (pick == null) return; + + this.repository = pick; + } + + async onChooseRef(requestType: T, msg: IpcCallMessageType) { + if (this.repository == null) { + return this.host.respond(requestType, msg, undefined); + } + + let pick; + // If not alt, then jump directly to HEAD + if (!msg.params.alt) { + let branch = find(this._graph!.branches.values(), b => b.current); + if (branch == null) { + branch = await this.repository.getBranch(); + } + if (branch != null) { + pick = branch; + } + } else { + pick = await showReferencePicker( + this.repository.path, + `Jump to Reference ${GlyphChars.Dot} ${this.repository?.name}`, + 'Choose a reference to jump to', + { include: ReferencesQuickPickIncludes.BranchesAndTags }, + ); + } - this.repository = pick.item; + return this.host.respond(requestType, msg, pick?.sha != null ? { name: pick.name, sha: pick.sha } : undefined); } - private _fireSelectionChangedDebounced: Deferrable | undefined = undefined; + private _fireSelectionChangedDebounced: Deferrable | undefined = + undefined; private onSelectionChanged(e: UpdateSelectionParams) { + this._showActiveSelectionDetailsDebounced?.cancel(); + const item = e.selection[0]; this.setSelectedRows(item?.id); if (this._fireSelectionChangedDebounced == null) { - this._fireSelectionChangedDebounced = debounce(this.fireSelectionChanged.bind(this), 250); + this._fireSelectionChangedDebounced = debounce(this.fireSelectionChanged.bind(this), 50); } this._fireSelectionChangedDebounced(item?.id, item?.type); @@ -1094,55 +1533,56 @@ export class GraphWebview extends WebviewBase { const commits = commit != null ? [commit] : undefined; this._selection = commits; - this._onDidChangeSelection.fire({ selection: commits ?? [] }); if (commits == null) return; + if (!this._firstSelection && this.host.isHost('editor') && !this.host.active) return; this.container.events.fire( 'commit:selected', { commit: commits[0], - pin: false, + interaction: 'passive', preserveFocus: true, preserveVisibility: this._firstSelection ? this._showDetailsView === false : this._showDetailsView !== 'selection', }, { - source: this.id, + source: this.host.id, }, ); this._firstSelection = false; } - private _notifyDidChangeStateDebounced: Deferrable | undefined = undefined; + private _notifyDidChangeStateDebounced: Deferrable | undefined = + undefined; private getRevisionReference( repoPath: string | undefined, id: string | undefined, type: GitGraphRowType | undefined, - ) { + ): GitStashReference | GitRevisionReference | undefined { if (repoPath == null || id == null) return undefined; switch (type) { - case GitGraphRowType.Stash: - return GitReference.create(id, repoPath, { + case 'stash-node': + return createReference(id, repoPath, { refType: 'stash', name: id, number: undefined, }); - case GitGraphRowType.Working: - return GitReference.create(GitRevision.uncommitted, repoPath, { refType: 'revision' }); + case 'work-dir-changes': + return createReference(uncommitted, repoPath, { refType: 'revision' }); default: - return GitReference.create(id, repoPath, { refType: 'revision' }); + return createReference(id, repoPath, { refType: 'revision' }); } } @debug() private updateState(immediate: boolean = false) { - this._pendingIpcNotifications.clear(); + this.host.clearPendingIpcNotifications(); if (immediate) { void this.notifyDidChangeState(); @@ -1156,19 +1596,7 @@ export class GraphWebview extends WebviewBase { void this._notifyDidChangeStateDebounced(); } - @debug() - private async notifyDidChangeWindowFocus(): Promise { - if (!this.isReady || !this.visible) { - this.addPendingIpcNotification(DidChangeWindowFocusNotificationType); - return false; - } - - return this.notify(DidChangeWindowFocusNotificationType, { - focused: this.isWindowFocused, - }); - } - - private _notifyDidChangeAvatarsDebounced: Deferrable | undefined = + private _notifyDidChangeAvatarsDebounced: Deferrable | undefined = undefined; @debug() @@ -1190,13 +1618,21 @@ export class GraphWebview extends WebviewBase { if (this._graph == null) return; const data = this._graph; - return this.notify(DidChangeAvatarsNotificationType, { + return this.host.notify(DidChangeAvatarsNotification, { avatars: Object.fromEntries(data.avatars), }); } - private _notifyDidChangeRefsMetadataDebounced: Deferrable | undefined = - undefined; + @debug() + private async notifyDidChangeBranchState(branchState: BranchState) { + return this.host.notify(DidChangeBranchStateNotification, { + branchState: branchState, + }); + } + + private _notifyDidChangeRefsMetadataDebounced: + | Deferrable + | undefined = undefined; @debug() private updateRefsMetadata(immediate: boolean = false) { @@ -1214,61 +1650,96 @@ export class GraphWebview extends WebviewBase { @debug() private async notifyDidChangeRefsMetadata() { - return this.notify(DidChangeRefsMetadataNotificationType, { + return this.host.notify(DidChangeRefsMetadataNotification, { metadata: this._refsMetadata != null ? Object.fromEntries(this._refsMetadata) : this._refsMetadata, }); } @debug() private async notifyDidChangeColumns() { - if (!this.isReady || !this.visible) { - this.addPendingIpcNotification(DidChangeColumnsNotificationType); + if (!this.host.ready || !this.host.visible) { + this.host.addPendingIpcNotification(DidChangeColumnsNotification, this._ipcNotificationMap, this); return false; } const columns = this.getColumns(); const columnSettings = this.getColumnSettings(columns); - return this.notify(DidChangeColumnsNotificationType, { + return this.host.notify(DidChangeColumnsNotification, { columns: columnSettings, context: this.getColumnHeaderContext(columnSettings), + settingsContext: this.getGraphSettingsIconContext(columnSettings), }); } @debug() - private async notifyDidChangeRefsVisibility() { - if (!this.isReady || !this.visible) { - this.addPendingIpcNotification(DidChangeRefsVisibilityNotificationType); + private async notifyDidChangeScrollMarkers() { + if (!this.host.ready || !this.host.visible) { + this.host.addPendingIpcNotification(DidChangeScrollMarkersNotification, this._ipcNotificationMap, this); return false; } - return this.notify(DidChangeRefsVisibilityNotificationType, { - excludeRefs: this.getExcludedRefs(this._graph), - excludeTypes: this.getExcludedTypes(this._graph), - includeOnlyRefs: this.getIncludeOnlyRefs(this._graph), + const columns = this.getColumns(); + const columnSettings = this.getColumnSettings(columns); + return this.host.notify(DidChangeScrollMarkersNotification, { + context: this.getGraphSettingsIconContext(columnSettings), }); } + @debug() + private async notifyDidChangeRefsVisibility(params?: DidChangeRefsVisibilityParams) { + if (!this.host.ready || !this.host.visible) { + this.host.addPendingIpcNotification(DidChangeRefsVisibilityNotification, this._ipcNotificationMap, this); + return false; + } + + if (params == null) { + const filters = this.getFiltersByRepo(this._graph?.repoPath); + params = { + branchesVisibility: this.getBranchesVisibility(filters), + excludeRefs: this.getExcludedRefs(filters, this._graph) ?? {}, + excludeTypes: this.getExcludedTypes(filters) ?? {}, + includeOnlyRefs: undefined, + }; + + if (params?.includeOnlyRefs == null) { + const includedRefsResult = await this.getIncludedRefs(filters, this._graph, { timeout: 100 }); + params.includeOnlyRefs = includedRefsResult.refs; + void includedRefsResult.continuation?.then(refs => { + if (refs == null) return; + + void this.notifyDidChangeRefsVisibility({ ...params!, includeOnlyRefs: refs }); + }); + } + } + + return this.host.notify(DidChangeRefsVisibilityNotification, params); + } + @debug() private async notifyDidChangeConfiguration() { - if (!this.isReady || !this.visible) { - this.addPendingIpcNotification(DidChangeGraphConfigurationNotificationType); + if (!this.host.ready || !this.host.visible) { + this.host.addPendingIpcNotification( + DidChangeGraphConfigurationNotification, + this._ipcNotificationMap, + this, + ); return false; } - return this.notify(DidChangeGraphConfigurationNotificationType, { + return this.host.notify(DidChangeGraphConfigurationNotification, { config: this.getComponentConfig(), }); } @debug() private async notifyDidFetch() { - if (!this.isReady || !this.visible) { - this.addPendingIpcNotification(DidFetchNotificationType); + if (!this.host.ready || !this.host.visible) { + this.host.addPendingIpcNotification(DidFetchNotification, this._ipcNotificationMap, this); return false; } const lastFetched = await this.repository!.getLastFetched(); - return this.notify(DidFetchNotificationType, { + return this.host.notify(DidFetchNotification, { lastFetched: new Date(lastFetched), }); } @@ -1277,56 +1748,70 @@ export class GraphWebview extends WebviewBase { private async notifyDidChangeRows(sendSelectedRows: boolean = false, completionId?: string) { if (this._graph == null) return; - const data = this._graph; - return this.notify( - DidChangeRowsNotificationType, + const graph = this._graph; + return this.host.notify( + DidChangeRowsNotification, { - rows: data.rows, - avatars: Object.fromEntries(data.avatars), + rows: graph.rows, + avatars: Object.fromEntries(graph.avatars), + downstreams: Object.fromEntries(graph.downstreams), refsMetadata: this._refsMetadata != null ? Object.fromEntries(this._refsMetadata) : this._refsMetadata, + rowsStats: graph.rowsStats?.size ? Object.fromEntries(graph.rowsStats) : undefined, + rowsStatsLoading: + graph.rowsStatsDeferred?.isLoaded != null ? !graph.rowsStatsDeferred.isLoaded() : false, selectedRows: sendSelectedRows ? this._selectedRows : undefined, paging: { - startingCursor: data.paging?.startingCursor, - hasMore: data.paging?.hasMore ?? false, + startingCursor: graph.paging?.startingCursor, + hasMore: graph.paging?.hasMore ?? false, }, }, completionId, ); } + @debug({ args: false }) + private async notifyDidChangeRowsStats(graph: GitGraph) { + if (graph.rowsStats == null) return; + + return this.host.notify(DidChangeRowsStatsNotification, { + rowsStats: Object.fromEntries(graph.rowsStats), + rowsStatsLoading: graph.rowsStatsDeferred?.isLoaded != null ? !graph.rowsStatsDeferred.isLoaded() : false, + }); + } + @debug() private async notifyDidChangeWorkingTree() { - if (!this.isReady || !this.visible) { - this.addPendingIpcNotification(DidChangeWorkingTreeNotificationType); + if (!this.host.ready || !this.host.visible) { + this.host.addPendingIpcNotification(DidChangeWorkingTreeNotification, this._ipcNotificationMap, this); return false; } - return this.notify(DidChangeWorkingTreeNotificationType, { + return this.host.notify(DidChangeWorkingTreeNotification, { stats: (await this.getWorkingTreeStats()) ?? { added: 0, deleted: 0, modified: 0 }, }); } @debug() private async notifyDidChangeSelection() { - if (!this.isReady || !this.visible) { - this.addPendingIpcNotification(DidChangeSelectionNotificationType); + if (!this.host.ready || !this.host.visible) { + this.host.addPendingIpcNotification(DidChangeSelectionNotification, this._ipcNotificationMap, this); return false; } - return this.notify(DidChangeSelectionNotificationType, { + return this.host.notify(DidChangeSelectionNotification, { selection: this._selectedRows ?? {}, }); } @debug() private async notifyDidChangeSubscription() { - if (!this.isReady || !this.visible) { - this.addPendingIpcNotification(DidChangeSubscriptionNotificationType); + if (!this.host.ready || !this.host.visible) { + this.host.addPendingIpcNotification(DidChangeSubscriptionNotification, this._ipcNotificationMap, this); return false; } const [access] = await this.getGraphAccess(); - return this.notify(DidChangeSubscriptionNotificationType, { + return this.host.notify(DidChangeSubscriptionNotification, { subscription: access.subscription.current, allowed: access.allowed !== false, }); @@ -1334,78 +1819,13 @@ export class GraphWebview extends WebviewBase { @debug() private async notifyDidChangeState() { - if (!this.isReady || !this.visible) { - this.addPendingIpcNotification(DidChangeNotificationType); + if (!this.host.ready || !this.host.visible) { + this.host.addPendingIpcNotification(DidChangeNotification, this._ipcNotificationMap, this); return false; } - return this.notify(DidChangeNotificationType, { state: await this.getState() }); - } - - protected override async notify>( - type: T, - params: IpcMessageParams, - completionId?: string, - ): Promise { - const msg: IpcMessage = { - id: this.nextIpcId(), - method: type.method, - params: params, - completionId: completionId, - }; - const success = await this.postMessage(msg); - if (success) { - this._pendingIpcNotifications.clear(); - } else { - this.addPendingIpcNotification(type, msg); - } - return success; - } - - private readonly _ipcNotificationMap = new Map, () => Promise>([ - [DidChangeColumnsNotificationType, this.notifyDidChangeColumns], - [DidChangeGraphConfigurationNotificationType, this.notifyDidChangeConfiguration], - [DidChangeNotificationType, this.notifyDidChangeState], - [DidChangeRefsVisibilityNotificationType, this.notifyDidChangeRefsVisibility], - [DidChangeSelectionNotificationType, this.notifyDidChangeSelection], - [DidChangeSubscriptionNotificationType, this.notifyDidChangeSubscription], - [DidChangeWorkingTreeNotificationType, this.notifyDidChangeWorkingTree], - [DidChangeWindowFocusNotificationType, this.notifyDidChangeWindowFocus], - [DidFetchNotificationType, this.notifyDidFetch], - ]); - - private addPendingIpcNotification(type: IpcNotificationType, msg?: IpcMessage) { - if (type === DidChangeNotificationType) { - this._pendingIpcNotifications.clear(); - } else if (type.overwriteable) { - this._pendingIpcNotifications.delete(type); - } - - let msgOrFn: IpcMessage | (() => Promise) | undefined; - if (msg == null) { - msgOrFn = this._ipcNotificationMap.get(type)?.bind(this); - if (msgOrFn == null) { - debugger; - return; - } - } else { - msgOrFn = msg; - } - this._pendingIpcNotifications.set(type, msgOrFn); - } - - private sendPendingIpcNotifications() { - if (this._pendingIpcNotifications.size === 0) return; - - const ipcs = new Map(this._pendingIpcNotifications); - this._pendingIpcNotifications.clear(); - for (const msgOrFn of ipcs.values()) { - if (typeof msgOrFn === 'function') { - void msgOrFn(); - } else { - void this.postMessage(msgOrFn); - } - } + this._notifyDidChangeStateDebounced?.cancel(); + return this.host.notify(DidChangeNotification, { state: await this.getState() }); } private ensureRepositorySubscriptions(force?: boolean) { @@ -1422,10 +1842,10 @@ export class GraphWebview extends WebviewBase { this._repositoryEventsDisposable = Disposable.from( repo.onDidChange(this.onRepositoryChanged, this), - repo.startWatchingFileSystem(), + repo.watchFileSystem(1000), repo.onDidChangeFileSystem(this.onRepositoryFileSystemChanged, this), onDidChangeContext(key => { - if (key !== ContextKeys.HasConnectedRemotes) return; + if (key !== 'gitlens:repos:withHostingIntegrationsConnected') return; this.resetRefsMetadata(); this.updateRefsMetadata(); @@ -1433,6 +1853,20 @@ export class GraphWebview extends WebviewBase { ); } + private onIntegrationConnectionChanged(_e: ConnectionStateChangeEvent) { + void this.notifyDidChangeRepoConnection(); + } + + private async notifyDidChangeRepoConnection() { + void this.host.notify(DidChangeRepoConnectionNotification, { + repositories: await this.getRepositoriesState(), + }); + } + + private async getRepositoriesState(): Promise { + return formatRepositories(this.container.git.openRepositories); + } + private async ensureLastFetchedSubscription(force?: boolean) { if (!force && this._lastFetchedDisposable != null) return; @@ -1465,10 +1899,10 @@ export class GraphWebview extends WebviewBase { let firstResult: string | undefined; for (const id of search.results.keys()) { - if (graph.ids.has(id)) return id; - if (graph.skippedIds?.has(id)) continue; + const remapped = graph.remappedIds?.get(id) ?? id; + if (graph.ids.has(remapped)) return remapped; - firstResult = id; + firstResult = remapped; break; } @@ -1484,40 +1918,20 @@ export class GraphWebview extends WebviewBase { return this.container.storage.getWorkspace('graph:columns'); } - private getExcludedTypes(graph: GitGraph | undefined): GraphExcludeTypes | undefined { - if (graph == null) return undefined; - - return this.getFiltersByRepo(graph)?.excludeTypes; + private getExcludedTypes(filters: StoredGraphFilters | undefined): GraphExcludeTypes | undefined { + return filters?.excludeTypes; } - private getExcludedRefs(graph: GitGraph | undefined): Record | undefined { + private getExcludedRefs( + filters: StoredGraphFilters | undefined, + graph: GitGraph | undefined, + ): Record | undefined { if (graph == null) return undefined; - let filtersByRepo: Record | undefined; - - const storedHiddenRefs = this.container.storage.getWorkspace('graph:hiddenRefs'); - if (storedHiddenRefs != null && Object.keys(storedHiddenRefs).length !== 0) { - // Migrate hidden refs to exclude refs - filtersByRepo = this.container.storage.getWorkspace('graph:filtersByRepo') ?? {}; - - for (const id in storedHiddenRefs) { - const repoPath = getRepoPathFromBranchOrTagId(id); - - filtersByRepo[repoPath] = filtersByRepo[repoPath] ?? {}; - filtersByRepo[repoPath].excludeRefs = updateRecordValue( - filtersByRepo[repoPath].excludeRefs, - id, - storedHiddenRefs[id], - ); - } - - void this.container.storage.storeWorkspace('graph:filtersByRepo', filtersByRepo); - void this.container.storage.deleteWorkspace('graph:hiddenRefs'); - } - - const storedExcludeRefs = (filtersByRepo?.[graph.repoPath] ?? this.getFiltersByRepo(graph))?.excludeRefs; + const storedExcludeRefs = filters?.excludeRefs; if (storedExcludeRefs == null || Object.keys(storedExcludeRefs).length === 0) return undefined; + const asWebviewUri = (uri: Uri) => this.host.asWebviewUri(uri); const useAvatars = configuration.get('graph.avatars', undefined, true); const excludeRefs: GraphExcludeRefs = {}; @@ -1529,7 +1943,7 @@ export class GraphWebview extends WebviewBase { if (remote != null) { ref.avatarUrl = ( (useAvatars ? remote.provider?.avatarUri : undefined) ?? - getRemoteIconUri(this.container, remote, this._panel!.webview.asWebviewUri.bind(this)) + getRemoteIconUri(this.container, remote, asWebviewUri) )?.toString(true); } } @@ -1571,51 +1985,83 @@ export class GraphWebview extends WebviewBase { return excludeRefs; } - private getIncludeOnlyRefs(graph: GitGraph | undefined): Record | undefined { - if (graph == null) return undefined; + private async getIncludedRefs( + filters: StoredGraphFilters | undefined, + graph: GitGraph | undefined, + options?: { timeout?: number }, + ): Promise<{ refs: GraphIncludeOnlyRefs; continuation?: Promise }> { + this.cancelOperation('computeIncludedRefs'); - const storedFilters = this.getFiltersByRepo(graph); - const storedIncludeOnlyRefs = storedFilters?.includeOnlyRefs; - if (storedIncludeOnlyRefs == null || Object.keys(storedIncludeOnlyRefs).length === 0) return undefined; + if (graph == null) return { refs: {} }; - const includeRemotes = !(storedFilters?.excludeTypes?.remotes ?? false); + const branchesVisibility = this.getBranchesVisibility(filters); - const includeOnlyRefs: Record = {}; + let refs: Map; + let continuation: Promise | undefined; - for (const [key, value] of Object.entries(storedIncludeOnlyRefs)) { - let branch; - if (value.id === 'HEAD') { - branch = find(graph.branches.values(), b => b.current); - if (branch == null) continue; + switch (branchesVisibility) { + case 'smart': { + // Add the default branch and if the current branch has a PR associated with it then add the base of the PR + const current = find(graph.branches.values(), b => b.current); + if (current == null) return { refs: {} }; - includeOnlyRefs[branch.id] = { ...value, id: branch.id, name: branch.name }; - } else { - includeOnlyRefs[key] = value; - } + const cancellation = this.createCancellation('computeIncludedRefs'); - // Add the upstream branches for any local branches if there are any and we aren't excluding them - if (includeRemotes && value.type === 'head') { - branch = branch ?? graph.branches.get(value.name); - if (branch?.upstream != null && !branch.upstream.missing) { - const id = getBranchId(graph.repoPath, true, branch.upstream.name); - includeOnlyRefs[id] = { - id: id, - type: 'remote', - name: getBranchNameWithoutRemote(branch.upstream.name), - owner: getRemoteNameFromBranchName(branch.upstream.name), - }; + const [baseResult, defaultResult, targetResult] = await Promise.allSettled([ + this.container.git.getBaseBranchName(current.repoPath, current.name), + getDefaultBranchName(this.container, current.repoPath, current.getRemoteName()), + getTargetBranchName(this.container, current, { + cancellation: cancellation.token, + timeout: options?.timeout, + }), + ]); + + const baseBranchName = getSettledValue(baseResult); + const defaultBranchName = getSettledValue(defaultResult); + const targetMaybeResult = getSettledValue(targetResult); + + let targetBranchName: string | undefined; + if (targetMaybeResult?.paused) { + continuation = targetMaybeResult.value.then(async target => { + if (target == null || cancellation?.token.isCancellationRequested) return undefined; + + const refs = await this.getVisibleRefs(graph, current, { + baseOrTargetBranchName: target, + defaultBranchName: defaultBranchName, + }); + return Object.fromEntries(refs); + }); + } else { + targetBranchName = targetMaybeResult?.value; } + + refs = await this.getVisibleRefs(graph, current, { + baseOrTargetBranchName: targetBranchName ?? baseBranchName, + defaultBranchName: defaultBranchName, + }); + + break; + } + case 'current': { + const current = find(graph.branches.values(), b => b.current); + if (current == null) return { refs: {} }; + + refs = await this.getVisibleRefs(graph, current); + break; } + default: + refs = new Map(); + break; } - return includeOnlyRefs; + return { refs: Object.fromEntries(refs), continuation: continuation }; } - private getFiltersByRepo(graph: GitGraph | undefined): StoredGraphFilters | undefined { - if (graph == null) return undefined; + private getFiltersByRepo(repoPath: string | undefined): StoredGraphFilters | undefined { + if (repoPath == null) return undefined; const filters = this.container.storage.getWorkspace('graph:filtersByRepo'); - return filters?.[graph.repoPath]; + return filters?.[repoPath]; } private getColumnSettings(columns: Record | undefined): GraphColumnsSettings { @@ -1635,18 +2081,95 @@ export class GraphWebview extends WebviewBase { } private getColumnHeaderContext(columnSettings: GraphColumnsSettings): string { - const hidden: string[] = []; - for (const [name, settings] of Object.entries(columnSettings)) { - if (settings.isHidden) { - hidden.push(name); - } - } return serializeWebviewItemContext({ webviewItem: 'gitlens:graph:columns', - webviewItemValue: hidden.join(','), + webviewItemValue: this.getColumnContextItems(columnSettings).join(','), + }); + } + + private getGraphSettingsIconContext(columnsSettings?: GraphColumnsSettings): string { + return serializeWebviewItemContext({ + webviewItem: 'gitlens:graph:settings', + webviewItemValue: this.getSettingsIconContextItems(columnsSettings).join(','), }); } + private getColumnContextItems(columnSettings: GraphColumnsSettings): string[] { + const contextItems: string[] = []; + // Old column settings that didn't get cleaned up can mess with calculation of only visible column. + // All currently used ones are listed here. + const validColumns = ['author', 'changes', 'datetime', 'graph', 'message', 'ref', 'sha']; + + let visibleColumns = 0; + for (const [name, settings] of Object.entries(columnSettings)) { + if (!validColumns.includes(name)) continue; + + if (!settings.isHidden) { + visibleColumns++; + } + contextItems.push( + `column:${name}:${settings.isHidden ? 'hidden' : 'visible'}${settings.mode ? `+${settings.mode}` : ''}`, + ); + } + + if (visibleColumns > 1) { + contextItems.push('columns:canHide'); + } + + return contextItems; + } + + private getSettingsIconContextItems(columnSettings?: GraphColumnsSettings): string[] { + const contextItems: string[] = columnSettings != null ? this.getColumnContextItems(columnSettings) : []; + + if (configuration.get('graph.scrollMarkers.enabled')) { + const configurableScrollMarkerTypes: GraphScrollMarkersAdditionalTypes[] = [ + 'localBranches', + 'remoteBranches', + 'stashes', + 'tags', + 'pullRequests', + ]; + const enabledScrollMarkerTypes = configuration.get('graph.scrollMarkers.additionalTypes'); + for (const type of configurableScrollMarkerTypes) { + contextItems.push( + `scrollMarker:${type}:${enabledScrollMarkerTypes.includes(type) ? 'enabled' : 'disabled'}`, + ); + } + } + + return contextItems; + } + + private getBranchesVisibility(filters: StoredGraphFilters | undefined): GraphBranchesVisibility { + // We can't currently support all or smart branches on virtual repos + if (this.repository?.virtual) return 'current'; + if (filters == null) return configuration.get('graph.branchesVisibility'); + + let branchesVisibility: GraphBranchesVisibility; + + // Migrate `current` visibility from before `branchesVisibility` existed by looking to see if there is only one ref included + if ( + filters != null && + filters.branchesVisibility == null && + filters.includeOnlyRefs != null && + Object.keys(filters.includeOnlyRefs).length === 1 && + Object.values(filters.includeOnlyRefs)[0].name === 'HEAD' + ) { + branchesVisibility = 'current'; + if (this.repository != null) { + void this.updateFiltersByRepo(this.repository.path, { + branchesVisibility: branchesVisibility, + includeOnlyRefs: undefined, + }); + } + } else { + branchesVisibility = filters?.branchesVisibility ?? configuration.get('graph.branchesVisibility'); + } + + return branchesVisibility; + } + private getComponentConfig(): GraphComponentConfig { const config: GraphComponentConfig = { avatars: configuration.get('graph.avatars'), @@ -1655,46 +2178,45 @@ export class GraphWebview extends WebviewBase { dateStyle: configuration.get('graph.dateStyle') ?? configuration.get('defaultDateStyle'), enabledRefMetadataTypes: this.getEnabledRefMetadataTypes(), dimMergeCommits: configuration.get('graph.dimMergeCommits'), - enableMultiSelection: false, + enableMultiSelection: this.container.prereleaseOrDebugging, highlightRowsOnRefHover: configuration.get('graph.highlightRowsOnRefHover'), - minimap: configuration.get('graph.experimental.minimap.enabled'), - enabledMinimapMarkerTypes: this.getEnabledGraphMinimapMarkers(), + idLength: configuration.get('advanced.abbreviatedShaLength'), + minimap: configuration.get('graph.minimap.enabled'), + minimapDataType: configuration.get('graph.minimap.dataType'), + minimapMarkerTypes: this.getMinimapMarkerTypes(), + onlyFollowFirstParent: configuration.get('graph.onlyFollowFirstParent'), scrollRowPadding: configuration.get('graph.scrollRowPadding'), - enabledScrollMarkerTypes: this.getEnabledGraphScrollMarkers(), + scrollMarkerTypes: this.getScrollMarkerTypes(), showGhostRefsOnRowHover: configuration.get('graph.showGhostRefsOnRowHover'), showRemoteNamesOnRefs: configuration.get('graph.showRemoteNames'), - idLength: configuration.get('advanced.abbreviatedShaLength'), + sidebar: configuration.get('graph.sidebar.enabled') ?? true, }; return config; } - private getEnabledGraphScrollMarkers(): GraphScrollMarkerTypes[] { - const markersEnabled = configuration.get('graph.scrollMarkers.enabled'); - if (!markersEnabled) return []; + private getScrollMarkerTypes(): GraphScrollMarkerTypes[] { + if (!configuration.get('graph.scrollMarkers.enabled')) return []; const markers: GraphScrollMarkerTypes[] = [ - GraphScrollMarkerTypes.Selection, - GraphScrollMarkerTypes.Highlights, - GraphScrollMarkerTypes.Head, - GraphScrollMarkerTypes.Upstream, - ...(configuration.get('graph.scrollMarkers.additionalTypes') as unknown as GraphScrollMarkerTypes[]), + 'selection', + 'highlights', + 'head', + 'upstream', + ...configuration.get('graph.scrollMarkers.additionalTypes'), ]; return markers; } - private getEnabledGraphMinimapMarkers(): GraphMinimapMarkerTypes[] { - const markersEnabled = configuration.get('graph.experimental.minimap.enabled'); - if (!markersEnabled) return []; + private getMinimapMarkerTypes(): GraphMinimapMarkerTypes[] { + if (!configuration.get('graph.minimap.enabled')) return []; const markers: GraphMinimapMarkerTypes[] = [ - GraphMinimapMarkerTypes.Selection, - GraphMinimapMarkerTypes.Highlights, - GraphMinimapMarkerTypes.Head, - GraphMinimapMarkerTypes.Upstream, - ...(configuration.get( - 'graph.experimental.minimap.additionalTypes', - ) as unknown as GraphMinimapMarkerTypes[]), + 'selection', + 'highlights', + 'head', + 'upstream', + ...configuration.get('graph.minimap.additionalTypes'), ]; return markers; @@ -1702,12 +2224,13 @@ export class GraphWebview extends WebviewBase { private getEnabledRefMetadataTypes(): GraphRefMetadataType[] { const types: GraphRefMetadataType[] = []; + if (configuration.get('graph.pullRequests.enabled')) { - types.push(GraphRefMetadataTypes.PullRequest as GraphRefMetadataType); + types.push('pullRequest'); } if (configuration.get('graph.showUpstreamStatus')) { - types.push(GraphRefMetadataTypes.Upstream as GraphRefMetadataType); + types.push('upstream'); } return types; @@ -1717,9 +2240,9 @@ export class GraphWebview extends WebviewBase { let access = await this.container.git.access(PlusFeatures.Graph, this.repository?.path); this._etagSubscription = this.container.subscription.etag; - // If we don't have access to GitLens+, but the preview trial hasn't been started, auto-start it + // If we don't have access, but the preview trial hasn't been started, auto-start it if (access.allowed === false && access.subscription.current.previewTrial == null) { - await this.container.subscription.startPreviewTrial(true); + // await this.container.subscription.startPreviewTrial(); access = await this.container.git.access(PlusFeatures.Graph, this.repository?.path); } @@ -1735,7 +2258,7 @@ export class GraphWebview extends WebviewBase { const item = typeof context === 'string' ? JSON.parse(context) : context; // Add the `webview` prop to the context if its missing (e.g. when this context doesn't come through via the context menus) if (item != null && !('webview' in item)) { - item.webview = this.id; + item.webview = this.host.id; } return item; } @@ -1753,11 +2276,7 @@ export class GraphWebview extends WebviewBase { webviewItem: 'gitlens:wip', webviewItemValue: { type: 'commit', - ref: this.getRevisionReference( - this.repository.path, - GitRevision.uncommitted, - GitGraphRowType.Working, - )!, + ref: this.getRevisionReference(this.repository.path, uncommitted, 'work-dir-changes')!, }, }), }; @@ -1765,62 +2284,61 @@ export class GraphWebview extends WebviewBase { private async getState(deferRows?: boolean): Promise { if (this.container.git.repositoryCount === 0) { - return { debugging: this.container.debugging, allowed: true, repositories: [] }; - } - - if (this.trialBanner == null) { - const banners = this.container.storage.getWorkspace('graph:banners:dismissed'); - if (this.trialBanner == null) { - this.trialBanner = !banners?.['trial']; - } + return { ...this.host.baseWebviewState, allowed: true, repositories: [] }; } if (this.repository == null) { this.repository = this.container.git.getBestRepositoryOrFirst(); if (this.repository == null) { - return { debugging: this.container.debugging, allowed: true, repositories: [] }; + return { ...this.host.baseWebviewState, allowed: true, repositories: [] }; } } this._etagRepository = this.repository?.etag; - this.title = `${this.originalTitle}: ${this.repository.formattedName}`; + this.host.title = `${this.host.originalTitle}: ${this.repository.formattedName}`; const { defaultItemLimit } = configuration.get('graph'); // If we have a set of data refresh to the same set const limit = Math.max(defaultItemLimit, this._graph?.ids.size ?? defaultItemLimit); - const ref = - this._selectedId == null || this._selectedId === GitRevision.uncommitted ? 'HEAD' : this._selectedId; + const selectedId = this._selectedId; + const ref = selectedId == null || selectedId === uncommitted ? 'HEAD' : selectedId; const columns = this.getColumns(); const columnSettings = this.getColumnSettings(columns); const dataPromise = this.container.git.getCommitsForGraph( - this.repository.path, - this._panel!.webview.asWebviewUri.bind(this._panel!.webview), + this.repository.uri, + uri => this.host.asWebviewUri(uri), { include: { - stats: configuration.get('graph.experimental.minimap.enabled') || !columnSettings.changes.isHidden, + stats: + (configuration.get('graph.minimap.enabled') && + configuration.get('graph.minimap.dataType') === 'lines') || + !columnSettings.changes.isHidden, }, limit: limit, ref: ref, }, ); - // Check for GitLens+ access and working tree stats - const [accessResult, workingStatsResult] = await Promise.allSettled([ + // Check for access and working tree stats + const promises = Promise.allSettled([ this.getGraphAccess(), this.getWorkingTreeStats(), + this.repository.getBranch(), + this.repository.getLastFetched(), ]); - const [access, visibility] = getSettledValue(accessResult) ?? []; let data; if (deferRows) { queueMicrotask(async () => { const data = await dataPromise; this.setGraph(data); - this.setSelectedRows(data.id); + if (selectedId !== uncommitted) { + this.setSelectedRows(data.id); + } void this.notifyDidChangeRefsVisibility(); void this.notifyDidChangeRows(true); @@ -1828,27 +2346,95 @@ export class GraphWebview extends WebviewBase { } else { data = await dataPromise; this.setGraph(data); - this.setSelectedRows(data.id); + if (selectedId !== uncommitted) { + this.setSelectedRows(data.id); + } + } + + const [accessResult, workingStatsResult, branchResult, lastFetchedResult] = await promises; + const [access, visibility] = getSettledValue(accessResult) ?? []; + + let branchState: BranchState | undefined; + + const branch = getSettledValue(branchResult); + if (branch != null) { + branchState = { ...branch.state }; + + if (branch.upstream != null) { + branchState.upstream = branch.upstream.name; + + const cancellation = this.createCancellation('state'); + + const [remoteResult, prResult] = await Promise.allSettled([ + branch.getRemote(), + pauseOnCancelOrTimeout(branch.getAssociatedPullRequest(), cancellation.token, 100), + ]); + + const remote = getSettledValue(remoteResult); + if (remote?.provider != null) { + branchState.provider = { + name: remote.provider.name, + icon: remote.provider.icon === 'remote' ? 'cloud' : remote.provider.icon, + url: remote.provider.url({ type: RemoteResourceType.Repo }), + }; + } + + const maybePr = getSettledValue(prResult); + if (maybePr?.paused) { + const updatedBranchState = { ...branchState }; + void maybePr.value.then(pr => { + if (cancellation?.token.isCancellationRequested) return; + + if (pr != null) { + updatedBranchState.pr = serializePullRequest(pr); + void this.notifyDidChangeBranchState(updatedBranchState); + } + }); + } else { + const pr = maybePr?.value; + if (pr != null) { + branchState.pr = serializePullRequest(pr); + } + } + } } - const lastFetched = await this.repository.getLastFetched(); - const branch = await this.repository.getBranch(); + const filters = this.getFiltersByRepo(this.repository.path); + const refsVisibility: DidChangeRefsVisibilityParams = { + branchesVisibility: this.getBranchesVisibility(filters), + excludeRefs: this.getExcludedRefs(filters, data) ?? {}, + excludeTypes: this.getExcludedTypes(filters) ?? {}, + includeOnlyRefs: undefined, + }; + if (data != null) { + const includedRefsResult = await this.getIncludedRefs(filters, data, { timeout: 100 }); + refsVisibility.includeOnlyRefs = includedRefsResult.refs; + void includedRefsResult.continuation?.then(refs => { + if (refs == null) return; + + void this.notifyDidChangeRefsVisibility({ ...refsVisibility, includeOnlyRefs: refs }); + }); + } return { + ...this.host.baseWebviewState, windowFocused: this.isWindowFocused, - trialBanner: this.trialBanner, - repositories: formatRepositories(this.container.git.openRepositories), + repositories: await formatRepositories(this.container.git.openRepositories), selectedRepository: this.repository.path, selectedRepositoryVisibility: visibility, + branchesVisibility: refsVisibility.branchesVisibility, branchName: branch?.name, - lastFetched: new Date(lastFetched), + branchState: branchState, + lastFetched: new Date(getSettledValue(lastFetchedResult)!), selectedRows: this._selectedRows, subscription: access?.subscription.current, allowed: (access?.allowed ?? false) !== false, avatars: data != null ? Object.fromEntries(data.avatars) : undefined, refsMetadata: this.resetRefsMetadata() === null ? null : {}, loading: deferRows, + rowsStatsLoading: data?.rowsStatsDeferred?.isLoaded != null ? !data.rowsStatsDeferred.isLoaded() : false, rows: data?.rows, + downstreams: data != null ? Object.fromEntries(data.downstreams) : undefined, paging: data != null ? { @@ -1860,13 +2446,13 @@ export class GraphWebview extends WebviewBase { config: this.getComponentConfig(), context: { header: this.getColumnHeaderContext(columnSettings), + settings: this.getGraphSettingsIconContext(columnSettings), }, - excludeRefs: data != null ? this.getExcludedRefs(data) ?? {} : {}, - excludeTypes: this.getExcludedTypes(data) ?? {}, - includeOnlyRefs: data != null ? this.getIncludeOnlyRefs(data) ?? {} : {}, - nonce: this.cspNonce, + excludeRefs: refsVisibility.excludeRefs, + excludeTypes: refsVisibility.excludeTypes, + includeOnlyRefs: refsVisibility.includeOnlyRefs, + nonce: this.host.cspNonce, workingTreeStats: getSettledValue(workingStatsResult) ?? { added: 0, deleted: 0, modified: 0 }, - debugging: this.container.debugging, }; } @@ -1879,69 +2465,318 @@ export class GraphWebview extends WebviewBase { void this.notifyDidChangeColumns(); } - private updateExcludedRefs(graph: GitGraph | undefined, refs: GraphExcludedRef[], visible: boolean) { - if (refs == null || refs.length === 0) return; + private updateExcludedRefs(repoPath: string | undefined, refs: GraphExcludedRef[], visible: boolean) { + if (repoPath == null || !refs?.length) return; - let storedExcludeRefs: StoredGraphFilters['excludeRefs'] = this.getFiltersByRepo(graph)?.excludeRefs ?? {}; + let storedExcludeRefs: StoredGraphFilters['excludeRefs'] = this.getFiltersByRepo(repoPath)?.excludeRefs ?? {}; for (const ref of refs) { storedExcludeRefs = updateRecordValue( storedExcludeRefs, ref.id, - visible ? undefined : { id: ref.id, type: ref.type, name: ref.name, owner: ref.owner }, + visible + ? undefined + : { id: ref.id, type: ref.type as StoredGraphRefType, name: ref.name, owner: ref.owner }, ); } - void this.updateFiltersByRepo(graph, { excludeRefs: storedExcludeRefs }); + void this.updateFiltersByRepo(repoPath, { excludeRefs: storedExcludeRefs }); void this.notifyDidChangeRefsVisibility(); } - private updateFiltersByRepo(graph: GitGraph | undefined, updates: Partial) { - if (graph == null) throw new Error('Cannot save repository filters since Graph is undefined'); + private updateFiltersByRepo(repoPath: string | undefined, updates: Partial) { + if (repoPath == null) return; const filtersByRepo = this.container.storage.getWorkspace('graph:filtersByRepo'); return this.container.storage.storeWorkspace( 'graph:filtersByRepo', - updateRecordValue(filtersByRepo, graph.repoPath, { ...filtersByRepo?.[graph.repoPath], ...updates }), + updateRecordValue(filtersByRepo, repoPath, { ...filtersByRepo?.[repoPath], ...updates }), ); } - private updateIncludeOnlyRefs(graph: GitGraph | undefined, refs: GraphIncludeOnlyRef[] | undefined) { - let storedIncludeOnlyRefs: StoredGraphFilters['includeOnlyRefs']; + private async getSmartRefs( + graph: GitGraph, + { + refs, + currentBranch, + defaultBranchName, + associatedPullRequest, + }: { + refs: GraphIncludeOnlyRef[]; + currentBranch: GitBranch | undefined; + defaultBranchName: string | undefined; + associatedPullRequest: PullRequest | undefined; + }, + ): Promise { + let includeDefault = true; + + const pr = associatedPullRequest; + if (pr?.refs != null) { + let prBranch; + + const remote = find(graph.remotes.values(), r => r.matches(pr.refs!.base.url)); + if (remote != null) { + prBranch = graph.branches.get(`${remote.name}/${pr.refs.base.branch}`); + } + + if (prBranch != null) { + refs.push({ + id: prBranch.id, + name: prBranch.name, + type: 'remote', + }); + + includeDefault = false; + } + } + + if (includeDefault) { + if (defaultBranchName != null && defaultBranchName !== currentBranch?.name) { + const defaultBranch = graph.branches.get(defaultBranchName); + if (defaultBranch != null) { + if (defaultBranch.remote) { + refs.push({ + id: defaultBranch.id, + name: defaultBranch.name, + type: 'remote', + }); + + const localDefault = await getLocalBranchByUpstream( + this.repository!, + defaultBranchName, + graph.branches, + ); + if (localDefault != null) { + refs.push({ + id: localDefault.id, + name: localDefault.name, + type: 'head', + }); + } + } else { + refs.push({ + id: defaultBranch.id, + name: defaultBranch.name, + type: 'head', + }); + + if (defaultBranch.upstream != null && !defaultBranch.upstream.missing) { + refs.push({ + id: getBranchId(graph.repoPath, true, defaultBranch.upstream.name), + name: defaultBranch.upstream.name, + type: 'remote', + }); + } + } + } + } + } + + return refs; + } + + private async getVisibleRefs( + graph: GitGraph, + currentBranch: GitBranch, + options?: { + defaultBranchName: string | undefined; + baseOrTargetBranchName?: string | undefined; + associatedPullRequest?: PullRequest | undefined; + }, + ): Promise> { + const refs = new Map([ + currentBranch.remote + ? [ + currentBranch.id, + { + id: currentBranch.id, + type: 'remote', + name: currentBranch.getNameWithoutRemote(), + owner: currentBranch.getRemoteName(), + }, + ] + : [ + currentBranch.id, + { + id: currentBranch.id, + type: 'head', + name: currentBranch.name, + }, + ], + ]); + + if (currentBranch.upstream != null && !currentBranch.upstream.missing) { + const id = getBranchId(graph.repoPath, true, currentBranch.upstream.name); + if (!refs.has(id)) { + refs.set(id, { + id: id, + type: 'remote', + name: getBranchNameWithoutRemote(currentBranch.upstream.name), + owner: currentBranch.getRemoteName(), + }); + } + } + + let includeDefault = true; + + const baseBranchName = options?.baseOrTargetBranchName; + if (baseBranchName != null && baseBranchName !== currentBranch?.name) { + const baseBranch = graph.branches.get(baseBranchName); + if (baseBranch != null) { + includeDefault = false; + + if (baseBranch.remote) { + if (!refs.has(baseBranch.id)) { + refs.set(baseBranch.id, { + id: baseBranch.id, + type: 'remote', + name: baseBranch.getNameWithoutRemote(), + owner: baseBranch.getRemoteName(), + }); + } + } else if (baseBranch.upstream != null && !baseBranch.upstream.missing) { + const id = getBranchId(graph.repoPath, true, baseBranch.upstream.name); + if (!refs.has(baseBranch.id)) { + refs.set(id, { + id: id, + type: 'remote', + name: getBranchNameWithoutRemote(baseBranch.upstream.name), + owner: baseBranch.getRemoteName(), + }); + } + } + } + } + + const pr = options?.associatedPullRequest; + if (pr?.refs != null) { + let prBranch; + + const remote = find(graph.remotes.values(), r => r.matches(pr.refs!.base.url)); + if (remote != null) { + prBranch = graph.branches.get(`${remote.name}/${pr.refs.base.branch}`); + } + + if (prBranch != null) { + includeDefault = false; + + if (!refs.has(prBranch.id)) { + refs.set(prBranch.id, { + id: prBranch.id, + type: 'remote', + name: prBranch.getNameWithoutRemote(), + owner: prBranch.getRemoteName(), + }); + } + } + } + + if (includeDefault) { + const defaultBranchName = options?.defaultBranchName; + if (defaultBranchName != null && defaultBranchName !== currentBranch?.name) { + const defaultBranch = graph.branches.get(defaultBranchName); + if (defaultBranch != null) { + if (defaultBranch.remote) { + if (!refs.has(defaultBranch.id)) { + refs.set(defaultBranch.id, { + id: defaultBranch.id, + type: 'remote', + name: defaultBranch.getNameWithoutRemote(), + owner: defaultBranch.getRemoteName(), + }); + } + + const localDefault = await getLocalBranchByUpstream( + this.repository!, + defaultBranchName, + graph.branches, + ); + if (localDefault != null) { + if (!refs.has(localDefault.id)) { + refs.set(localDefault.id, { + id: localDefault.id, + type: 'head', + name: localDefault.name, + }); + } + } + } else { + if (!refs.has(defaultBranch.id)) { + refs.set(defaultBranch.id, { + id: defaultBranch.id, + type: 'head', + name: defaultBranch.name, + }); + } + + if (defaultBranch.upstream != null && !defaultBranch.upstream.missing) { + const id = getBranchId(graph.repoPath, true, defaultBranch.upstream.name); + if (!refs.has(defaultBranch.id)) { + refs.set(id, { + id: id, + type: 'remote', + name: getBranchNameWithoutRemote(defaultBranch.upstream.name), + owner: defaultBranch.getRemoteName(), + }); + } + } + } + } + } + } + + return refs; + } + + private updateIncludeOnlyRefs( + repoPath: string | undefined, + { branchesVisibility, refs }: UpdateIncludedRefsParams, + ) { + if (repoPath == null) return; - if (refs == null || refs.length === 0) { - if (this.getFiltersByRepo(graph)?.includeOnlyRefs == null) return; + let storedIncludeOnlyRefs: StoredGraphFilters['includeOnlyRefs']; + if (!refs?.length) { storedIncludeOnlyRefs = undefined; } else { storedIncludeOnlyRefs = {}; for (const ref of refs) { storedIncludeOnlyRefs[ref.id] = { id: ref.id, - type: ref.type, + type: ref.type as StoredGraphRefType, name: ref.name, owner: ref.owner, }; } } - void this.updateFiltersByRepo(graph, { includeOnlyRefs: storedIncludeOnlyRefs }); + void this.updateFiltersByRepo(repoPath, { + branchesVisibility: branchesVisibility, + includeOnlyRefs: storedIncludeOnlyRefs, + }); void this.notifyDidChangeRefsVisibility(); } - private updateExcludedType(graph: GitGraph | undefined, { key, value }: UpdateExcludeTypeParams) { - let excludeTypes = this.getFiltersByRepo(graph)?.excludeTypes; - if ((excludeTypes == null || Object.keys(excludeTypes).length === 0) && value === false) { + private updateExcludedTypes(repoPath: string | undefined, { key, value }: UpdateExcludeTypesParams) { + if (repoPath == null) return; + + let excludeTypes = this.getFiltersByRepo(repoPath)?.excludeTypes; + if ((excludeTypes == null || !Object.keys(excludeTypes).length) && value === false) { return; } excludeTypes = updateRecordValue(excludeTypes, key, value); - void this.updateFiltersByRepo(graph, { excludeTypes: excludeTypes }); + void this.updateFiltersByRepo(repoPath, { excludeTypes: excludeTypes }); void this.notifyDidChangeRefsVisibility(); } + private resetHoverCache() { + this._hoverCache.clear(); + this.cancelOperation('hover'); + } + private resetRefsMetadata(): null | undefined { - this._refsMetadata = getContext(ContextKeys.HasConnectedRemotes) ? undefined : null; + this._refsMetadata = getContext('gitlens:repos:withHostingIntegrationsConnected') ? undefined : null; return this._refsMetadata; } @@ -1952,16 +2787,15 @@ export class GraphWebview extends WebviewBase { private resetSearchState() { this._search = undefined; - this._searchCancellation?.dispose(); - this._searchCancellation = undefined; + this.cancelOperation('search'); } private setSelectedRows(id: string | undefined) { if (this._selectedId === id) return; this._selectedId = id; - if (id === GitRevision.uncommitted) { - id = GitGraphRowType.Working; + if (id === uncommitted) { + id = 'work-dir-changes' satisfies GitGraphRowType; } this._selectedRows = id != null ? { [id]: true } : undefined; } @@ -1969,8 +2803,12 @@ export class GraphWebview extends WebviewBase { private setGraph(graph: GitGraph | undefined) { this._graph = graph; if (graph == null) { + this.resetHoverCache(); this.resetRefsMetadata(); this.resetSearchState(); + this.cancelOperation('computeIncludedRefs'); + } else { + void graph.rowsStatsDeferred?.promise.then(() => void this.notifyDidChangeRowsStats(graph)); } } @@ -1980,57 +2818,52 @@ export class GraphWebview extends WebviewBase { if (updatedGraph != null) { this.setGraph(updatedGraph); - if (search?.paging?.hasMore) { - const lastId = last(search.results)?.[0]; - if (lastId != null && (updatedGraph.ids.has(lastId) || updatedGraph.skippedIds?.has(lastId))) { - queueMicrotask(() => void this.onSearch({ search: search.query, more: true })); - } - } - } else { - debugger; - } - } + if (!search?.paging?.hasMore) return; - private updateStatusBar() { - const enabled = - configuration.get('graph.statusBar.enabled') && getContext(ContextKeys.Enabled) && arePlusFeaturesEnabled(); - if (enabled) { - if (this._statusBarItem == null) { - this._statusBarItem = window.createStatusBarItem('gitlens.graph', StatusBarAlignment.Left, 10000 - 3); - this._statusBarItem.name = 'GitLens Commit Graph'; - this._statusBarItem.command = Commands.ShowGraphPage; - this._statusBarItem.text = '$(gitlens-graph)'; - this._statusBarItem.tooltip = new MarkdownString('Visualize commits on the Commit Graph ✨'); - this._statusBarItem.accessibilityInformation = { - label: `Show the GitLens Commit Graph`, - }; + const lastId = last(search.results)?.[0]; + if (lastId == null) return; + + const remapped = updatedGraph.remappedIds?.get(lastId) ?? lastId; + if (updatedGraph.ids.has(remapped)) { + queueMicrotask(async () => { + try { + const results = await this.getSearchResults({ search: search.query, more: true }); + void this.host.notify(DidSearchNotification, results); + } catch (ex) { + if (ex instanceof CancellationError) return; + + void this.host.notify(DidSearchNotification, { + results: { + error: ex instanceof GitSearchError ? 'Invalid search pattern' : 'Unexpected error', + }, + }); + } + }); } - this._statusBarItem.show(); } else { - this._statusBarItem?.dispose(); - this._statusBarItem = undefined; + debugger; } } - @debug() + @log() private fetch(item?: GraphItemContext) { - const ref = this.getGraphItemRef(item, 'branch'); + const ref = item != null ? this.getGraphItemRef(item, 'branch') : undefined; void RepoActions.fetch(this.repository, ref); } - @debug() + @log() private pull(item?: GraphItemContext) { - const ref = this.getGraphItemRef(item, 'branch'); + const ref = item != null ? this.getGraphItemRef(item, 'branch') : undefined; void RepoActions.pull(this.repository, ref); } - @debug() + @log() private push(item?: GraphItemContext) { - const ref = this.getGraphItemRef(item); + const ref = item != null ? this.getGraphItemRef(item) : undefined; void RepoActions.push(this.repository, undefined, ref); } - @debug() + @log() private createBranch(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2038,7 +2871,7 @@ export class GraphWebview extends WebviewBase { return BranchActions.create(ref.repoPath, ref); } - @debug() + @log() private deleteBranch(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2048,7 +2881,7 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() private mergeBranchInto(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2058,17 +2891,24 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() private openBranchOnRemote(item?: GraphItemContext, clipboard?: boolean) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; + let remote; + if (ref.remote) { + remote = getRemoteNameFromBranchName(ref.name); + } else if (ref.upstream != null) { + remote = getRemoteNameFromBranchName(ref.upstream.name); + } + return executeCommand(Commands.OpenOnRemote, { repoPath: ref.repoPath, resource: { type: RemoteResourceType.Branch, branch: ref.name, }, - remote: ref.upstream?.name, + remote: remote, clipboard: clipboard, }); } @@ -2076,7 +2916,17 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() + private publishBranch(item?: GraphItemContext) { + if (isGraphItemRefContext(item, 'branch')) { + const { ref } = item.webviewItemValue; + return RepoActions.push(ref.repoPath, undefined, ref); + } + + return Promise.resolve(); + } + + @log() private rebase(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2084,14 +2934,14 @@ export class GraphWebview extends WebviewBase { return RepoActions.rebase(ref.repoPath, ref); } - @debug() + @log() private rebaseToRemote(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; if (ref.upstream != null) { return RepoActions.rebase( ref.repoPath, - GitReference.create(ref.upstream.name, ref.repoPath, { + createReference(ref.upstream.name, ref.repoPath, { refType: 'branch', name: ref.upstream.name, remote: true, @@ -2103,7 +2953,7 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() private renameBranch(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2113,7 +2963,7 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() private cherryPick(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); @@ -2121,25 +2971,29 @@ export class GraphWebview extends WebviewBase { return RepoActions.cherryPick(ref.repoPath, ref); } - @debug() + @log() private async copy(item?: GraphItemContext) { - const ref = this.getGraphItemRef(item); - if (ref != null) { - await env.clipboard.writeText( - ref.refType === 'revision' && ref.message ? `${ref.name}: ${ref.message}` : ref.name, - ); + let data; + + const { selection } = this.getGraphItemRefs(item); + if (selection.length) { + data = selection + .map(r => (r.refType === 'revision' && r.message ? `${r.name}: ${r.message.trim()}` : r.name)) + .join('\n'); } else if (isGraphItemTypedContext(item, 'contributor')) { const { name, email } = item.webviewItemValue; - await env.clipboard.writeText(`${name}${email ? ` <${email}>` : ''}`); + data = `${name}${email ? ` <${email}>` : ''}`; } else if (isGraphItemTypedContext(item, 'pullrequest')) { const { url } = item.webviewItemValue; - await env.clipboard.writeText(url); + data = url; } - return Promise.resolve(); + if (data != null) { + await env.clipboard.writeText(data); + } } - @debug() + @log() private copyMessage(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2151,56 +3005,54 @@ export class GraphWebview extends WebviewBase { }); } - @debug() + @log() private async copySha(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); let sha = ref.ref; - if (!GitRevision.isSha(sha)) { + if (!isSha(sha)) { sha = await this.container.git.resolveReference(ref.repoPath, sha, undefined, { force: true }); } - return executeCommand(Commands.CopyShaToClipboard, { + return executeCommand(Commands.CopyShaToClipboard, { sha: sha, }); } - @debug() + @log() private openInDetailsView(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); - return executeCommand(Commands.ShowInDetailsView, { - repoPath: ref.repoPath, - refs: [ref.ref], - }); + if (this.host.isHost('view')) { + return void showGraphDetailsView(ref, { preserveFocus: true, preserveVisibility: false }); + } + + return executeCommand(Commands.ShowInDetailsView, { ref: ref }); } - @debug() + @log() private openSCM(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); - return executeCoreCommand(CoreCommands.ShowSCM); + return executeCoreCommand('workbench.view.scm'); } - @debug() + @log() private openCommitOnRemote(item?: GraphItemContext, clipboard?: boolean) { - const ref = this.getGraphItemRef(item, 'revision'); - if (ref == null) return Promise.resolve(); + const { selection } = this.getGraphItemRefs(item, 'revision'); + if (selection == null) return Promise.resolve(); return executeCommand(Commands.OpenOnRemote, { - repoPath: ref.repoPath, - resource: { - type: RemoteResourceType.Commit, - sha: ref.ref, - }, + repoPath: selection[0].repoPath, + resource: selection.map(r => ({ type: RemoteResourceType.Commit, sha: r.ref })), clipboard: clipboard, }); } - @debug() + @log() private copyDeepLinkToBranch(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2210,7 +3062,7 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() private copyDeepLinkToCommit(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); @@ -2218,7 +3070,7 @@ export class GraphWebview extends WebviewBase { return executeCommand(Commands.CopyDeepLinkToCommit, { refOrRepoPath: ref }); } - @debug() + @log() private copyDeepLinkToRepo(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2233,7 +3085,7 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() private copyDeepLinkToTag(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'tag')) { const { ref } = item.webviewItemValue; @@ -2243,14 +3095,29 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() + private async shareAsCloudPatch(item?: GraphItemContext) { + const ref = this.getGraphItemRef(item, 'revision') ?? this.getGraphItemRef(item, 'stash'); + + if (ref == null) return Promise.resolve(); + + const { title, description } = splitGitCommitMessage(ref.message); + return executeCommand(Commands.CreateCloudPatch, { + to: ref.ref, + repoPath: ref.repoPath, + title: title, + description: description, + }); + } + + @log() private resetCommit(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); return RepoActions.reset( ref.repoPath, - GitReference.create(`${ref.ref}^`, ref.repoPath, { + createReference(`${ref.ref}^`, ref.repoPath, { refType: 'revision', name: `${ref.name}^`, message: ref.message, @@ -2258,7 +3125,7 @@ export class GraphWebview extends WebviewBase { ); } - @debug() + @log() private resetToCommit(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); @@ -2266,7 +3133,18 @@ export class GraphWebview extends WebviewBase { return RepoActions.reset(ref.repoPath, ref); } - @debug() + @log() + private resetToTip(item?: GraphItemContext) { + const ref = this.getGraphItemRef(item, 'branch'); + if (ref == null) return Promise.resolve(); + + return RepoActions.reset( + ref.repoPath, + createReference(ref.ref, ref.repoPath, { refType: 'revision', name: ref.name }), + ); + } + + @log() private revertCommit(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); @@ -2274,7 +3152,7 @@ export class GraphWebview extends WebviewBase { return RepoActions.revert(ref.repoPath, ref); } - @debug() + @log() private switchTo(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2282,7 +3160,7 @@ export class GraphWebview extends WebviewBase { return RepoActions.switchTo(ref.repoPath, ref); } - @debug() + @log() private hideRef(item?: GraphItemContext, options?: { group?: boolean; remote?: boolean }) { let refs; if (options?.group && isGraphItemRefGroupContext(item)) { @@ -2296,7 +3174,7 @@ export class GraphWebview extends WebviewBase { if (refs != null) { this.updateExcludedRefs( - this._graph, + this._graph?.repoPath, refs.map(r => { const remoteBranch = r.refType === 'branch' && r.remote; return { @@ -2313,7 +3191,7 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() private switchToAnother(item?: GraphItemContext | unknown) { const ref = this.getGraphItemRef(item); if (ref == null) return RepoActions.switchTo(this.repository?.path); @@ -2321,29 +3199,15 @@ export class GraphWebview extends WebviewBase { return RepoActions.switchTo(ref.repoPath); } - @debug() + @log() private async undoCommit(item?: GraphItemContext) { - const ref = this.getGraphItemRef(item); + const ref = this.getGraphItemRef(item, 'revision'); if (ref == null) return Promise.resolve(); - const repo = await this.container.git.getOrOpenScmRepository(ref.repoPath); - const commit = await repo?.getCommit('HEAD'); - - if (commit?.hash !== ref.ref) { - void window.showWarningMessage( - `Commit ${GitReference.toString(ref, { - capitalize: true, - icon: false, - })} cannot be undone, because it is no longer the most recent commit.`, - ); - - return; - } - - return void executeCoreGitCommand(CoreGitCommands.UndoCommit, ref.repoPath); + await undoCommit(this.container, ref); } - @debug() + @log() private saveStash(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2351,7 +3215,7 @@ export class GraphWebview extends WebviewBase { return StashActions.push(ref.repoPath); } - @debug() + @log() private applyStash(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'stash'); if (ref == null) return Promise.resolve(); @@ -2359,15 +3223,23 @@ export class GraphWebview extends WebviewBase { return StashActions.apply(ref.repoPath, ref); } - @debug() + @log() private deleteStash(item?: GraphItemContext) { const ref = this.getGraphItemRef(item, 'stash'); if (ref == null) return Promise.resolve(); - return StashActions.drop(ref.repoPath, ref); + return StashActions.drop(ref.repoPath, [ref]); } - @debug() + @log() + private renameStash(item?: GraphItemContext) { + const ref = this.getGraphItemRef(item, 'stash'); + if (ref == null) return Promise.resolve(); + + return StashActions.rename(ref.repoPath, ref); + } + + @log() private async createTag(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2375,7 +3247,7 @@ export class GraphWebview extends WebviewBase { return TagActions.create(ref.repoPath, ref); } - @debug() + @log() private deleteTag(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'tag')) { const { ref } = item.webviewItemValue; @@ -2385,7 +3257,7 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() private async createWorktree(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2393,7 +3265,7 @@ export class GraphWebview extends WebviewBase { return WorktreeActions.create(ref.repoPath, undefined, ref); } - @debug() + @log() private async createPullRequest(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2430,13 +3302,64 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() + private openPullRequest(item?: GraphItemContext) { + if (isGraphItemTypedContext(item, 'pullrequest')) { + const pr = item.webviewItemValue; + return executeActionCommand('openPullRequest', { + repoPath: pr.repoPath, + provider: { + id: pr.provider.id, + name: pr.provider.name, + domain: pr.provider.domain, + }, + pullRequest: { + id: pr.id, + url: pr.url, + }, + }); + } + + return Promise.resolve(); + } + + @log() + private openPullRequestChanges(item?: GraphItemContext) { + if (isGraphItemTypedContext(item, 'pullrequest')) { + const pr = item.webviewItemValue; + if (pr.refs?.base != null && pr.refs.head != null) { + const refs = getComparisonRefsForPullRequest(pr.repoPath, pr.refs); + return openComparisonChanges( + this.container, + { + repoPath: refs.repoPath, + lhs: refs.base.ref, + rhs: refs.head.ref, + }, + { title: `Changes in Pull Request #${pr.id}` }, + ); + } + } + + return Promise.resolve(); + } + + @log() + private openPullRequestComparison(item?: GraphItemContext) { + if (isGraphItemTypedContext(item, 'pullrequest')) { + const pr = item.webviewItemValue; + if (pr.refs?.base != null && pr.refs.head != null) { + const refs = getComparisonRefsForPullRequest(pr.repoPath, pr.refs); + return this.container.searchAndCompareView.compare(refs.repoPath, refs.head, refs.base); + } + } + + return Promise.resolve(); + } + + @log() private openPullRequestOnRemote(item?: GraphItemContext, clipboard?: boolean) { - if ( - isGraphItemContext(item) && - typeof item.webviewItemValue === 'object' && - item.webviewItemValue.type === 'pullrequest' - ) { + if (isGraphItemTypedContext(item, 'pullrequest')) { const { url } = item.webviewItemValue; return executeCommand(Commands.OpenPullRequestOnRemote, { pr: { url: url }, @@ -2447,7 +3370,7 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() private async compareAncestryWithWorking(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2458,22 +3381,69 @@ export class GraphWebview extends WebviewBase { const commonAncestor = await this.container.git.getMergeBase(ref.repoPath, branch.ref, ref.ref); if (commonAncestor == null) return undefined; - return this.container.searchAndCompareView.compare( - ref.repoPath, - { ref: commonAncestor, label: `ancestry with ${ref.ref} (${GitRevision.shorten(commonAncestor)})` }, - '', - ); + return this.container.searchAndCompareView.compare(ref.repoPath, '', { + ref: commonAncestor, + label: `${branch.ref} (${shortenRevision(commonAncestor)})`, + }); } - @debug() - private compareHeadWith(item?: GraphItemContext) { + @log() + private async compareHeadWith(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); - return this.container.searchAndCompareView.compare(ref.repoPath, 'HEAD', ref.ref); + const [ref1, ref2] = await getOrderedComparisonRefs(this.container, ref.repoPath, 'HEAD', ref.ref); + return this.container.searchAndCompareView.compare(ref.repoPath, ref1, ref2); } - @debug() + @log() + private compareBranchWithHead(item?: GraphItemContext) { + const ref = this.getGraphItemRef(item); + if (ref == null) return Promise.resolve(); + + return this.container.searchAndCompareView.compare(ref.repoPath, ref.ref, 'HEAD'); + } + + @log() + private async compareWithMergeBase(item?: GraphItemContext) { + const ref = this.getGraphItemRef(item); + if (ref == null) return Promise.resolve(); + + const branch = await this.container.git.getBranch(ref.repoPath); + if (branch == null) return undefined; + + const commonAncestor = await this.container.git.getMergeBase(ref.repoPath, branch.ref, ref.ref); + if (commonAncestor == null) return undefined; + + return this.container.searchAndCompareView.compare(ref.repoPath, ref.ref, { + ref: commonAncestor, + label: `${branch.ref} (${shortenRevision(commonAncestor)})`, + }); + } + + @log() + private async openChangedFileDiffsWithMergeBase(item?: GraphItemContext) { + const ref = this.getGraphItemRef(item); + if (ref == null) return Promise.resolve(); + + const branch = await this.container.git.getBranch(ref.repoPath); + if (branch == null) return undefined; + + const commonAncestor = await this.container.git.getMergeBase(ref.repoPath, branch.ref, ref.ref); + if (commonAncestor == null) return undefined; + + return openComparisonChanges( + this.container, + { repoPath: ref.repoPath, lhs: commonAncestor, rhs: ref.ref }, + { + title: `Changes between ${branch.ref} (${shortenRevision(commonAncestor)}) ${ + GlyphChars.ArrowLeftRightLong + } ${shortenRevision(ref.ref, { strings: { working: 'Working Tree' } })}`, + }, + ); + } + + @log() private compareWithUpstream(item?: GraphItemContext) { if (isGraphItemRefContext(item, 'branch')) { const { ref } = item.webviewItemValue; @@ -2485,7 +3455,7 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() private compareWorkingWith(item?: GraphItemContext) { const ref = this.getGraphItemRef(item); if (ref == null) return Promise.resolve(); @@ -2493,7 +3463,98 @@ export class GraphWebview extends WebviewBase { return this.container.searchAndCompareView.compare(ref.repoPath, '', ref.ref); } - @debug() + private copyWorkingChangesToWorktree(item?: GraphItemContext) { + const ref = this.getGraphItemRef(item); + if (ref == null) return Promise.resolve(); + + return WorktreeActions.copyChangesToWorktree('working-tree', ref.repoPath); + } + + @log() + private async openFiles(item?: GraphItemContext) { + const commit = await this.getCommitFromGraphItemRef(item); + if (commit == null) return; + + return openFiles(commit); + } + + @log() + private async openAllChanges(item?: GraphItemContext, individually?: boolean) { + const commit = await this.getCommitFromGraphItemRef(item); + if (commit == null) return; + + if (individually) { + return openAllChangesIndividually(commit); + } + return openAllChanges(commit); + } + + @log() + private async openAllChangesWithWorking(item?: GraphItemContext, individually?: boolean) { + const commit = await this.getCommitFromGraphItemRef(item); + if (commit == null) return; + + if (individually) { + return openAllChangesWithWorkingIndividually(commit); + } + return openAllChangesWithWorking(commit); + } + + @log() + private async openRevisions(item?: GraphItemContext) { + const commit = await this.getCommitFromGraphItemRef(item); + if (commit == null) return; + + return openFilesAtRevision(commit); + } + + @log() + private async openOnlyChangedFiles(item?: GraphItemContext) { + const commit = await this.getCommitFromGraphItemRef(item); + if (commit == null) return; + + return openOnlyChangedFiles(commit); + } + + @log() + private async openInWorktree(item?: GraphItemContext) { + if (isGraphItemRefContext(item, 'branch')) { + const { ref } = item.webviewItemValue; + await executeGitCommand({ + command: 'switch', + state: { + repos: ref.repoPath, + reference: ref, + skipWorktreeConfirmations: true, + }, + }); + } + } + + @log() + private async openWorktree(item?: GraphItemContext, options?: { location?: OpenWorkspaceLocation }) { + if (isGraphItemRefContext(item, 'branch')) { + const { ref } = item.webviewItemValue; + if (ref.id == null) return; + + let worktreesByBranch; + if (ref.repoPath === this._graph?.repoPath) { + worktreesByBranch = this._graph?.worktreesByBranch; + } else { + const repo = this.container.git.getRepository(ref.repoPath); + if (repo == null) return; + + worktreesByBranch = await getWorktreesByBranch(repo); + } + + const worktree = worktreesByBranch?.get(ref.id); + if (worktree == null) return; + + openWorkspace(worktree.uri, options); + } + } + + @log() private addAuthor(item?: GraphItemContext) { if (isGraphItemTypedContext(item, 'contributor')) { const { repoPath, name, email, current } = item.webviewItemValue; @@ -2506,7 +3567,7 @@ export class GraphWebview extends WebviewBase { return Promise.resolve(); } - @debug() + @log() private async toggleColumn(name: GraphColumnName, visible: boolean) { let columns = this.container.storage.getWorkspace('graph:columns'); let column = columns?.[name]; @@ -2526,6 +3587,50 @@ export class GraphWebview extends WebviewBase { } } + @log() + private async toggleScrollMarker(type: GraphScrollMarkersAdditionalTypes, enabled: boolean) { + let scrollMarkers = configuration.get('graph.scrollMarkers.additionalTypes'); + let updated = false; + if (enabled && !scrollMarkers.includes(type)) { + scrollMarkers = scrollMarkers.concat(type); + updated = true; + } else if (!enabled && scrollMarkers.includes(type)) { + scrollMarkers = scrollMarkers.filter(marker => marker !== type); + updated = true; + } + + if (updated) { + await configuration.updateEffective('graph.scrollMarkers.additionalTypes', scrollMarkers); + void this.notifyDidChangeScrollMarkers(); + } + } + + @log() + private async setColumnMode(name: GraphColumnName, mode?: string) { + let columns = this.container.storage.getWorkspace('graph:columns'); + let column = columns?.[name]; + if (column != null) { + column.mode = mode; + } else { + column = { mode: mode }; + } + + columns = updateRecordValue(columns, name, column); + await this.container.storage.storeWorkspace('graph:columns', columns); + + void this.notifyDidChangeColumns(); + } + + private getCommitFromGraphItemRef(item?: GraphItemContext): Promise { + let ref: GitRevisionReference | GitStashReference | undefined = this.getGraphItemRef(item, 'revision'); + if (ref != null) return this.container.git.getCommit(ref.repoPath, ref.ref); + + ref = this.getGraphItemRef(item, 'stash'); + if (ref != null) return this.container.git.getCommit(ref.repoPath, ref.ref); + + return Promise.resolve(undefined); + } + private getGraphItemRef(item?: GraphItemContext | unknown | undefined): GitReference | undefined; private getGraphItemRef( item: GraphItemContext | unknown | undefined, @@ -2564,98 +3669,119 @@ export class GraphWebview extends WebviewBase { return isGraphItemRefContext(item) ? item.webviewItemValue.ref : undefined; } } -} - -function formatRepositories(repositories: Repository[]): GraphRepository[] { - if (repositories.length === 0) return []; - - return repositories.map(r => ({ - formattedName: r.formattedName, - id: r.id, - name: r.name, - path: r.path, - isVirtual: r.provider.virtual, - })); -} -export type GraphItemContext = WebviewItemContext; -export type GraphItemContextValue = GraphColumnsContextValue | GraphItemTypedContextValue | GraphItemRefContextValue; - -export type GraphItemGroupContext = WebviewItemGroupContext; -export type GraphItemGroupContextValue = GraphItemRefGroupContextValue; - -export type GraphItemRefContext = WebviewItemContext; -export type GraphItemRefContextValue = - | GraphBranchContextValue - | GraphCommitContextValue - | GraphStashContextValue - | GraphTagContextValue; + private getGraphItemRefs( + item: GraphItemContext | unknown | undefined, + refType: 'branch', + ): GraphItemRefs; + private getGraphItemRefs( + item: GraphItemContext | unknown | undefined, + refType: 'revision', + ): GraphItemRefs; + private getGraphItemRefs( + item: GraphItemContext | unknown | undefined, + refType: 'stash', + ): GraphItemRefs; + private getGraphItemRefs( + item: GraphItemContext | unknown | undefined, + refType: 'tag', + ): GraphItemRefs; + private getGraphItemRefs(item: GraphItemContext | unknown | undefined): GraphItemRefs; + private getGraphItemRefs( + item: GraphItemContext | unknown, + refType?: 'branch' | 'revision' | 'stash' | 'tag', + ): GraphItemRefs { + if (item == null) return { active: undefined, selection: [] }; -export type GraphItemRefGroupContext = WebviewItemGroupContext; -export interface GraphItemRefGroupContextValue { - type: 'refGroup'; - refs: (GitBranchReference | GitTagReference)[]; -} + switch (refType) { + case 'branch': + if (!isGraphItemRefContext(item, 'branch') && !isGraphItemTypedContext(item, 'upstreamStatus')) + return { active: undefined, selection: [] }; + break; + case 'revision': + if (!isGraphItemRefContext(item, 'revision')) return { active: undefined, selection: [] }; + break; + case 'stash': + if (!isGraphItemRefContext(item, 'stash')) return { active: undefined, selection: [] }; + break; + case 'tag': + if (!isGraphItemRefContext(item, 'tag')) return { active: undefined, selection: [] }; + break; + default: + if (!isGraphItemRefContext(item)) return { active: undefined, selection: [] }; + } -export type GraphItemTypedContext = WebviewItemContext; -export type GraphItemTypedContextValue = - | GraphContributorContextValue - | GraphPullRequestContextValue - | GraphUpstreamStatusContextValue; + const selection = item.webviewItemsValues?.map(i => i.webviewItemValue.ref) ?? []; + if (!selection.length) { + selection.push(item.webviewItemValue.ref); + } + return { active: item.webviewItemValue.ref, selection: selection }; + } -export type GraphColumnsContextValue = string; + private createCancellation(op: CancellableOperations) { + this.cancelOperation(op); -export interface GraphContributorContextValue { - type: 'contributor'; - repoPath: string; - name: string; - email: string | undefined; - current?: boolean; -} + const cancellation = new CancellationTokenSource(); + this._cancellations.set(op, cancellation); + return cancellation; + } -export interface GraphPullRequestContextValue { - type: 'pullrequest'; - id: string; - url: string; + private cancelOperation(op: CancellableOperations) { + this._cancellations.get(op)?.cancel(); + this._cancellations.delete(op); + } } -export interface GraphBranchContextValue { - type: 'branch'; - ref: GitBranchReference; -} +type GraphItemRefs = { + active: T | undefined; + selection: T[]; +}; -export interface GraphCommitContextValue { - type: 'commit'; - ref: GitRevisionReference; -} +async function formatRepositories(repositories: Repository[]): Promise { + if (repositories.length === 0) return Promise.resolve([]); -export interface GraphStashContextValue { - type: 'stash'; - ref: GitStashReference; -} + return Promise.all( + repositories.map(async r => { + const remote = await r.getBestRemoteWithIntegration(); -export interface GraphTagContextValue { - type: 'tag'; - ref: GitTagReference; -} + // const integration = await remote?.getIntegration(); + // const connected = integration ? integration?.maybeConnected ?? (await integration?.isConnected()) : false; + let connected = false; + if (remote?.maybeIntegrationConnected) { + connected = true; + } -export interface GraphUpstreamStatusContextValue { - type: 'upstreamStatus'; - ref: GitBranchReference; - ahead: number; - behind: number; + return { + formattedName: r.formattedName, + id: r.id, + name: r.name, + path: r.path, + provider: remote?.provider + ? { + name: remote.provider.name, + connected: connected, + icon: remote.provider.icon === 'remote' ? 'cloud' : remote.provider.icon, + url: remote.provider.url({ type: RemoteResourceType.Repo }), + } + : undefined, + isVirtual: r.provider.virtual, + }; + }), + ); } function isGraphItemContext(item: unknown): item is GraphItemContext { if (item == null) return false; - return isWebviewItemContext(item) && item.webview === 'gitlens.graph'; + return isWebviewItemContext(item) && (item.webview === 'gitlens.graph' || item.webview === 'gitlens.views.graph'); } function isGraphItemGroupContext(item: unknown): item is GraphItemGroupContext { if (item == null) return false; - return isWebviewItemGroupContext(item) && item.webview === 'gitlens.graph'; + return ( + isWebviewItemGroupContext(item) && (item.webview === 'gitlens.graph' || item.webview === 'gitlens.views.graph') + ); } function isGraphItemTypedContext( @@ -2708,6 +3834,9 @@ function isGraphItemRefContext(item: unknown, refType?: GitReference['refType']) ); } -function getRepoPathFromBranchOrTagId(id: string): string { - return id.split('|', 1)[0]; +export function hasGitReference(o: unknown): o is { ref: GitReference } { + if (o == null || typeof o !== 'object') return false; + if (!('ref' in o)) return false; + + return isGitReference(o.ref); } diff --git a/src/plus/webviews/graph/protocol.ts b/src/plus/webviews/graph/protocol.ts index 4778fa0166f1a..4c2e5e8ba4554 100644 --- a/src/plus/webviews/graph/protocol.ts +++ b/src/plus/webviews/graph/protocol.ts @@ -1,5 +1,4 @@ import type { - CommitDateTimeSource, CssVariables, ExcludeByType, ExcludeRefsById, @@ -17,23 +16,41 @@ import type { RefMetadataItem, RefMetadataType, Remote, + RowStats, + GraphItemContext as SerializedGraphItemContext, Tag, UpstreamMetadata, WorkDirStats, } from '@gitkraken/gitkraken-components'; -import type { DateStyle } from '../../../config'; +import type { Config, DateStyle, GraphBranchesVisibility } from '../../../config'; +import type { SearchQuery } from '../../../constants.search'; import type { RepositoryVisibility } from '../../../git/gitProvider'; +import type { GitTrackingState } from '../../../git/models/branch'; import type { GitGraphRowType } from '../../../git/models/graph'; -import type { GitSearchResultData, SearchQuery } from '../../../git/search'; -import type { Subscription } from '../../../subscription'; +import type { PullRequestRefs, PullRequestShape } from '../../../git/models/pullRequest'; +import type { + GitBranchReference, + GitReference, + GitRevisionReference, + GitStashReference, + GitTagReference, +} from '../../../git/models/reference'; +import type { ProviderReference } from '../../../git/models/remoteProvider'; +import type { GitSearchResultData } from '../../../git/search'; import type { DateTimeFormat } from '../../../system/date'; -import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; +import type { WebviewItemContext, WebviewItemGroupContext } from '../../../system/webview'; +import type { IpcScope, WebviewState } from '../../../webviews/protocol'; +import { IpcCommand, IpcNotification, IpcRequest } from '../../../webviews/protocol'; +import type { Subscription } from '../../gk/account/subscription'; export type { GraphRefType } from '@gitkraken/gitkraken-components'; +export const scope: IpcScope = 'graph'; + export type GraphColumnsSettings = Record; export type GraphSelectedRows = Record; export type GraphAvatars = Record; +export type GraphDownstreams = Record; export type GraphRefMetadata = RefMetadata | null; export type GraphUpstreamMetadata = UpstreamMetadata | null; @@ -45,47 +62,40 @@ export type GraphMissingRefsMetadataType = RefMetadataType; export type GraphMissingRefsMetadata = Record; export type GraphPullRequestMetadata = PullRequestMetadata; -export enum GraphRefMetadataTypes { - Upstream = 'upstream', - PullRequest = 'pullRequest', -} - -export const enum GraphScrollMarkerTypes { - Selection = 'selection', - Head = 'head', - Highlights = 'highlights', - LocalBranches = 'localBranches', - RemoteBranches = 'remoteBranches', - Stashes = 'stashes', - Tags = 'tags', - Upstream = 'upstream', -} - -export const enum GraphMinimapMarkerTypes { - Selection = 'selection', - Head = 'head', - Highlights = 'highlights', - LocalBranches = 'localBranches', - RemoteBranches = 'remoteBranches', - Stashes = 'stashes', - Tags = 'tags', - Upstream = 'upstream', -} - -export const supportedRefMetadataTypes: GraphRefMetadataType[] = Object.values(GraphRefMetadataTypes); - -export type GraphCommitDateTimeSource = CommitDateTimeSource; -export enum GraphCommitDateTimeSources { - RowEntry = 'rowEntry', - Tooltip = 'tooltip', -} - -export interface State { +export type GraphRefMetadataTypes = 'upstream' | 'pullRequest' | 'issue'; + +export type GraphScrollMarkerTypes = + | 'selection' + | 'head' + | 'highlights' + | 'localBranches' + | 'pullRequests' + | 'remoteBranches' + | 'stashes' + | 'tags' + | 'upstream'; + +export type GraphMinimapMarkerTypes = + | 'selection' + | 'head' + | 'highlights' + | 'localBranches' + | 'pullRequests' + | 'remoteBranches' + | 'stashes' + | 'tags' + | 'upstream'; + +export const supportedRefMetadataTypes: GraphRefMetadataType[] = ['upstream', 'pullRequest', 'issue']; + +export interface State extends WebviewState { windowFocused?: boolean; repositories?: GraphRepository[]; selectedRepository?: string; selectedRepositoryVisibility?: RepositoryVisibility; + branchesVisibility?: GraphBranchesVisibility; branchName?: string; + branchState?: BranchState; lastFetched?: Date; selectedRows?: GraphSelectedRows; subscription?: Subscription; @@ -94,18 +104,19 @@ export interface State { loading?: boolean; refsMetadata?: GraphRefsMetadata | null; rows?: GraphRow[]; + rowsStats?: Record; + rowsStatsLoading?: boolean; + downstreams?: GraphDownstreams; paging?: GraphPaging; columns?: GraphColumnsSettings; config?: GraphComponentConfig; - context?: GraphContexts; + context?: GraphContexts & { settings?: SerializedGraphItemContext }; nonce?: string; - trialBanner?: boolean; workingTreeStats?: GraphWorkingTreeStats; searchResults?: DidSearchParams['results']; excludeRefs?: GraphExcludeRefs; excludeTypes?: GraphExcludeTypes; includeOnlyRefs?: GraphIncludeOnlyRefs; - debugging: boolean; // Props below are computed in the webview (not passed) activeDay?: number; @@ -117,6 +128,16 @@ export interface State { theming?: { cssVariables: CssVariables; themeOpacityFactor: number }; } +export interface BranchState extends GitTrackingState { + upstream?: string; + provider?: { + name: string; + icon?: string; + url?: string; + }; + pr?: PullRequestShape; +} + export type GraphWorkingTreeStats = WorkDirStats; export interface GraphPaging { @@ -130,6 +151,12 @@ export interface GraphRepository { name: string; path: string; isVirtual: boolean; + provider?: { + name: string; + connected: boolean; + icon?: string; + url?: string; + }; } export interface GraphCommitIdentity { @@ -159,22 +186,26 @@ export interface GraphComponentConfig { enabledRefMetadataTypes?: GraphRefMetadataType[]; enableMultiSelection?: boolean; highlightRowsOnRefHover?: boolean; + idLength?: number; minimap?: boolean; - enabledMinimapMarkerTypes?: GraphMinimapMarkerTypes[]; + minimapDataType?: Config['graph']['minimap']['dataType']; + minimapMarkerTypes?: GraphMinimapMarkerTypes[]; + onlyFollowFirstParent?: boolean; + scrollMarkerTypes?: GraphScrollMarkerTypes[]; scrollRowPadding?: number; - enabledScrollMarkerTypes?: GraphScrollMarkerTypes[]; showGhostRefsOnRowHover?: boolean; showRemoteNamesOnRefs?: boolean; - idLength?: number; + sidebar: boolean; } export interface GraphColumnConfig { isHidden?: boolean; + mode?: string; width?: number; order?: number; } -export type GraphColumnsConfig = { [name: string]: GraphColumnConfig }; +export type GraphColumnsConfig = Record; export type GraphExcludeRefs = ExcludeRefsById; export type GraphExcludedRef = GraphRefOptData; @@ -183,26 +214,19 @@ export type GraphIncludeOnlyRefs = IncludeOnlyRefsById; export type GraphIncludeOnlyRef = GraphRefOptData; export type GraphColumnName = GraphZoneType; +export type GraphRowStats = RowStats; export type InternalNotificationType = 'didChangeTheme'; -export interface UpdateStateCallback { - (state: State, type?: IpcNotificationType | InternalNotificationType, themingChanged?: boolean): void; -} - -// Commands +export type UpdateStateCallback = ( + state: State, + type?: IpcNotification | InternalNotificationType, + themingChanged?: boolean, +) => void; -export const ChooseRepositoryCommandType = new IpcCommandType('graph/chooseRepository'); - -export interface DimMergeCommitsParams { - dim: boolean; -} -export const DimMergeCommitsCommandType = new IpcCommandType('graph/dimMergeCommits'); +// COMMANDS -export interface DismissBannerParams { - key: 'preview' | 'trial'; -} -export const DismissBannerCommandType = new IpcCommandType('graph/dismissBanner'); +export const ChooseRepositoryCommand = new IpcCommand(scope, 'chooseRepository'); export type DoubleClickedParams = | { @@ -215,197 +239,331 @@ export type DoubleClickedParams = row: { id: string; type: GitGraphRowType }; preserveFocus?: boolean; }; -export const DoubleClickedCommandType = new IpcCommandType('graph/dblclick'); - -export interface EnsureRowParams { - id: string; - select?: boolean; -} -export const EnsureRowCommandType = new IpcCommandType('graph/rows/ensure'); +export const DoubleClickedCommandType = new IpcCommand(scope, 'dblclick'); export interface GetMissingAvatarsParams { emails: GraphAvatars; } -export const GetMissingAvatarsCommandType = new IpcCommandType('graph/avatars/get'); +export const GetMissingAvatarsCommand = new IpcCommand(scope, 'avatars/get'); export interface GetMissingRefsMetadataParams { metadata: GraphMissingRefsMetadata; } -export const GetMissingRefsMetadataCommandType = new IpcCommandType( - 'graph/refs/metadata/get', -); +export const GetMissingRefsMetadataCommand = new IpcCommand(scope, 'refs/metadata/get'); export interface GetMoreRowsParams { id?: string; } -export const GetMoreRowsCommandType = new IpcCommandType('graph/rows/get'); +export const GetMoreRowsCommand = new IpcCommand(scope, 'rows/get'); -export interface SearchParams { - search?: SearchQuery; - limit?: number; - more?: boolean; +export interface OpenPullRequestDetailsParams { + id?: string; } -export const SearchCommandType = new IpcCommandType('graph/search'); +export const OpenPullRequestDetailsCommand = new IpcCommand( + scope, + 'pullRequest/openDetails', +); export interface SearchOpenInViewParams { search: SearchQuery; } -export const SearchOpenInViewCommandType = new IpcCommandType('graph/search/openInView'); +export const SearchOpenInViewCommand = new IpcCommand(scope, 'search/openInView'); export interface UpdateColumnsParams { config: GraphColumnsConfig; } -export const UpdateColumnsCommandType = new IpcCommandType('graph/columns/update'); +export const UpdateColumnsCommand = new IpcCommand(scope, 'columns/update'); export interface UpdateRefsVisibilityParams { refs: GraphExcludedRef[]; visible: boolean; } -export const UpdateRefsVisibilityCommandType = new IpcCommandType( - 'graph/refs/update/visibility', -); +export const UpdateRefsVisibilityCommand = new IpcCommand(scope, 'refs/update/visibility'); -export interface UpdateExcludeTypeParams { +export interface UpdateExcludeTypesParams { key: keyof GraphExcludeTypes; value: boolean; } -export const UpdateExcludeTypeCommandType = new IpcCommandType( - 'graph/fitlers/update/excludeType', -); +export const UpdateExcludeTypesCommand = new IpcCommand(scope, 'filters/update/excludeTypes'); export interface UpdateGraphConfigurationParams { changes: { [key in keyof GraphComponentConfig]?: GraphComponentConfig[key] }; } -export const UpdateGraphConfigurationCommandType = new IpcCommandType( - 'graph/configuration/update', +export const UpdateGraphConfigurationCommand = new IpcCommand( + scope, + 'configuration/update', ); -export interface UpdateIncludeOnlyRefsParams { +export interface UpdateIncludedRefsParams { + branchesVisibility?: GraphBranchesVisibility; refs?: GraphIncludeOnlyRef[]; } -export const UpdateIncludeOnlyRefsCommandType = new IpcCommandType( - 'graph/fitlers/update/includeOnlyRefs', -); +export const UpdateIncludedRefsCommand = new IpcCommand(scope, 'filters/update/includedRefs'); export interface UpdateSelectionParams { selection: { id: string; type: GitGraphRowType }[]; } -export const UpdateSelectionCommandType = new IpcCommandType('graph/selection/update'); +export const UpdateSelectionCommand = new IpcCommand(scope, 'selection/update'); + +// REQUESTS + +export interface ChooseRefParams { + alt: boolean; +} +export type DidChooseRefParams = { name: string; sha: string } | undefined; +export const ChooseRefRequest = new IpcRequest(scope, 'chooseRef'); + +export interface EnsureRowParams { + id: string; + select?: boolean; +} +export interface DidEnsureRowParams { + id?: string; // `undefined` if the row was not found + remapped?: string; +} +export const EnsureRowRequest = new IpcRequest(scope, 'rows/ensure'); + +export type DidGetCountParams = + | { + branches: number; + remotes: number; + stashes?: number; + tags: number; + worktrees?: number; + } + | undefined; +export const GetCountsRequest = new IpcRequest(scope, 'counts'); + +export type GetRowHoverParams = { + type: GitGraphRowType; + id: string; +}; + +export interface DidGetRowHoverParams { + id: string; + markdown: PromiseSettledResult; +} -// Notifications +export const GetRowHoverRequest = new IpcRequest(scope, 'row/hover/get'); + +export interface SearchParams { + search?: SearchQuery; + limit?: number; + more?: boolean; +} +export interface GraphSearchResults { + ids?: Record; + count: number; + paging?: { hasMore: boolean }; +} +export interface GraphSearchResultsError { + error: string; +} +export interface DidSearchParams { + results: GraphSearchResults | GraphSearchResultsError | undefined; + selectedRows?: GraphSelectedRows; +} +export const SearchRequest = new IpcRequest(scope, 'search'); + +// NOTIFICATIONS + +export interface DidChangeRepoConnectionParams { + repositories?: GraphRepository[]; +} +export const DidChangeRepoConnectionNotification = new IpcNotification( + scope, + 'repositories/integration/didChange', +); export interface DidChangeParams { state: State; } -export const DidChangeNotificationType = new IpcNotificationType('graph/didChange', true); +export const DidChangeNotification = new IpcNotification(scope, 'didChange', true, true); export interface DidChangeGraphConfigurationParams { config: GraphComponentConfig; } -export const DidChangeGraphConfigurationNotificationType = new IpcNotificationType( - 'graph/configuration/didChange', - true, +export const DidChangeGraphConfigurationNotification = new IpcNotification( + scope, + 'configuration/didChange', ); export interface DidChangeSubscriptionParams { subscription: Subscription; allowed: boolean; } -export const DidChangeSubscriptionNotificationType = new IpcNotificationType( - 'graph/subscription/didChange', - true, +export const DidChangeSubscriptionNotification = new IpcNotification( + scope, + 'subscription/didChange', ); export interface DidChangeAvatarsParams { avatars: GraphAvatars; } -export const DidChangeAvatarsNotificationType = new IpcNotificationType( - 'graph/avatars/didChange', - true, +export const DidChangeAvatarsNotification = new IpcNotification(scope, 'avatars/didChange'); + +export interface DidChangeBranchStateParams { + branchState: BranchState; +} +export const DidChangeBranchStateNotification = new IpcNotification( + scope, + 'branchState/didChange', ); export interface DidChangeRefsMetadataParams { metadata: GraphRefsMetadata | null | undefined; } -export const DidChangeRefsMetadataNotificationType = new IpcNotificationType( - 'graph/refs/didChangeMetadata', - true, +export const DidChangeRefsMetadataNotification = new IpcNotification( + scope, + 'refs/didChangeMetadata', ); export interface DidChangeColumnsParams { columns: GraphColumnsSettings | undefined; context?: string; + settingsContext?: string; } -export const DidChangeColumnsNotificationType = new IpcNotificationType( - 'graph/columns/didChange', - true, -); +export const DidChangeColumnsNotification = new IpcNotification(scope, 'columns/didChange'); -export interface DidChangeWindowFocusParams { - focused: boolean; +export interface DidChangeScrollMarkersParams { + context?: string; } -export const DidChangeWindowFocusNotificationType = new IpcNotificationType( - 'graph/window/focus/didChange', - true, +export const DidChangeScrollMarkersNotification = new IpcNotification( + scope, + 'scrollMarkers/didChange', ); export interface DidChangeRefsVisibilityParams { + branchesVisibility: GraphBranchesVisibility; excludeRefs?: GraphExcludeRefs; excludeTypes?: GraphExcludeTypes; includeOnlyRefs?: GraphIncludeOnlyRefs; } -export const DidChangeRefsVisibilityNotificationType = new IpcNotificationType( - 'graph/refs/didChangeVisibility', - true, +export const DidChangeRefsVisibilityNotification = new IpcNotification( + scope, + 'refs/didChangeVisibility', ); export interface DidChangeRowsParams { rows: GraphRow[]; - avatars: { [email: string]: string }; + avatars: Record; + downstreams: Record; paging?: GraphPaging; refsMetadata?: GraphRefsMetadata | null; + rowsStats?: Record; + rowsStatsLoading: boolean; selectedRows?: GraphSelectedRows; } -export const DidChangeRowsNotificationType = new IpcNotificationType('graph/rows/didChange'); +export const DidChangeRowsNotification = new IpcNotification( + scope, + 'rows/didChange', + undefined, + true, +); + +export interface DidChangeRowsStatsParams { + rowsStats: Record; + rowsStatsLoading: boolean; +} +export const DidChangeRowsStatsNotification = new IpcNotification( + scope, + 'rows/stats/didChange', +); export interface DidChangeSelectionParams { selection: GraphSelectedRows; } -export const DidChangeSelectionNotificationType = new IpcNotificationType( - 'graph/selection/didChange', - true, +export const DidChangeSelectionNotification = new IpcNotification( + scope, + 'selection/didChange', ); export interface DidChangeWorkingTreeParams { stats: WorkDirStats; } -export const DidChangeWorkingTreeNotificationType = new IpcNotificationType( - 'graph/workingTree/didChange', - true, +export const DidChangeWorkingTreeNotification = new IpcNotification( + scope, + 'workingTree/didChange', ); -export interface DidEnsureRowParams { - id?: string; // `undefined` if the row was not found +export const DidSearchNotification = new IpcNotification(scope, 'didSearch'); + +export interface DidFetchParams { + lastFetched: Date; } -export const DidEnsureRowNotificationType = new IpcNotificationType('graph/rows/didEnsure'); +export const DidFetchNotification = new IpcNotification(scope, 'didFetch'); -export interface GraphSearchResults { - ids?: { [id: string]: GitSearchResultData }; - count: number; - paging?: { hasMore: boolean }; +export interface ShowInCommitGraphCommandArgs { + ref: GitReference; + preserveFocus?: boolean; } +export type GraphItemContext = WebviewItemContext; +export type GraphItemContextValue = GraphColumnsContextValue | GraphItemTypedContextValue | GraphItemRefContextValue; -export interface GraphSearchResultsError { - error: string; +export type GraphItemGroupContext = WebviewItemGroupContext; +export type GraphItemGroupContextValue = GraphItemRefGroupContextValue; + +export type GraphItemRefContext = WebviewItemContext; +export type GraphItemRefContextValue = + | GraphBranchContextValue + | GraphCommitContextValue + | GraphStashContextValue + | GraphTagContextValue; + +export type GraphItemRefGroupContext = WebviewItemGroupContext; +export interface GraphItemRefGroupContextValue { + type: 'refGroup'; + refs: (GitBranchReference | GitTagReference)[]; } -export interface DidSearchParams { - results: GraphSearchResults | GraphSearchResultsError | undefined; - selectedRows?: GraphSelectedRows; +export type GraphItemTypedContext = WebviewItemContext; +export type GraphItemTypedContextValue = + | GraphContributorContextValue + | GraphPullRequestContextValue + | GraphUpstreamStatusContextValue; + +export type GraphColumnsContextValue = string; + +export interface GraphContributorContextValue { + type: 'contributor'; + repoPath: string; + name: string; + email: string | undefined; + current?: boolean; } -export const DidSearchNotificationType = new IpcNotificationType('graph/didSearch', true); -export interface DidFetchParams { - lastFetched: Date; +export interface GraphPullRequestContextValue { + type: 'pullrequest'; + id: string; + url: string; + repoPath: string; + refs?: PullRequestRefs; + provider: ProviderReference; +} + +export interface GraphBranchContextValue { + type: 'branch'; + ref: GitBranchReference; +} + +export interface GraphCommitContextValue { + type: 'commit'; + ref: GitRevisionReference; +} + +export interface GraphStashContextValue { + type: 'stash'; + ref: GitStashReference; +} + +export interface GraphTagContextValue { + type: 'tag'; + ref: GitTagReference; +} + +export interface GraphUpstreamStatusContextValue { + type: 'upstreamStatus'; + ref: GitBranchReference; + ahead: number; + behind: number; } -export const DidFetchNotificationType = new IpcNotificationType('graph/didFetch', true); diff --git a/src/plus/webviews/graph/registration.ts b/src/plus/webviews/graph/registration.ts new file mode 100644 index 0000000000000..5b6981889ef2e --- /dev/null +++ b/src/plus/webviews/graph/registration.ts @@ -0,0 +1,196 @@ +import { Disposable, ViewColumn } from 'vscode'; +import { isScm } from '../../../commands/base'; +import { Commands } from '../../../constants.commands'; +import type { Container } from '../../../container'; +import type { GitReference } from '../../../git/models/reference'; +import type { Repository } from '../../../git/models/repository'; +import { executeCommand, executeCoreCommand, registerCommand } from '../../../system/vscode/command'; +import { configuration } from '../../../system/vscode/configuration'; +import { getContext } from '../../../system/vscode/context'; +import { ViewNode } from '../../../views/nodes/abstract/viewNode'; +import type { BranchNode } from '../../../views/nodes/branchNode'; +import type { CommitFileNode } from '../../../views/nodes/commitFileNode'; +import type { CommitNode } from '../../../views/nodes/commitNode'; +import { PullRequestNode } from '../../../views/nodes/pullRequestNode'; +import type { StashNode } from '../../../views/nodes/stashNode'; +import type { TagNode } from '../../../views/nodes/tagNode'; +import type { + WebviewPanelShowCommandArgs, + WebviewPanelsProxy, + WebviewsController, +} from '../../../webviews/webviewsController'; +import type { ShowInCommitGraphCommandArgs, State } from './protocol'; + +export type GraphWebviewShowingArgs = [Repository | { ref: GitReference }]; + +export function registerGraphWebviewPanel(controller: WebviewsController) { + return controller.registerWebviewPanel( + { id: Commands.ShowGraphPage, options: { preserveInstance: true } }, + { + id: 'gitlens.graph', + fileName: 'graph.html', + iconPath: 'images/gitlens-icon.png', + title: 'Commit Graph', + contextKeyPrefix: `gitlens:webview:graph`, + trackingFeature: 'graphWebview', + plusFeature: true, + column: ViewColumn.Active, + webviewHostOptions: { + retainContextWhenHidden: true, + enableFindWidget: false, + }, + allowMultipleInstances: configuration.get('graph.allowMultiple'), + }, + async (container, host) => { + const { GraphWebviewProvider } = await import(/* webpackChunkName: "webview-graph" */ './graphWebview'); + return new GraphWebviewProvider(container, host); + }, + ); +} + +export function registerGraphWebviewView(controller: WebviewsController) { + return controller.registerWebviewView( + { + id: 'gitlens.views.graph', + fileName: 'graph.html', + title: 'Commit Graph', + contextKeyPrefix: `gitlens:webviewView:graph`, + trackingFeature: 'graphView', + plusFeature: true, + webviewHostOptions: { + retainContextWhenHidden: true, + }, + }, + async (container, host) => { + const { GraphWebviewProvider } = await import(/* webpackChunkName: "webview-graph" */ './graphWebview'); + return new GraphWebviewProvider(container, host); + }, + ); +} + +export function registerGraphWebviewCommands( + container: Container, + panels: WebviewPanelsProxy, +) { + return Disposable.from( + registerCommand(Commands.ShowGraph, (...args: unknown[]) => { + const [arg] = args; + + let showInGraphArg; + if (isScm(arg)) { + if (arg.rootUri != null) { + const repo = container.git.getRepository(arg.rootUri); + if (repo != null) { + showInGraphArg = repo; + } + } + args = []; + } else if (arg instanceof ViewNode) { + if (arg.is('repo-folder')) { + showInGraphArg = arg.repo; + } + args = []; + } + + if (showInGraphArg != null) { + return executeCommand(Commands.ShowInCommitGraph, showInGraphArg); + } + + if (configuration.get('graph.layout') === 'panel') { + return executeCommand(Commands.ShowGraphView, ...args); + } + + return executeCommand(Commands.ShowGraphPage, undefined, ...args); + }), + registerCommand(`${panels.id}.switchToEditorLayout`, async () => { + await configuration.updateEffective('graph.layout', 'editor'); + queueMicrotask(() => void executeCommand(Commands.ShowGraphPage)); + }), + registerCommand(`${panels.id}.switchToPanelLayout`, async () => { + await configuration.updateEffective('graph.layout', 'panel'); + queueMicrotask(async () => { + await executeCoreCommand('gitlens.views.graph.resetViewLocation'); + await executeCoreCommand('gitlens.views.graphDetails.resetViewLocation'); + void executeCommand(Commands.ShowGraphView); + }); + }), + registerCommand(Commands.ToggleGraph, (...args: any[]) => { + if (getContext('gitlens:webviewView:graph:visible')) { + void executeCoreCommand('workbench.action.closePanel'); + } else { + void executeCommand(Commands.ShowGraphView, ...args); + } + }), + registerCommand(Commands.ToggleMaximizedGraph, (...args: any[]) => { + if (getContext('gitlens:webviewView:graph:visible')) { + void executeCoreCommand('workbench.action.toggleMaximizedPanel'); + } else { + void executeCommand(Commands.ShowGraphView, ...args); + void executeCoreCommand('workbench.action.toggleMaximizedPanel'); + } + }), + registerCommand( + Commands.ShowInCommitGraph, + ( + args: + | ShowInCommitGraphCommandArgs + | Repository + | BranchNode + | CommitNode + | CommitFileNode + | PullRequestNode + | StashNode + | TagNode, + ) => { + if (args instanceof PullRequestNode) { + if (args.ref == null) return; + + args = { ref: args.ref }; + } + + const preserveFocus = 'preserveFocus' in args ? args.preserveFocus ?? false : false; + if (configuration.get('graph.layout') === 'panel') { + if (!container.graphView.visible) { + const instance = panels.getBestInstance({ preserveFocus: preserveFocus }, args); + if (instance != null) { + void instance.show({ preserveFocus: preserveFocus }, args); + return; + } + } + + void container.graphView.show({ preserveFocus: preserveFocus }, args); + } else { + void panels.show({ preserveFocus: preserveFocus }, args); + } + }, + ), + registerCommand( + Commands.ShowInCommitGraphView, + ( + args: + | ShowInCommitGraphCommandArgs + | Repository + | BranchNode + | CommitNode + | CommitFileNode + | PullRequestNode + | StashNode + | TagNode, + ) => { + if (args instanceof PullRequestNode) { + if (args.ref == null) return; + + args = { ref: args.ref }; + } + + const preserveFocus = 'preserveFocus' in args ? args.preserveFocus ?? false : false; + void container.graphView.show({ preserveFocus: preserveFocus }, args); + }, + ), + registerCommand(`${panels.id}.refresh`, () => void panels.getActiveInstance()?.refresh(true)), + registerCommand( + `${panels.id}.split`, + () => void panels.splitActiveInstance({ preserveInstance: false, column: ViewColumn.Beside }), + ), + ); +} diff --git a/src/plus/webviews/graph/statusbar.ts b/src/plus/webviews/graph/statusbar.ts new file mode 100644 index 0000000000000..afe287885c3ba --- /dev/null +++ b/src/plus/webviews/graph/statusbar.ts @@ -0,0 +1,62 @@ +import type { ConfigurationChangeEvent, StatusBarItem } from 'vscode'; +import { Disposable, MarkdownString, StatusBarAlignment, window } from 'vscode'; +import { Commands } from '../../../constants.commands'; +import type { Container } from '../../../container'; +import { once } from '../../../system/function'; +import { configuration } from '../../../system/vscode/configuration'; +import { getContext, onDidChangeContext } from '../../../system/vscode/context'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; +import { arePlusFeaturesEnabled } from '../../gk/utils'; + +export class GraphStatusBarController implements Disposable { + private readonly _disposable: Disposable; + private _statusBarItem: StatusBarItem | undefined; + + constructor(container: Container) { + this._disposable = Disposable.from( + configuration.onDidChange(this.onConfigurationChanged, this), + container.subscription.onDidChange(this.onSubscriptionChanged, this), + once(container.onReady)(() => queueMicrotask(() => this.updateStatusBar())), + onDidChangeContext(key => { + if (key !== 'gitlens:enabled' && key !== 'gitlens:plus:enabled') return; + this.updateStatusBar(); + }), + { dispose: () => this._statusBarItem?.dispose() }, + ); + } + + dispose() { + this._disposable.dispose(); + } + + private onConfigurationChanged(e: ConfigurationChangeEvent) { + if (configuration.changed(e, 'graph.statusBar.enabled') || configuration.changed(e, 'plusFeatures.enabled')) { + this.updateStatusBar(); + } + } + + private onSubscriptionChanged(_e: SubscriptionChangeEvent) { + this.updateStatusBar(); + } + + private updateStatusBar() { + const enabled = + configuration.get('graph.statusBar.enabled') && getContext('gitlens:enabled') && arePlusFeaturesEnabled(); + if (enabled) { + if (this._statusBarItem == null) { + this._statusBarItem = window.createStatusBarItem('gitlens.graph', StatusBarAlignment.Left, 10000 - 2); + this._statusBarItem.name = 'GitLens Commit Graph'; + this._statusBarItem.command = Commands.ShowGraph; + this._statusBarItem.text = '$(gitlens-graph)'; + this._statusBarItem.tooltip = new MarkdownString('Visualize commits on the Commit Graph'); + this._statusBarItem.accessibilityInformation = { + label: `Show the GitLens Commit Graph`, + }; + } + this._statusBarItem.show(); + } else { + this._statusBarItem?.dispose(); + this._statusBarItem = undefined; + } + } +} diff --git a/src/plus/webviews/patchDetails/patchDetailsWebview.ts b/src/plus/webviews/patchDetails/patchDetailsWebview.ts new file mode 100644 index 0000000000000..0e00e85e62b01 --- /dev/null +++ b/src/plus/webviews/patchDetails/patchDetailsWebview.ts @@ -0,0 +1,1646 @@ +import type { ConfigurationChangeEvent } from 'vscode'; +import { Disposable, env, Uri, window } from 'vscode'; +import { extractDraftMessage } from '../../../ai/aiProviderService'; +import { getAvatarUri } from '../../../avatars'; +import { GlyphChars, previewBadge } from '../../../constants'; +import { Commands } from '../../../constants.commands'; +import type { ContextKeys } from '../../../constants.context'; +import type { Sources } from '../../../constants.telemetry'; +import type { Container } from '../../../container'; +import { CancellationError } from '../../../errors'; +import { openChanges, openChangesWithWorking, openFile } from '../../../git/actions/commit'; +import { ApplyPatchCommitError, ApplyPatchCommitErrorReason } from '../../../git/errors'; +import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; +import type { GitCommit } from '../../../git/models/commit'; +import { uncommitted, uncommittedStaged } from '../../../git/models/constants'; +import { GitFileChange } from '../../../git/models/file'; +import type { PatchRevisionRange } from '../../../git/models/patch'; +import { createReference, shortenRevision } from '../../../git/models/reference'; +import type { Repository } from '../../../git/models/repository'; +import { isRepository } from '../../../git/models/repository'; +import type { + CreateDraftChange, + Draft, + DraftArchiveReason, + DraftPatch, + DraftPatchFileChange, + DraftPendingUser, + DraftUser, + DraftVisibility, + LocalDraft, +} from '../../../gk/models/drafts'; +import type { GkRepositoryId } from '../../../gk/models/repositoryIdentities'; +import { showNewOrSelectBranchPicker } from '../../../quickpicks/branchPicker'; +import { showOrganizationMembersPicker } from '../../../quickpicks/organizationMembersPicker'; +import { ReferencesQuickPickIncludes, showReferencePicker } from '../../../quickpicks/referencePicker'; +import { gate } from '../../../system/decorators/gate'; +import { debug } from '../../../system/decorators/log'; +import type { Deferrable } from '../../../system/function'; +import { debounce } from '../../../system/function'; +import { find, some } from '../../../system/iterable'; +import { basename } from '../../../system/path'; +import { executeCommand, registerCommand } from '../../../system/vscode/command'; +import { configuration } from '../../../system/vscode/configuration'; +import { getContext, onDidChangeContext, setContext } from '../../../system/vscode/context'; +import type { Serialized } from '../../../system/vscode/serialize'; +import { serialize } from '../../../system/vscode/serialize'; +import { showInspectView } from '../../../webviews/commitDetails/actions'; +import type { IpcCallMessageType, IpcMessage } from '../../../webviews/protocol'; +import type { WebviewHost, WebviewProvider } from '../../../webviews/webviewProvider'; +import type { WebviewShowOptions } from '../../../webviews/webviewsController'; +import { showPatchesView } from '../../drafts/actions'; +import { getDraftEntityIdentifier } from '../../drafts/draftsService'; +import type { OrganizationMember } from '../../gk/account/organization'; +import { confirmDraftStorage, ensureAccount } from '../../utils'; +import type { ShowInCommitGraphCommandArgs } from '../graph/protocol'; +import type { + ApplyPatchParams, + Change, + CreateDraft, + CreatePatchParams, + DidExplainParams, + DidGenerateParams, + DraftPatchCheckedParams, + DraftUserSelection, + ExecuteFileActionParams, + Mode, + Preferences, + State, + SwitchModeParams, + UpdateablePreferences, + UpdateCreatePatchMetadataParams, + UpdateCreatePatchRepositoryCheckedStateParams, + UpdatePatchDetailsMetadataParams, + UpdatePatchUserSelection, +} from './protocol'; +import { + ApplyPatchCommand, + ArchiveDraftCommand, + CopyCloudLinkCommand, + CreatePatchCommand, + DidChangeCreateNotification, + DidChangeDraftNotification, + DidChangeNotification, + DidChangePatchRepositoryNotification, + DidChangePreferencesNotification, + DraftPatchCheckedCommand, + ExplainRequest, + GenerateRequest, + OpenFileCommand, + OpenFileComparePreviousCommand, + OpenFileCompareWorkingCommand, + OpenInCommitGraphCommand, + SwitchModeCommand, + UpdateCreatePatchMetadataCommand, + UpdateCreatePatchRepositoryCheckedStateCommand, + UpdatePatchDetailsMetadataCommand, + UpdatePatchDetailsPermissionsCommand, + UpdatePatchUsersCommand, + UpdatePatchUserSelectionCommand, + UpdatePreferencesCommand, +} from './protocol'; +import type { PatchDetailsWebviewShowingArgs } from './registration'; +import type { RepositoryChangeset } from './repositoryChangeset'; +import { RepositoryRefChangeset, RepositoryWipChangeset } from './repositoryChangeset'; + +interface DraftUserState { + users: DraftUser[]; + selections: DraftUserSelection[]; +} +interface Context { + mode: Mode; + draft: LocalDraft | Draft | undefined; + draftGkDevUrl: string | undefined; + draftVisibiltyState: DraftVisibility | undefined; + draftUserState: DraftUserState | undefined; + create: + | { + title?: string; + description?: string; + changes: Map; + showingAllRepos: boolean; + visibility: DraftVisibility; + userSelections?: DraftUserSelection[]; + } + | undefined; + preferences: Preferences; + orgSettings: State['orgSettings']; +} + +export class PatchDetailsWebviewProvider + implements WebviewProvider, PatchDetailsWebviewShowingArgs> +{ + private _context: Context; + private readonly _disposable: Disposable; + + constructor( + private readonly container: Container, + private readonly host: WebviewHost, + ) { + this._context = { + mode: 'create', + draft: undefined, + draftGkDevUrl: undefined, + draftUserState: undefined, + draftVisibiltyState: undefined, + create: undefined, + preferences: this.getPreferences(), + orgSettings: this.getOrgSettings(), + }; + + this.setHostTitle(); + this.host.description = previewBadge; + + this._disposable = Disposable.from( + configuration.onDidChangeAny(this.onAnyConfigurationChanged, this), + container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), + onDidChangeContext(this.onContextChanged, this), + ); + } + + dispose() { + this._disposable.dispose(); + } + + canReuseInstance(...args: PatchDetailsWebviewShowingArgs): boolean | undefined { + const [arg] = args; + if (arg?.mode === 'view' && arg.draft != null) { + switch (arg.draft.draftType) { + case 'cloud': + return ( + this._context.draft?.draftType === arg.draft.draftType && + this._context.draft.id === arg.draft.id + ); + + case 'local': + return ( + this._context.draft?.draftType === arg.draft.draftType && + this._context.draft.patch.contents === arg.draft.patch?.contents + ); + } + } + + return false; + } + + async onShowing( + _loading: boolean, + options: WebviewShowOptions, + ...args: PatchDetailsWebviewShowingArgs + ): Promise { + const [arg] = args; + if (arg?.mode === 'view' && arg.draft != null) { + await this.updateViewDraftState(arg.draft); + void this.trackViewDraft(this._context.draft, arg.source); + } else { + if (this.container.git.isDiscoveringRepositories) { + await this.container.git.isDiscoveringRepositories; + } + + const create = arg?.mode === 'create' && arg.create != null ? arg.create : { repositories: undefined }; + this.updateCreateDraftState(create); + } + + if (options?.preserveVisibility && !this.host.visible) return false; + + return true; + } + + includeBootstrap(): Promise> { + return this.getState(this._context); + } + + registerCommands(): Disposable[] { + const commands: Disposable[] = []; + + if (this.host.isHost('view')) { + commands.push( + registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true)), + registerCommand(`${this.host.id}.close`, () => this.closeView()), + ); + } + + return commands; + } + + onMessageReceived(e: IpcMessage) { + switch (true) { + case ApplyPatchCommand.is(e): + void this.applyPatch(e.params); + break; + + case CopyCloudLinkCommand.is(e): + this.copyCloudLink(); + break; + + // case CreateFromLocalPatchCommandType.method: + // this.shareLocalPatch(); + // break; + + case CreatePatchCommand.is(e): + void this.createDraft(e.params); + break; + + case ExplainRequest.is(e): + void this.explainRequest(ExplainRequest, e); + break; + + case GenerateRequest.is(e): + void this.generateRequest(GenerateRequest, e); + break; + + case OpenFileComparePreviousCommand.is(e): + void this.openFileComparisonWithPrevious(e.params); + break; + + case OpenFileCompareWorkingCommand.is(e): + void this.openFileComparisonWithWorking(e.params); + break; + + case OpenFileCommand.is(e): + void this.openFile(e.params); + break; + + case OpenInCommitGraphCommand.is(e): + void executeCommand(Commands.ShowInCommitGraph, { + ref: createReference(e.params.ref, e.params.repoPath, { refType: 'revision' }), + }); + break; + + // case SelectPatchBaseCommandType.is(e): + // void this.selectPatchBase(); + // break; + + // case SelectPatchRepoCommandType.is(e): + // void this.selectPatchRepo(); + // break; + + case SwitchModeCommand.is(e): + this.switchMode(e.params); + break; + + case UpdateCreatePatchMetadataCommand.is(e): + this.updateCreateMetadata(e.params); + break; + + case UpdatePatchDetailsMetadataCommand.is(e): + this.updateDraftMetadata(e.params); + break; + + case UpdatePatchDetailsPermissionsCommand.is(e): + void this.updateDraftPermissions(); + break; + + case UpdateCreatePatchRepositoryCheckedStateCommand.is(e): + this.updateCreateCheckedState(e.params); + break; + + case UpdatePreferencesCommand.is(e): + this.updatePreferences(e.params); + break; + + case DraftPatchCheckedCommand.is(e): + this.onPatchChecked(e.params); + break; + + case UpdatePatchUsersCommand.is(e): + void this.onInviteUsers(); + break; + + case UpdatePatchUserSelectionCommand.is(e): + this.onUpdatePatchUserSelection(e.params); + break; + + case ArchiveDraftCommand.is(e): + void this.archiveDraft(e.params.reason); + break; + } + } + + onRefresh(): void { + this.updateState(true); + } + + onReloaded(): void { + this.updateState(true); + } + + onVisibilityChanged(visible: boolean) { + // TODO@eamodio ugly -- clean this up later + this._context.create?.changes.forEach(c => (visible ? c.resume() : c.suspend())); + + if (visible) { + this.host.sendPendingIpcNotifications(); + } + } + + private onAnyConfigurationChanged(e: ConfigurationChangeEvent) { + if ( + configuration.changed(e, ['defaultDateFormat', 'views.patchDetails.files', 'views.patchDetails.avatars']) || + configuration.changedCore(e, 'workbench.tree.renderIndentGuides') || + configuration.changedCore(e, 'workbench.tree.indent') + ) { + this._context.preferences = { ...this._context.preferences, ...this.getPreferences() }; + this.updateState(); + } + } + + private getPreferences(): Preferences { + return { + avatars: configuration.get('views.patchDetails.avatars'), + dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma', + files: configuration.get('views.patchDetails.files'), + indentGuides: configuration.getCore('workbench.tree.renderIndentGuides') ?? 'onHover', + indent: configuration.getCore('workbench.tree.indent'), + }; + } + + private onContextChanged(key: keyof ContextKeys) { + if (['gitlens:gk:organization:ai:enabled', 'gitlens:gk:organization:drafts:enabled'].includes(key)) { + this._context.orgSettings = this.getOrgSettings(); + this.updateState(); + } + } + + private getOrgSettings(): State['orgSettings'] { + return { + ai: getContext('gitlens:gk:organization:ai:enabled', false), + byob: getContext('gitlens:gk:organization:drafts:byob', false), + }; + } + + private onRepositoriesChanged(e: RepositoriesChangeEvent) { + if (this.mode === 'create' && this._context.create != null) { + if (this._context.create?.showingAllRepos) { + for (const repo of e.added) { + this._context.create.changes.set( + repo.uri.toString(), + new RepositoryWipChangeset( + this.container, + repo, + { to: uncommitted, from: 'HEAD' }, + this.onRepositoryWipChanged.bind(this), + false, + true, + ), + ); + } + } + + for (const repo of e.removed) { + this._context.create.changes.delete(repo.uri.toString()); + } + + void this.notifyDidChangeCreateDraftState(); + } + } + + private onRepositoryWipChanged(_e: RepositoryWipChangeset) { + void this.notifyDidChangeCreateDraftState(); + } + + private get mode(): Mode { + return this._context.mode; + } + private setMode(mode: Mode, silent?: boolean) { + this._context.mode = mode; + this.setHostTitle(mode); + void setContext( + 'gitlens:views:patchDetails:mode', + configuration.get('cloudPatches.experimental.layout') === 'editor' ? undefined : mode, + ); + if (!silent) { + this.updateState(true); + } + } + + private setHostTitle(mode: Mode = this._context.mode) { + if (mode === 'create') { + this.host.title = 'Create Cloud Patch'; + } else if (this._context.draft?.draftType === 'cloud' && this._context.draft.type === 'suggested_pr_change') { + this.host.title = 'Cloud Suggestion'; + } else { + this.host.title = 'Cloud Patch Details'; + } + } + + private async applyPatch(params: ApplyPatchParams) { + // if (params.details.repoPath == null || params.details.commit == null) return; + // void this.container.git.applyPatchCommit(params.details.repoPath, params.details.commit, { + // branchName: params.targetRef, + // }); + if (this._context.draft == null || this._context.draft.draftType === 'local' || !params.selected?.length) { + return; + } + + const changeset = this._context.draft.changesets?.[0]; + if (changeset == null) return; + + // TODO: should be overridable with targetRef + const shouldPickBranch = params.target === 'branch'; + for (const patch of changeset.patches) { + if (!params.selected.includes(patch.id)) continue; + + try { + const commit = patch.commit ?? (await this.getOrCreateCommitForPatch(patch.gkRepositoryId)); + if (!commit) { + // TODO: say we can't apply this patch + continue; + } + + let options: + | { + branchName?: string; + createBranchIfNeeded?: boolean; + createWorktreePath?: string; + } + | undefined = undefined; + + if (shouldPickBranch) { + const repo = commit.getRepository(); + const branch = await showNewOrSelectBranchPicker( + `Choose a Branch ${GlyphChars.Dot} ${repo?.name}`, + // 'Choose a branch to apply the Cloud Patch to', + repo, + ); + + if (branch == null) { + void window.showErrorMessage( + `Unable to apply patch to '${patch.repository!.name}': No branch selected`, + ); + continue; + } + + const isString = typeof branch === 'string'; + options = { + branchName: isString ? branch : branch.ref, + createBranchIfNeeded: isString, + }; + } + + await this.container.git.applyUnreachableCommitForPatch(commit.repoPath, commit.ref, { + stash: 'prompt', + ...options, + }); + void window.showInformationMessage(`Patch applied successfully`); + } catch (ex) { + if (ex instanceof CancellationError) return; + + if (ex instanceof ApplyPatchCommitError) { + if (ex.reason === ApplyPatchCommitErrorReason.AppliedWithConflicts) { + void window.showWarningMessage('Patch applied with conflicts'); + } else { + void window.showErrorMessage(ex.message); + } + } else { + void window.showErrorMessage(`Unable to apply patch onto '${patch.baseRef}': ${ex.message}`); + } + } + } + } + + private closeView() { + void setContext('gitlens:views:patchDetails:mode', undefined); + + if (this._context.mode === 'create') { + void this.container.draftsView.show(); + } else if (this._context.draft?.draftType === 'cloud') { + if (this._context.draft.type === 'suggested_pr_change') { + const repositoryOrIdentity = this._context.draft.changesets?.[0].patches[0].repository; + void showInspectView({ + type: 'wip', + repository: isRepoLocated(repositoryOrIdentity) ? (repositoryOrIdentity as Repository) : undefined, + source: 'patchDetails', + }); + } else { + void this.container.draftsView.revealDraft(this._context.draft); + } + } + } + + private copyCloudLink() { + if (this._context.draft?.draftType !== 'cloud') return; + + void env.clipboard.writeText(this._context.draft.deepLinkUrl); + } + + private async getOrganizationMembers() { + return this.container.organizations.getMembers(); + } + + private async onInviteUsers() { + let owner; + let ownerSelection; + let pickedMemberIds: string[] | undefined; + if (this.mode === 'create') { + pickedMemberIds = this._context.create?.userSelections?.map(u => u.member.id); + ownerSelection = this._context.create?.userSelections?.find(u => u.member.role === 'owner'); + owner = ownerSelection?.user; + } else { + pickedMemberIds = this._context.draftUserState?.selections + ?.filter(s => s.change !== 'delete') + ?.map(u => u.member.id); + owner = this._context.draftUserState?.users.find(u => u.role === 'owner'); + } + + const members = await showOrganizationMembersPicker( + 'Select Collaborators', + 'Select the collaborators to share this patch with', + this.getOrganizationMembers(), + { + multiselect: true, + filter: m => m.id !== owner?.userId, + picked: m => pickedMemberIds?.includes(m.id) ?? false, + }, + ); + if (members == null) return; + + if (this.mode === 'create') { + const userSelections = members.map(member => toDraftUserSelection(member, undefined, 'editor', 'add')); + if (ownerSelection != null) { + userSelections.push(ownerSelection); + } + this._context.create!.userSelections = userSelections; + void this.notifyDidChangeCreateDraftState(); + return; + } + + const draftUserState = this._context.draftUserState!; + + const currentSelections = draftUserState.selections; + const preserveSelections = new Map(); + const updatedMemberIds = new Set(members.map(member => member.id)); + const updatedSelections: DraftUserSelection[] = []; + + for (const selection of currentSelections) { + if (updatedMemberIds.has(selection.member.id) || selection.member.role === 'owner') { + preserveSelections.set(selection.member.id, selection); + continue; + } + + updatedSelections.push({ ...selection, change: 'delete' }); + } + + for (const member of members) { + const selection = preserveSelections.get(member.id); + // If we have an existing selection, and it's marked for deletion, we need to undo the deletion + if (selection != null && selection.change === 'delete') { + selection.change = undefined; + } + + updatedSelections.push( + selection != null ? selection : toDraftUserSelection(member, undefined, 'editor', 'add'), + ); + } + + if (updatedSelections.length) { + draftUserState.selections = updatedSelections; + void this.notifyDidChangeViewDraftState(); + } + } + + private onUpdatePatchUserSelection(params: UpdatePatchUserSelection) { + if (this.mode === 'create') { + const userSelections = this._context.create?.userSelections; + if (userSelections == null) return; + + if (params.role === 'remove') { + const selection = userSelections.findIndex(u => u.member.id === params.selection.member.id); + if (selection === -1) return; + userSelections.splice(selection, 1); + } else { + const selection = userSelections.find(u => u.member.id === params.selection.member.id); + if (selection == null) return; + selection.pendingRole = params.role; + } + + void this.notifyDidChangeCreateDraftState(); + return; + } + + const allSelections = this._context.draftUserState!.selections; + const selection = allSelections.find(u => u.member.id === params.selection.member.id); + if (selection == null) return; + + if (params.role === 'remove') { + selection.change = 'delete'; + } else { + selection.change = 'modify'; + selection.pendingRole = params.role; + } + + void this.notifyDidChangeViewDraftState(); + } + + private async createDraft({ + title, + changesets, + description, + visibility, + userSelections, + }: CreatePatchParams): Promise { + if ( + !(await ensureAccount(this.container, 'Cloud Patches are a Preview feature and require an account.', { + source: 'cloud-patches', + detail: 'create', + })) || + !(await confirmDraftStorage(this.container)) + ) { + return; + } + + const createChanges: CreateDraftChange[] = []; + + const changes = Object.entries(changesets); + const ignoreChecked = changes.length === 1; + + for (const [id, change] of changes) { + if (!ignoreChecked && change.checked === false) continue; + + const repoChangeset = this._context.create?.changes?.get(id); + if (repoChangeset == null) continue; + + let { revision, repository } = repoChangeset; + if (change.type === 'wip' && change.checked === 'staged') { + revision = { ...revision, to: uncommittedStaged }; + } + + createChanges.push({ + repository: repository, + revision: revision, + }); + } + if (createChanges == null) return; + + try { + const options = { + description: description, + visibility: visibility, + }; + const draft = await this.container.drafts.createDraft('patch', title, createChanges, options); + + if (userSelections != null && userSelections.length !== 0) { + await this.container.drafts.addDraftUsers( + draft.id, + userSelections.map(u => ({ + userId: u.member.id, + role: u.pendingRole!, + })), + ); + } + + async function showNotification() { + const view = { title: 'View Patch' }; + const copy = { title: 'Copy Link' }; + let copied = false; + while (true) { + const result = await window.showInformationMessage( + `Cloud Patch successfully created${copied ? '\u2014 link copied to the clipboard' : ''}`, + view, + copy, + ); + + if (result === copy) { + void env.clipboard.writeText(draft.deepLinkUrl); + copied = true; + continue; + } + + if (result === view) { + void showPatchesView({ mode: 'view', draft: draft }); + } + + break; + } + } + + void showNotification(); + void this.container.draftsView.refresh(true).then(() => void this.container.draftsView.revealDraft(draft)); + + this.closeView(); + } catch (ex) { + debugger; + void this.notifyDidChangeCreateDraftState(); + void window.showErrorMessage(`Unable to create draft: ${ex.message}`); + } + } + + private async archiveDraft(reason?: Exclude) { + if (this._context.draft?.draftType !== 'cloud') return; + + const isCodeSuggestion = this._context.draft.type === 'suggested_pr_change'; + let label = 'Cloud Patch'; + if (isCodeSuggestion) { + label = 'Code Suggestion'; + } + + try { + await this.container.drafts.archiveDraft(this._context.draft, { archiveReason: reason }); + this._context.draft = { + ...this._context.draft, + isArchived: true, + archivedReason: reason, + }; + + let action = 'archived'; + if (isCodeSuggestion) { + switch (reason) { + case 'accepted': + action = 'accepted'; + break; + case 'rejected': + action = 'declined'; + break; + } + } + + void window.showInformationMessage(`${label} successfully ${action}`); + void this.notifyDidChangeViewDraftState(); + if (isCodeSuggestion) { + void this.trackArchiveDraft(this._context.draft); + } + } catch (ex) { + let action = 'archive'; + if (isCodeSuggestion) { + switch (reason) { + case 'accepted': + action = 'accept'; + break; + case 'rejected': + action = 'decline'; + break; + } + } + + void window.showErrorMessage(`Unable to ${action} ${label}: ${ex.message}`); + } + } + + private async trackArchiveDraft(draft: Draft) { + draft = await this.ensureDraftContent(draft); + const patch = draft.changesets?.[0].patches.find(p => isRepoLocated(p.repository)); + + let repoPrivacy; + if (isRepository(patch?.repository)) { + repoPrivacy = await this.container.git.visibility(patch.repository.uri); + } + + const entity = getDraftEntityIdentifier(draft, patch); + + this.container.telemetry.sendEvent( + 'codeSuggestionArchived', + { + provider: entity?.provider, + 'repository.visibility': repoPrivacy, + repoPrivacy: repoPrivacy, + draftId: draft.id, + reason: draft.archivedReason!, + }, + { source: 'patchDetails' }, + ); + } + + private async explainRequest(requestType: T, msg: IpcCallMessageType) { + if (this._context.draft?.draftType !== 'cloud') { + void this.host.respond(requestType, msg, { error: { message: 'Unable to find patch' } }); + return; + } + + let params: DidExplainParams; + + try { + // TODO@eamodio HACK -- only works for the first patch + const patch = await this.getDraftPatch(this._context.draft); + if (patch == null) throw new Error('Unable to find patch'); + + const commit = await this.getOrCreateCommitForPatch(patch.gkRepositoryId); + if (commit == null) throw new Error('Unable to find commit'); + + const summary = await ( + await this.container.ai + )?.explainCommit( + commit, + { source: 'patchDetails', type: `draft-${this._context.draft.type}` }, + { progress: { location: { viewId: this.host.id } } }, + ); + if (summary == null) throw new Error('Error retrieving content'); + + params = { summary: summary }; + } catch (ex) { + debugger; + params = { error: { message: ex.message } }; + } + + void this.host.respond(requestType, msg, params); + } + + private async generateRequest(requestType: T, msg: IpcCallMessageType) { + let repo: Repository | undefined; + if (this._context.create?.changes != null) { + for (const change of this._context.create.changes.values()) { + if (change.repository) { + repo = change.repository; + break; + } + } + } + + if (!repo) { + void this.host.respond(requestType, msg, { error: { message: 'Unable to find changes' } }); + return; + } + + let params: DidGenerateParams; + + try { + // TODO@eamodio HACK -- only works for the first patch + // const patch = await this.getDraftPatch(this._context.draft); + // if (patch == null) throw new Error('Unable to find patch'); + + // const commit = await this.getOrCreateCommitForPatch(patch.gkRepositoryId); + // if (commit == null) throw new Error('Unable to find commit'); + + const summary = await ( + await this.container.ai + )?.generateDraftMessage( + repo, + { source: 'patchDetails', type: 'patch' }, + { progress: { location: { viewId: this.host.id } } }, + ); + if (summary == null) throw new Error('Error retrieving content'); + + params = extractDraftMessage(summary); + } catch (ex) { + debugger; + params = { error: { message: ex.message } }; + } + + void this.host.respond(requestType, msg, params); + } + + private async openPatchContents(_params: ExecuteFileActionParams) { + // TODO@eamodio Open the patch contents for the selected repo in an untitled editor + } + + private onPatchChecked(params: DraftPatchCheckedParams) { + if (params.patch.repository.located || params.checked === false) return; + + const patch = (this._context.draft as Draft)?.changesets?.[0].patches?.find( + p => p.gkRepositoryId === params.patch.gkRepositoryId, + ); + if (patch == null) return; + + void this.getOrLocatePatchRepository(patch, { prompt: true, notifyOnLocation: true }); + } + + private notifyPatchRepositoryUpdated(patch: DraftPatch) { + return this.host.notify(DidChangePatchRepositoryNotification, { + patch: serialize({ + ...patch, + contents: undefined, + commit: undefined, + repository: { + id: patch.gkRepositoryId, + name: patch.repository?.name ?? '', + located: isRepoLocated(patch.repository), + }, + }), + }); + } + + private updateCreateCheckedState(params: UpdateCreatePatchRepositoryCheckedStateParams) { + const changeset = this._context.create?.changes.get(params.repoUri); + if (changeset == null) return; + + changeset.checked = params.checked; + void this.notifyDidChangeCreateDraftState(); + } + + private updateCreateMetadata(params: UpdateCreatePatchMetadataParams) { + if (this._context.create == null) return; + + this._context.create.title = params.title; + this._context.create.description = params.description; + this._context.create.visibility = params.visibility; + void this.notifyDidChangeCreateDraftState(); + } + + private updateDraftMetadata(params: UpdatePatchDetailsMetadataParams) { + if (this._context.draft == null) return; + + this._context.draftVisibiltyState = params.visibility; + void this.notifyDidChangeViewDraftState(); + } + + private async updateDraftPermissions() { + const draft = this._context.draft as Draft; + const draftId = draft.id; + const changes = []; + + if (this._context.draftVisibiltyState != null && this._context.draftVisibiltyState !== draft.visibility) { + changes.push(this.container.drafts.updateDraftVisibility(draftId, this._context.draftVisibiltyState)); + } + + const selections = this._context.draftUserState?.selections; + const adds: DraftPendingUser[] = []; + if (selections != null) { + for (const selection of selections) { + if (selection.change === undefined) continue; + + // modifying an existing user has to be done by deleting and adding back + if (selection.change !== 'delete') { + adds.push({ + userId: selection.member.id, + role: selection.pendingRole!, + }); + } + + if (selection.change !== 'add') { + changes.push(this.container.drafts.removeDraftUser(draftId, selection.member.id)); + } + } + } + + if (changes.length === 0 && adds.length === 0) { + return; + } + if (changes.length !== 0) { + const results = await Promise.all(changes); + console.log(results); + } + + if (adds.length !== 0) { + await this.container.drafts.addDraftUsers(draftId, adds); + } + await this.createDraftUserState(draft, { force: true }); + + void window.showInformationMessage('Cloud Patch successfully updated'); + void this.notifyDidChangeViewDraftState(); + } + + // private shareLocalPatch() { + // if (this._context.open?.draftType !== 'local') return; + + // this.updateCreateFromLocalPatch(this._context.open); + // } + + private switchMode(params: SwitchModeParams) { + this.setMode(params.mode); + } + + private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; + + private updateState(immediate: boolean = false) { + this.host.clearPendingIpcNotifications(); + + if (immediate) { + void this.notifyDidChangeState(); + return; + } + + if (this._notifyDidChangeStateDebounced == null) { + this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); + } + + this._notifyDidChangeStateDebounced(); + } + + @debug({ args: false }) + protected async getState(current: Context): Promise> { + let create; + if (current.mode === 'create' && current.create != null) { + create = await this.getCreateDraftState(current); + } + + let draft; + if (current.mode === 'view' && current.draft != null) { + draft = await this.getViewDraftState(current); + } + + const state = serialize({ + ...this.host.baseWebviewState, + mode: current.mode, + create: create, + draft: draft, + preferences: current.preferences, + orgSettings: current.orgSettings, + }); + return state; + } + + private async notifyDidChangeState() { + this._notifyDidChangeStateDebounced?.cancel(); + return this.host.notify(DidChangeNotification, { state: await this.getState(this._context) }); + } + + private updateCreateDraftState(create: CreateDraft) { + let changesetByRepo: Map; + let allRepos = false; + + if (create.changes != null) { + changesetByRepo = this._context.create?.changes ?? new Map(); + + const updated = new Set(); + for (const change of create.changes) { + const repo = this.container.git.getRepository(Uri.parse(change.repository.uri)); + if (repo == null) continue; + + let changeset: RepositoryChangeset; + if (change.type === 'wip') { + changeset = new RepositoryWipChangeset( + this.container, + repo, + change.revision, + this.onRepositoryWipChanged.bind(this), + change.checked ?? true, + change.expanded ?? true, + ); + } else { + changeset = new RepositoryRefChangeset( + this.container, + repo, + change.revision, + change.files, + change.checked ?? true, + change.expanded ?? true, + ); + } + + updated.add(repo.uri.toString()); + changesetByRepo.set(repo.uri.toString(), changeset); + } + + if (updated.size !== changesetByRepo.size) { + for (const [uri, repoChange] of changesetByRepo) { + if (updated.has(uri)) continue; + repoChange.checked = false; + } + } + } else { + allRepos = create.repositories == null; + const repos = create.repositories ?? this.container.git.openRepositories; + changesetByRepo = new Map( + repos.map(r => [ + r.uri.toString(), + new RepositoryWipChangeset( + this.container, + r, + { to: uncommitted, from: 'HEAD' }, + this.onRepositoryWipChanged.bind(this), + true, + true, // TODO revisit + ), + ]), + ); + } + + this._context.create = { + title: create.title, + description: create.description, + changes: changesetByRepo, + showingAllRepos: allRepos, + visibility: 'public', + }; + this.setMode('create', true); + void this.notifyDidChangeCreateDraftState(); + } + + private async getCreateDraftState(current: Context): Promise { + const { create } = current; + if (create == null) return undefined; + + const repoChanges: Record = {}; + + if (create.changes.size !== 0) { + for (const [id, repo] of create.changes) { + const change = await repo.getChange(); + if (change?.files?.length === 0) continue; // TODO remove when we support dynamic expanded repos + + if (change.checked !== repo.checked) { + change.checked = repo.checked; + } + repoChanges[id] = change; + } + } + + return { + title: create.title, + description: create.description, + changes: repoChanges, + visibility: create.visibility, + userSelections: create.userSelections, + }; + } + + private async notifyDidChangeCreateDraftState() { + return this.host.notify(DidChangeCreateNotification, { + mode: this._context.mode, + create: await this.getCreateDraftState(this._context), + }); + } + + private async trackViewDraft(draft: Draft | LocalDraft | undefined, source?: Sources | undefined) { + if (draft?.draftType !== 'cloud' || draft.type !== 'suggested_pr_change') return; + + draft = await this.ensureDraftContent(draft); + const patch = draft.changesets?.[0].patches.find(p => isRepoLocated(p.repository)); + + let repoPrivacy; + if (isRepository(patch?.repository)) { + repoPrivacy = await this.container.git.visibility(patch.repository.uri); + } + + const entity = getDraftEntityIdentifier(draft, patch); + + this.container.telemetry.sendEvent( + 'codeSuggestionViewed', + { + provider: entity?.provider, + 'repository.visibility': repoPrivacy, + repoPrivacy: repoPrivacy, + draftId: draft.id, + draftPrivacy: draft.visibility, + source: source, + }, + { source: source ?? 'patchDetails' }, + ); + } + + private async updateViewDraftState(draft: LocalDraft | Draft | undefined) { + this._context.draft = draft; + if (draft?.draftType === 'cloud') { + this._context.draftGkDevUrl = this.container.drafts.generateWebUrl(draft); + await this.createDraftUserState(draft, { force: true }); + } + this.setMode('view', true); + void this.notifyDidChangeViewDraftState(); + } + + // eslint-disable-next-line @typescript-eslint/require-await + private async getViewDraftState( + current: Context, + deferredPatchLoading = true, + ): Promise { + if (current.draft == null) return undefined; + + const draft = current.draft; + + // if (draft.draftType === 'local') { + // const { patch } = draft; + // if (patch.repository == null) { + // const repo = this.container.git.getBestRepository(); + // if (repo != null) { + // patch.repository = repo; + // } + // } + + // return { + // draftType: 'local', + // files: patch.files ?? [], + // repoPath: patch.repository?.path, + // repoName: patch.repository?.name, + // baseRef: patch.baseRef, + // }; + // } + + if (draft.draftType === 'cloud') { + if (deferredPatchLoading === true && isDraftMissingContent(draft)) { + setTimeout(async () => { + await this.ensureDraftContent(draft); + + void this.notifyDidChangeViewDraftState(false); + }, 0); + } + + const draftUserState = this._context.draftUserState!; + return { + draftType: 'cloud', + id: draft.id, + type: draft.type, + createdAt: draft.createdAt.getTime(), + updatedAt: draft.updatedAt.getTime(), + author: { + id: draft.author.id, + name: draft.author.name, + email: draft.author.email, + avatar: draft.author.avatarUri?.toString(), + }, + role: draft.role, + title: draft.title, + description: draft.description, + isArchived: draft.isArchived, + archivedReason: draft.archivedReason, + visibility: draft.visibility, + gkDevLink: this._context.draftGkDevUrl, + patches: draft.changesets?.length + ? serialize( + draft.changesets[0].patches.map(p => ({ + ...p, + contents: undefined, + commit: undefined, + repository: { + id: p.gkRepositoryId, + name: p.repository?.name ?? '', + located: isRepoLocated(p.repository), + }, + })), + ) + : undefined, + users: draftUserState.users, + userSelections: draftUserState.selections, + }; + } + + return undefined; + } + + private async createDraftUserState(draft: Draft, options?: { force?: boolean }): Promise { + if (this._context.draftUserState != null && options?.force !== true) { + return; + } + // try to create the state if it doesn't exist + try { + const draftUsers = await this.container.drafts.getDraftUsers(draft.id); + if (draftUsers.length === 0) { + return; + } + + const users: DraftUser[] = []; + const userSelections: DraftUserSelection[] = []; + const members = await this.getOrganizationMembers(); + for (const user of draftUsers) { + users.push(user); + const member = members.find(m => m.id === user.userId)!; + userSelections.push(toDraftUserSelection(member, user)); + } + userSelections.sort( + (a, b) => + ((a.pendingRole ?? a.member.role) === 'owner' ? -1 : 1) - + ((b.pendingRole ?? b.member.role) === 'owner' ? -1 : 1) || + a.member.name.localeCompare(b.member.name), + ); + + this._context.draftUserState = { users: users, selections: userSelections }; + } catch (_ex) { + debugger; + } + } + + private async notifyDidChangeViewDraftState(deferredPatchLoading = true) { + return this.host.notify(DidChangeDraftNotification, { + mode: this._context.mode, + draft: serialize(await this.getViewDraftState(this._context, deferredPatchLoading)), + }); + } + + private updatePreferences(preferences: UpdateablePreferences) { + if ( + this._context.preferences?.files?.compact === preferences.files?.compact && + this._context.preferences?.files?.icon === preferences.files?.icon && + this._context.preferences?.files?.layout === preferences.files?.layout && + this._context.preferences?.files?.threshold === preferences.files?.threshold + ) { + return; + } + + if (preferences.files != null) { + if (this._context.preferences?.files?.compact !== preferences.files?.compact) { + void configuration.updateEffective('views.patchDetails.files.compact', preferences.files?.compact); + } + if (this._context.preferences?.files?.icon !== preferences.files?.icon) { + void configuration.updateEffective('views.patchDetails.files.icon', preferences.files?.icon); + } + if (this._context.preferences?.files?.layout !== preferences.files?.layout) { + void configuration.updateEffective('views.patchDetails.files.layout', preferences.files?.layout); + } + if (this._context.preferences?.files?.threshold !== preferences.files?.threshold) { + void configuration.updateEffective('views.patchDetails.files.threshold', preferences.files?.threshold); + } + + this._context.preferences.files = preferences.files; + } + + void this.notifyDidChangePreferences(); + } + + private async notifyDidChangePreferences() { + return this.host.notify(DidChangePreferencesNotification, { preferences: this._context.preferences }); + } + + private async getDraftPatch(draft: Draft, gkRepositoryId?: GkRepositoryId): Promise { + draft.changesets = await this.ensureChangesets(draft); + + const patch = + gkRepositoryId == null + ? draft.changesets[0].patches?.[0] + : draft.changesets[0].patches?.find(p => p.gkRepositoryId === gkRepositoryId); + if (patch == null) return undefined; + + if (patch.contents == null || patch.files == null || patch.repository == null) { + const details = await this.container.drafts.getPatchDetails(patch.id); + patch.contents = details.contents; + patch.files = details.files; + patch.repository = details.repository; + } + + return patch; + } + + private async getFileCommitFromParams( + params: ExecuteFileActionParams, + ): Promise< + | [commit: GitCommit, file: GitFileChange, revision?: Required>] + | undefined + > { + let [commit, revision] = await this.getOrCreateCommit(params); + + if (commit != null && revision != null) { + return [ + commit, + new GitFileChange( + params.repoPath, + params.path, + params.status, + params.originalPath, + undefined, + undefined, + params.staged, + ), + revision, + ]; + } + + commit = await commit?.getCommitForFile(params.path, params.staged); + return commit != null ? [commit, commit.file!, revision] : undefined; + } + + private async getOrCreateCommit( + file: DraftPatchFileChange, + ): Promise<[commit: GitCommit | undefined, revision?: PatchRevisionRange]> { + switch (this.mode) { + case 'create': + return this.getCommitForFile(file); + case 'view': + return [await this.getOrCreateCommitForPatch(file.gkRepositoryId)]; + default: + return [undefined]; + } + } + + async getCommitForFile( + file: DraftPatchFileChange, + ): Promise<[commit: GitCommit | undefined, revision?: PatchRevisionRange]> { + const changeset = find(this._context.create!.changes.values(), cs => cs.repository.path === file.repoPath); + if (changeset == null) return [undefined]; + + const change = await changeset.getChange(); + if (change == null) return [undefined]; + + if (change.type === 'revision') { + const commit = await this.container.git.getCommit(file.repoPath, change.revision.to ?? uncommitted); + if ( + change.revision.to === change.revision.from || + (change.revision.from.length === change.revision.to.length + 1 && + change.revision.from.endsWith('^') && + change.revision.from.startsWith(change.revision.to)) + ) { + return [commit]; + } + + return [commit, change.revision]; + } else if (change.type === 'wip') { + return [await this.container.git.getCommit(file.repoPath, change.revision.to ?? uncommitted)]; + } + + return [undefined]; + } + + async getOrCreateCommitForPatch(gkRepositoryId: GkRepositoryId): Promise { + const draft = this._context.draft!; + if (draft.draftType === 'local') return undefined; // TODO + + const patch = await this.getDraftPatch(draft, gkRepositoryId); + if (patch?.repository == null) return undefined; + + if (patch?.commit == null) { + const repo = await this.getOrLocatePatchRepository(patch, { prompt: true }); + if (repo == null) return undefined; + + let baseRef = patch.baseRef ?? 'HEAD'; + + do { + try { + const commit = await this.container.git.createUnreachableCommitForPatch( + repo.uri, + patch.contents!, + baseRef, + draft.title, + ); + patch.commit = commit; + } catch (ex) { + if (baseRef != null) { + // const head = { title: 'HEAD' }; + const chooseBase = { title: 'Choose Base...' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + + const result = await window.showErrorMessage( + `Unable to apply the patch onto ${ + baseRef === 'HEAD' ? 'HEAD' : `'${shortenRevision(baseRef)}'` + }.\nDo you want to try again on a different base?`, + { modal: true }, + chooseBase, + cancel, + // ...(baseRef === 'HEAD' ? [chooseBase, cancel] : [head, chooseBase, cancel]), + ); + + if (result == null || result === cancel) break; + // if (result === head) { + // baseRef = 'HEAD'; + // continue; + // } + + if (result === chooseBase) { + const ref = await showReferencePicker( + repo.path, + `Choose New Base for Patch`, + `Choose a new base to apply the patch onto`, + { + allowRevisions: true, + include: + ReferencesQuickPickIncludes.BranchesAndTags | ReferencesQuickPickIncludes.HEAD, + }, + ); + if (ref == null) break; + + baseRef = ref.ref; + continue; + } + } else { + void window.showErrorMessage( + `Unable to apply the patch on base '${shortenRevision(baseRef)}': ${ex.message}`, + ); + } + } + + break; + } while (true); + } + + return patch?.commit; + } + + private async openFile(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file] = result; + + void openFile(file, commit, { + preserveFocus: true, + preview: true, + ...params.showOptions, + }); + } + + private getChangesTitleNote() { + if ( + this._context.mode === 'view' && + this._context.draft?.draftType === 'cloud' && + this._context.draft.type === 'suggested_pr_change' + ) { + return 'Code Suggestion'; + } + + return 'Patch'; + } + + private async openFileComparisonWithPrevious(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file, revision] = result; + + const titleNote = this.getChangesTitleNote(); + + void openChanges( + file, + revision != null + ? { repoPath: commit.repoPath, rhs: revision.to ?? uncommitted, lhs: revision.from } + : commit, + { + preserveFocus: true, + preview: true, + ...params.showOptions, + rhsTitle: this.mode === 'view' ? `${basename(file.path)} (${titleNote})` : undefined, + }, + ); + this.container.events.fire('file:selected', { uri: file.uri }, { source: this.host.id }); + } + + private async openFileComparisonWithWorking(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file, revision] = result; + + const titleNote = this.getChangesTitleNote(); + + void openChangesWithWorking(file, revision != null ? { repoPath: commit.repoPath, ref: revision.to } : commit, { + preserveFocus: true, + preview: true, + ...params.showOptions, + lhsTitle: this.mode === 'view' ? `${basename(file.path)} (${titleNote})` : undefined, + }); + } + + private async getOrLocatePatchRepository( + patch: DraftPatch, + options?: { notifyOnLocation?: boolean; prompt?: boolean }, + ): Promise { + if (patch.repository == null || isRepository(patch.repository)) { + return patch.repository; + } + + const repo = await this.container.repositoryIdentity.getRepository(patch.repository, { + openIfNeeded: true, + prompt: options?.prompt ?? false, + }); + if (repo == null) { + void window.showErrorMessage(`Unable to locate repository '${patch.repository.name}'`); + } else { + patch.repository = repo; + + if (options?.notifyOnLocation) { + void this.notifyPatchRepositoryUpdated(patch); + } + } + + return repo; + } + + // Ensures that changesets arent mutated twice on the same draft + @gate(d => d.id) + private async ensureChangesets(draft: Draft) { + draft.changesets ??= await this.container.drafts.getChangesets(draft.id); + return draft.changesets; + } + + private async ensureDraftContent(draft: Draft): Promise { + if (!isDraftMissingContent(draft)) { + return draft; + } + + draft.changesets = await this.ensureChangesets(draft); + + const patches = draft.changesets + .flatMap(cs => cs.patches) + .filter(p => p.contents == null || p.files == null || p.repository == null); + + if (patches.length === 0) { + return draft; + } + + const patchDetails = await Promise.allSettled(patches.map(p => this.container.drafts.getPatchDetails(p))); + + for (const d of patchDetails) { + if (d.status === 'fulfilled') { + const patch = patches.find(p => p.id === d.value.id); + if (patch != null) { + patch.contents = d.value.contents; + patch.files = d.value.files; + patch.repository = d.value.repository; + await this.getOrLocatePatchRepository(patch); + } + } + } + + return draft; + } +} + +function isDraftMissingContent(draft: Draft): boolean { + if (draft.changesets == null) return true; + + return some(draft.changesets, cs => + cs.patches.some(p => p.contents == null || p.files == null || p.repository == null), + ); +} + +function isRepoLocated(repo: DraftPatch['repository']): boolean { + return repo != null && isRepository(repo); +} + +function toDraftUserSelection( + member: OrganizationMember, + user?: DraftUserSelection['user'], + pendingRole?: DraftPendingUser['role'], + change?: DraftUserSelection['change'], +): DraftUserSelection { + return { + change: change, + member: member, + user: user, + pendingRole: pendingRole, + avatarUrl: member?.email != null ? getAvatarUri(member.email, undefined).toString() : undefined, + }; +} diff --git a/src/plus/webviews/patchDetails/protocol.ts b/src/plus/webviews/patchDetails/protocol.ts new file mode 100644 index 0000000000000..7283c4cef1c7b --- /dev/null +++ b/src/plus/webviews/patchDetails/protocol.ts @@ -0,0 +1,318 @@ +import type { TextDocumentShowOptions } from 'vscode'; +import type { Config } from '../../../config'; +import type { GitFileChangeShape } from '../../../git/models/file'; +import type { PatchRevisionRange } from '../../../git/models/patch'; +import type { Repository } from '../../../git/models/repository'; +import type { + Draft, + DraftArchiveReason, + DraftPatch, + DraftPatchFileChange, + DraftPendingUser, + DraftRole, + DraftType, + DraftUser, + DraftVisibility, + LocalDraft, +} from '../../../gk/models/drafts'; +import type { GkRepositoryId } from '../../../gk/models/repositoryIdentities'; +import type { DateTimeFormat } from '../../../system/date'; +import type { Serialized } from '../../../system/vscode/serialize'; +import type { IpcScope, WebviewState } from '../../../webviews/protocol'; +import { IpcCommand, IpcNotification, IpcRequest } from '../../../webviews/protocol'; +import type { OrganizationMember } from '../../gk/account/organization'; + +export const scope: IpcScope = 'patchDetails'; + +export const messageHeadlineSplitterToken = '\x00\n\x00'; + +export type FileShowOptions = TextDocumentShowOptions; + +export type PatchDetails = Serialized< + Omit & { + repository: { id: GkRepositoryId; name: string; located: boolean }; + } +>; + +interface CreateDraftFromChanges { + title?: string; + description?: string; + changes: Change[]; + repositories?: never; +} + +interface CreateDraftFromRepositories { + title?: string; + description?: string; + changes?: never; + repositories: Repository[] | undefined; +} + +export type CreateDraft = CreateDraftFromChanges | CreateDraftFromRepositories; +export type ViewDraft = LocalDraft | Draft; + +interface LocalDraftDetails { + draftType: 'local'; + + id?: never; + author?: never; + createdAt?: never; + updatedAt?: never; + + title?: string; + description?: string; + + patches?: PatchDetails[]; +} + +export interface CloudDraftDetails { + draftType: 'cloud'; + + id: string; + type: DraftType; + createdAt: number; + updatedAt: number; + author: { + id: string; + name: string; + email: string | undefined; + avatar?: string; + }; + + role: DraftRole; + visibility: DraftVisibility; + + title: string; + description?: string; + + isArchived: boolean; + archivedReason?: DraftArchiveReason; + + gkDevLink?: string; + + patches?: PatchDetails[]; + + users?: DraftUser[]; + userSelections?: DraftUserSelection[]; +} + +export type DraftDetails = LocalDraftDetails | CloudDraftDetails; + +export interface DraftUserSelection { + change: 'add' | 'modify' | 'delete' | undefined; + member: OrganizationMember; + user: DraftUser | undefined; + pendingRole: DraftPendingUser['role'] | undefined; + avatarUrl?: string; +} + +export interface Preferences { + avatars: boolean; + dateFormat: DateTimeFormat | string; + files: Config['views']['patchDetails']['files']; + indentGuides: 'none' | 'onHover' | 'always'; + indent: number | undefined; +} + +export type UpdateablePreferences = Partial>; + +export type Mode = 'create' | 'view'; +export type ChangeType = 'revision' | 'wip'; + +export interface WipChange { + type: 'wip'; + repository: { name: string; path: string; uri: string }; + revision: PatchRevisionRange; + files: GitFileChangeShape[] | undefined; + + checked?: boolean | 'staged'; + expanded?: boolean; +} + +export interface RevisionChange { + type: 'revision'; + repository: { name: string; path: string; uri: string }; + revision: PatchRevisionRange; + files: GitFileChangeShape[]; + + checked?: boolean | 'staged'; + expanded?: boolean; +} + +export type Change = WipChange | RevisionChange; + +export interface CreatePatchState { + title?: string; + description?: string; + changes: Record; + creationError?: string; + visibility: DraftVisibility; + userSelections?: DraftUserSelection[]; +} + +export interface State extends WebviewState { + mode: Mode; + + preferences: Preferences; + orgSettings: { + ai: boolean; + byob: boolean; + }; + + draft?: DraftDetails; + create?: CreatePatchState; +} + +export type ShowCommitDetailsViewCommandArgs = string[]; + +// COMMANDS + +export interface ApplyPatchParams { + details: DraftDetails; + targetRef?: string; // a branch name. default to HEAD if not supplied + target: 'current' | 'branch' | 'worktree'; + selected: PatchDetails['id'][]; +} +export const ApplyPatchCommand = new IpcCommand(scope, 'apply'); + +export interface ArchiveDraftParams { + reason?: Exclude; +} +export const ArchiveDraftCommand = new IpcCommand(scope, 'archive'); + +export interface CreatePatchParams { + title: string; + description?: string; + changesets: Record; + visibility: DraftVisibility; + userSelections?: DraftUserSelection[]; +} +export const CreatePatchCommand = new IpcCommand(scope, 'create'); + +export interface OpenInCommitGraphParams { + repoPath: string; + ref: string; +} +export const OpenInCommitGraphCommand = new IpcCommand(scope, 'openInGraph'); + +export interface DraftPatchCheckedParams { + patch: PatchDetails; + checked: boolean; +} +export const DraftPatchCheckedCommand = new IpcCommand(scope, 'checked'); + +export interface SelectPatchRepoParams { + repoPath: string; +} +export const SelectPatchRepoCommand = new IpcCommand(scope, 'selectRepo'); + +export const SelectPatchBaseCommand = new IpcCommand(scope, 'selectBase'); + +export interface ExecuteFileActionParams extends DraftPatchFileChange { + showOptions?: TextDocumentShowOptions; +} +export const ExecuteFileActionCommand = new IpcCommand(scope, 'file/actions/execute'); +export const OpenFileCommand = new IpcCommand(scope, 'file/open'); +export const OpenFileOnRemoteCommand = new IpcCommand(scope, 'file/openOnRemote'); +export const OpenFileCompareWorkingCommand = new IpcCommand(scope, 'file/compareWorking'); +export const OpenFileComparePreviousCommand = new IpcCommand(scope, 'file/comparePrevious'); + +export type UpdatePreferenceParams = UpdateablePreferences; +export const UpdatePreferencesCommand = new IpcCommand(scope, 'preferences/update'); + +export interface SwitchModeParams { + repoPath?: string; + mode: Mode; +} +export const SwitchModeCommand = new IpcCommand(scope, 'switchMode'); + +export const CopyCloudLinkCommand = new IpcCommand(scope, 'cloud/copyLink'); + +export const CreateFromLocalPatchCommand = new IpcCommand(scope, 'local/createPatch'); + +export interface UpdateCreatePatchRepositoryCheckedStateParams { + repoUri: string; + checked: boolean | 'staged'; +} +export const UpdateCreatePatchRepositoryCheckedStateCommand = + new IpcCommand(scope, 'create/repository/check'); + +export interface UpdateCreatePatchMetadataParams { + title: string; + description: string | undefined; + visibility: DraftVisibility; +} +export const UpdateCreatePatchMetadataCommand = new IpcCommand( + scope, + 'update/create/metadata', +); + +export interface UpdatePatchDetailsMetadataParams { + visibility: DraftVisibility; +} +export const UpdatePatchDetailsMetadataCommand = new IpcCommand( + scope, + 'update/draft/metadata', +); + +export const UpdatePatchDetailsPermissionsCommand = new IpcCommand(scope, 'update/draft/permissions'); + +export const UpdatePatchUsersCommand = new IpcCommand(scope, 'update/users'); + +export interface UpdatePatchUserSelection { + selection: DraftUserSelection; + role: Exclude | 'remove'; +} +export const UpdatePatchUserSelectionCommand = new IpcCommand(scope, 'update/userSelection'); + +// REQUESTS + +export type DidExplainParams = + | { + summary: string | undefined; + error?: undefined; + } + | { error: { message: string } }; +export const ExplainRequest = new IpcRequest(scope, 'explain'); + +export type DidGenerateParams = + | { + title: string | undefined; + description: string | undefined; + error?: undefined; + } + | { error: { message: string } }; +export const GenerateRequest = new IpcRequest(scope, 'generate'); + +// NOTIFICATIONS + +export interface DidChangeParams { + state: Serialized; +} +export const DidChangeNotification = new IpcNotification(scope, 'didChange', true); + +export type DidChangeCreateParams = Pick, 'create' | 'mode'>; +export const DidChangeCreateNotification = new IpcNotification(scope, 'create/didChange'); + +export type DidChangeDraftParams = Pick, 'draft' | 'mode'>; +export const DidChangeDraftNotification = new IpcNotification(scope, 'draft/didChange'); + +export type DidChangePreferencesParams = Pick, 'preferences'>; +export const DidChangePreferencesNotification = new IpcNotification( + scope, + 'preferences/didChange', +); + +export interface DidChangePatchRepositoryParams { + patch: PatchDetails; +} +export const DidChangePatchRepositoryNotification = new IpcNotification( + scope, + 'draft/didChangeRepository', +); + +export type DidChangeOrgSettings = Pick, 'orgSettings'>; +export const DidChangeOrgSettingsNotification = new IpcNotification( + scope, + 'org/settings/didChange', +); diff --git a/src/plus/webviews/patchDetails/registration.ts b/src/plus/webviews/patchDetails/registration.ts new file mode 100644 index 0000000000000..e86d291bb4960 --- /dev/null +++ b/src/plus/webviews/patchDetails/registration.ts @@ -0,0 +1,84 @@ +import { ViewColumn } from 'vscode'; +import { Commands } from '../../../constants.commands'; +import type { Sources } from '../../../constants.telemetry'; +import { executeCommand } from '../../../system/vscode/command'; +import { configuration } from '../../../system/vscode/configuration'; +import { setContext } from '../../../system/vscode/context'; +import type { Serialized } from '../../../system/vscode/serialize'; +import type { WebviewPanelShowCommandArgs, WebviewsController } from '../../../webviews/webviewsController'; +import type { CreateDraft, State, ViewDraft } from './protocol'; + +export type ShowCreateDraft = { + mode: 'create'; + create?: CreateDraft; + source?: Sources; +}; + +export type ShowViewDraft = { + mode: 'view'; + draft: ViewDraft; + source?: Sources; +}; + +export type PatchDetailsWebviewShowingArgs = [ShowCreateDraft | ShowViewDraft]; + +export function registerPatchDetailsWebviewView(controller: WebviewsController) { + return controller.registerWebviewView, PatchDetailsWebviewShowingArgs>( + { + id: 'gitlens.views.patchDetails', + fileName: 'patchDetails.html', + title: 'Patch', + contextKeyPrefix: `gitlens:webviewView:patchDetails`, + trackingFeature: 'patchDetailsView', + plusFeature: true, + webviewHostOptions: { + retainContextWhenHidden: false, + }, + }, + async (container, host) => { + const { PatchDetailsWebviewProvider } = await import( + /* webpackChunkName: "webview-patchDetails" */ './patchDetailsWebview' + ); + return new PatchDetailsWebviewProvider(container, host); + }, + async (...args) => { + if (configuration.get('cloudPatches.experimental.layout') === 'editor') { + await setContext('gitlens:views:patchDetails:mode', undefined); + void executeCommand(Commands.ShowPatchDetailsPage, undefined, ...args); + return; + } + + const arg = args[0]; + if (arg == null) return; + + await setContext('gitlens:views:patchDetails:mode', 'state' in arg ? arg.state.mode : arg.mode); + }, + ); +} + +export function registerPatchDetailsWebviewPanel(controller: WebviewsController) { + return controller.registerWebviewPanel, PatchDetailsWebviewShowingArgs>( + { id: Commands.ShowPatchDetailsPage, options: { preserveInstance: true } }, + { + id: 'gitlens.patchDetails', + fileName: 'patchDetails.html', + iconPath: 'images/gitlens-icon.png', + title: 'Patch', + contextKeyPrefix: `gitlens:webview:patchDetails`, + trackingFeature: 'patchDetailsWebview', + plusFeature: true, + column: ViewColumn.Active, + webviewHostOptions: { + retainContextWhenHidden: false, + enableFindWidget: false, + }, + allowMultipleInstances: true, + }, + async (container, host) => { + const { PatchDetailsWebviewProvider } = await import( + /* webpackChunkName: "webview-patchDetails" */ './patchDetailsWebview' + ); + return new PatchDetailsWebviewProvider(container, host); + }, + ); +} diff --git a/src/plus/webviews/patchDetails/repositoryChangeset.ts b/src/plus/webviews/patchDetails/repositoryChangeset.ts new file mode 100644 index 0000000000000..8358d70ec69fe --- /dev/null +++ b/src/plus/webviews/patchDetails/repositoryChangeset.ts @@ -0,0 +1,234 @@ +import { Disposable } from 'vscode'; +import type { Container } from '../../../container'; +import type { GitFileChangeShape } from '../../../git/models/file'; +import type { PatchRevisionRange } from '../../../git/models/patch'; +import type { Repository } from '../../../git/models/repository'; +import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; +import type { Change, ChangeType, RevisionChange } from './protocol'; + +export interface RepositoryChangeset extends Disposable { + type: ChangeType; + repository: Repository; + revision: PatchRevisionRange; + getChange(): Promise; + + suspend(): void; + resume(): void; + + checked: Change['checked']; + expanded: boolean; +} + +export class RepositoryRefChangeset implements RepositoryChangeset { + readonly type = 'revision'; + + constructor( + private readonly container: Container, + public readonly repository: Repository, + public readonly revision: PatchRevisionRange, + private readonly files: RevisionChange['files'], + checked: Change['checked'], + expanded: boolean, + ) { + this.checked = checked; + this.expanded = expanded; + } + + dispose() {} + + suspend() {} + + resume() {} + + private _checked: Change['checked'] = false; + get checked(): Change['checked'] { + return this._checked; + } + set checked(value: Change['checked']) { + this._checked = value; + } + + private _expanded = false; + get expanded(): boolean { + return this._expanded; + } + set expanded(value: boolean) { + if (this._expanded === value) return; + + this._expanded = value; + } + + // private _files: Promise<{ files: Change['files'] }> | undefined; + // eslint-disable-next-line @typescript-eslint/require-await + async getChange(): Promise { + // let filesResult; + // if (this.expanded) { + // if (this._files == null) { + // this._files = this.getFiles(); + // } + + // filesResult = await this._files; + // } + + return { + type: 'revision', + repository: { + name: this.repository.name, + path: this.repository.path, + uri: this.repository.uri.toString(), + }, + revision: this.revision, + files: this.files, //filesResult?.files, + checked: this.checked, + expanded: this.expanded, + }; + } + + // private async getFiles(): Promise<{ files: Change['files'] }> { + // const commit = await this.container.git.getCommit(this.repository.path, this.range.sha!); + + // const files: GitFileChangeShape[] = []; + // if (commit != null) { + // for (const file of commit.files ?? []) { + // const change = { + // repoPath: file.repoPath, + // path: file.path, + // status: file.status, + // originalPath: file.originalPath, + // }; + + // files.push(change); + // } + // } + + // return { files: files }; + // } +} + +export class RepositoryWipChangeset implements RepositoryChangeset { + readonly type = 'wip'; + + private _disposable: Disposable | undefined; + + constructor( + private readonly container: Container, + public readonly repository: Repository, + public readonly revision: PatchRevisionRange, + private readonly onDidChangeRepositoryWip: (e: RepositoryWipChangeset) => void, + checked: Change['checked'], + expanded: boolean, + ) { + this.checked = checked; + this.expanded = expanded; + } + + dispose() { + this._disposable?.dispose(); + this._disposable = undefined; + } + + suspend() { + this._disposable?.dispose(); + this._disposable = undefined; + } + + resume() { + this._files = undefined; + if (this._expanded) { + this.subscribe(); + } + } + + private _checked: Change['checked'] = false; + get checked(): Change['checked'] { + return this._checked; + } + set checked(value: Change['checked']) { + this._checked = value; + } + + private _expanded = false; + get expanded(): boolean { + return this._expanded; + } + set expanded(value: boolean) { + if (this._expanded === value) return; + + this._files = undefined; + if (value) { + this.subscribe(); + } else { + this._disposable?.dispose(); + this._disposable = undefined; + } + this._expanded = value; + } + + private _files: Promise<{ files: Change['files'] }> | undefined; + async getChange(): Promise { + let filesResult; + if (this.expanded) { + if (this._files == null) { + this._files = this.getFiles(); + } + + filesResult = await this._files; + } + + return { + type: 'wip', + repository: { + name: this.repository.name, + path: this.repository.path, + uri: this.repository.uri.toString(), + }, + revision: this.revision, + files: filesResult?.files, + checked: this.checked, + expanded: this.expanded, + }; + } + + private subscribe() { + if (this._disposable != null) return; + + this._disposable = Disposable.from( + this.repository.watchFileSystem(1000), + this.repository.onDidChangeFileSystem(() => this.onDidChangeWip(), this), + this.repository.onDidChange(e => { + if (e.changed(RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) { + this.onDidChangeWip(); + } + }), + ); + } + + private onDidChangeWip() { + this._files = undefined; + this.onDidChangeRepositoryWip(this); + } + + private async getFiles(): Promise<{ files: Change['files'] }> { + const status = await this.container.git.getStatusForRepo(this.repository.path); + + const files: GitFileChangeShape[] = []; + if (status != null) { + for (const file of status.files) { + const change = { + repoPath: file.repoPath, + path: file.path, + status: file.status, + originalPath: file.originalPath, + staged: file.staged, + }; + + files.push(change); + if (file.staged && file.wip) { + files.push({ ...change, staged: false }); + } + } + } + + return { files: files }; + } +} diff --git a/src/plus/webviews/timeline/protocol.ts b/src/plus/webviews/timeline/protocol.ts index a585a5c90bad2..ca184980f5feb 100644 --- a/src/plus/webviews/timeline/protocol.ts +++ b/src/plus/webviews/timeline/protocol.ts @@ -1,11 +1,13 @@ import type { FeatureAccess } from '../../../features'; -import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; +import type { IpcScope, WebviewState } from '../../../webviews/protocol'; +import { IpcCommand, IpcNotification } from '../../../webviews/protocol'; -export interface State { +export const scope: IpcScope = 'timeline'; + +export interface State extends WebviewState { dataset?: Commit[]; - emptyMessage?: string; period: Period; - title: string; + title?: string; sha?: string; uri?: string; @@ -26,12 +28,9 @@ export interface Commit { sort: number; } -export type Period = `${number}|${'D' | 'M' | 'Y'}`; +export type Period = `${number}|${'D' | 'M' | 'Y'}` | 'all'; -export interface DidChangeParams { - state: State; -} -export const DidChangeNotificationType = new IpcNotificationType('timeline/didChange'); +// COMMANDS export interface OpenDataPointParams { data?: { @@ -39,9 +38,16 @@ export interface OpenDataPointParams { selected: boolean; }; } -export const OpenDataPointCommandType = new IpcCommandType('timeline/point/open'); +export const OpenDataPointCommand = new IpcCommand(scope, 'point/open'); export interface UpdatePeriodParams { period: Period; } -export const UpdatePeriodCommandType = new IpcCommandType('timeline/period/update'); +export const UpdatePeriodCommand = new IpcCommand(scope, 'period/update'); + +// NOTIFICATIONS + +export interface DidChangeParams { + state: State; +} +export const DidChangeNotification = new IpcNotification(scope, 'didChange'); diff --git a/src/plus/webviews/timeline/registration.ts b/src/plus/webviews/timeline/registration.ts new file mode 100644 index 0000000000000..767ecf5985e7e --- /dev/null +++ b/src/plus/webviews/timeline/registration.ts @@ -0,0 +1,73 @@ +import type { Uri } from 'vscode'; +import { Disposable, ViewColumn } from 'vscode'; +import { Commands } from '../../../constants.commands'; +import { registerCommand } from '../../../system/vscode/command'; +import { configuration } from '../../../system/vscode/configuration'; +import type { ViewFileNode } from '../../../views/nodes/abstract/viewFileNode'; +import type { WebviewPanelsProxy, WebviewsController } from '../../../webviews/webviewsController'; +import type { State } from './protocol'; + +export type TimelineWebviewShowingArgs = [Uri | ViewFileNode]; + +export function registerTimelineWebviewPanel(controller: WebviewsController) { + return controller.registerWebviewPanel( + { id: Commands.ShowTimelinePage, options: { preserveInstance: true } }, + { + id: 'gitlens.timeline', + fileName: 'timeline.html', + iconPath: 'images/gitlens-icon.png', + title: 'Visual File History', + contextKeyPrefix: `gitlens:webview:timeline`, + trackingFeature: 'timelineWebview', + plusFeature: true, + column: ViewColumn.Active, + webviewHostOptions: { + retainContextWhenHidden: false, + enableFindWidget: false, + }, + allowMultipleInstances: configuration.get('visualHistory.allowMultiple'), + }, + async (container, host) => { + const { TimelineWebviewProvider } = await import( + /* webpackChunkName: "webview-timeline" */ './timelineWebview' + ); + return new TimelineWebviewProvider(container, host); + }, + ); +} + +export function registerTimelineWebviewView(controller: WebviewsController) { + return controller.registerWebviewView( + { + id: 'gitlens.views.timeline', + fileName: 'timeline.html', + title: 'Visual File History', + contextKeyPrefix: `gitlens:webviewView:timeline`, + trackingFeature: 'timelineView', + plusFeature: true, + webviewHostOptions: { + retainContextWhenHidden: false, + }, + }, + async (container, host) => { + const { TimelineWebviewProvider } = await import( + /* webpackChunkName: "webview-timeline" */ './timelineWebview' + ); + return new TimelineWebviewProvider(container, host); + }, + ); +} + +export function registerTimelineWebviewCommands(panels: WebviewPanelsProxy) { + return Disposable.from( + registerCommand( + Commands.ShowInTimeline, + (...args: TimelineWebviewShowingArgs) => void panels.show(undefined, ...args), + ), + registerCommand(`${panels.id}.refresh`, () => void panels.getActiveInstance()?.refresh(true)), + registerCommand( + `${panels.id}.split`, + () => void panels.splitActiveInstance({ preserveInstance: false, column: ViewColumn.Beside }), + ), + ); +} diff --git a/src/plus/webviews/timeline/timelineWebview.ts b/src/plus/webviews/timeline/timelineWebview.ts index f5cfc2a39ed52..251175b0bb9fc 100644 --- a/src/plus/webviews/timeline/timelineWebview.ts +++ b/src/plus/webviews/timeline/timelineWebview.ts @@ -1,103 +1,147 @@ -'use strict'; -import type { Disposable, TextEditor, ViewColumn } from 'vscode'; -import { Uri, window } from 'vscode'; -import { configuration } from '../../../configuration'; -import { Commands, ContextKeys } from '../../../constants'; +import type { TextEditor } from 'vscode'; +import { Disposable, Uri, window } from 'vscode'; +import { proBadge } from '../../../constants'; +import { Commands } from '../../../constants.commands'; import type { Container } from '../../../container'; +import type { CommitSelectedEvent, FileSelectedEvent } from '../../../eventBus'; import { PlusFeatures } from '../../../features'; -import { showDetailsView } from '../../../git/actions/commit'; +import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; import { GitUri } from '../../../git/gitUri'; import { getChangedFilesCount } from '../../../git/models/commit'; import type { RepositoryChangeEvent } from '../../../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; -import { registerCommand } from '../../../system/command'; import { createFromDateDelta } from '../../../system/date'; import { debug } from '../../../system/decorators/log'; import type { Deferrable } from '../../../system/function'; import { debounce } from '../../../system/function'; import { filter } from '../../../system/iterable'; -import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; +import { executeCommand, registerCommand } from '../../../system/vscode/command'; +import { configuration } from '../../../system/vscode/configuration'; +import { hasVisibleTrackableTextEditor, isTrackableTextEditor } from '../../../system/vscode/utils'; +import { isViewFileNode } from '../../../views/nodes/abstract/viewFileNode'; import type { IpcMessage } from '../../../webviews/protocol'; -import { onIpc } from '../../../webviews/protocol'; -import { WebviewBase } from '../../../webviews/webviewBase'; -import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; -import { ensurePlusFeaturesEnabled } from '../../subscription/utils'; +import { updatePendingContext } from '../../../webviews/webviewController'; +import type { WebviewHost, WebviewProvider, WebviewShowingArgs } from '../../../webviews/webviewProvider'; +import type { WebviewShowOptions } from '../../../webviews/webviewsController'; +import { isSerializedState } from '../../../webviews/webviewsController'; +import type { SubscriptionChangeEvent } from '../../gk/account/subscriptionService'; import type { Commit, Period, State } from './protocol'; -import { DidChangeNotificationType, OpenDataPointCommandType, UpdatePeriodCommandType } from './protocol'; -import { generateRandomTimelineDataset } from './timelineWebviewView'; +import { DidChangeNotification, OpenDataPointCommand, UpdatePeriodCommand } from './protocol'; +import type { TimelineWebviewShowingArgs } from './registration'; interface Context { uri: Uri | undefined; period: Period | undefined; + etagRepositories: number | undefined; etagRepository: number | undefined; etagSubscription: number | undefined; } const defaultPeriod: Period = '3|M'; -export class TimelineWebview extends WebviewBase { +export class TimelineWebviewProvider implements WebviewProvider { private _bootstraping = true; /** The context the webview has */ private _context: Context; /** The context the webview should have */ private _pendingContext: Partial | undefined; + private readonly _disposable: Disposable; - constructor(container: Container) { - super( - container, - 'gitlens.timeline', - 'timeline.html', - 'images/gitlens-icon.png', - 'Visual File History', - `${ContextKeys.WebviewPrefix}timeline`, - 'timelineWebview', - Commands.ShowTimelinePage, - ); + constructor( + private readonly container: Container, + private readonly host: WebviewHost, + ) { this._context = { uri: undefined, period: defaultPeriod, + etagRepositories: this.container.git.etag, etagRepository: 0, - etagSubscription: 0, + etagSubscription: this.container.subscription.etag, }; + + this._context = { ...this._context, ...this._pendingContext }; + this._pendingContext = undefined; + + if (this.host.isHost('editor')) { + this._disposable = Disposable.from( + this.container.subscription.onDidChange(this.onSubscriptionChanged, this), + this.container.git.onDidChangeRepository(this.onRepositoryChanged, this), + ); + } else { + this.host.description = proBadge; + this._disposable = Disposable.from( + this.container.subscription.onDidChange(this.onSubscriptionChanged, this), + this.container.git.onDidChangeRepository(this.onRepositoryChanged, this), + this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), + window.onDidChangeActiveTextEditor(debounce(this.onActiveEditorChanged, 250), this), + this.container.events.on('file:selected', debounce(this.onFileSelected, 250), this), + ); + } } - override async show(options?: { column?: ViewColumn; preserveFocus?: boolean }, ...args: unknown[]): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; + dispose() { + this._disposable.dispose(); + } - return super.show(options, ...args); + onReloaded(): void { + void this.notifyDidChangeState(true); } - protected override onInitializing(): Disposable[] | undefined { - this._context = { - uri: undefined, - period: defaultPeriod, - etagRepository: 0, - etagSubscription: this.container.subscription.etag, - }; + canReuseInstance(...args: WebviewShowingArgs): boolean | undefined { + let uri: Uri | undefined; + + const [arg] = args; + if (arg != null) { + if (arg instanceof Uri) { + uri = arg; + } else if (isViewFileNode(arg)) { + uri = arg.uri; + } else if (isSerializedState(arg) && arg.state.uri != null) { + uri = Uri.parse(arg.state.uri); + } + } else { + uri = window.activeTextEditor?.document.uri; + } - this.updatePendingEditor(window.activeTextEditor); - this._context = { ...this._context, ...this._pendingContext }; - this._pendingContext = undefined; + return uri?.toString() === this._context.uri?.toString() ? true : undefined; + } - return [ - this.container.subscription.onDidChange(this.onSubscriptionChanged, this), - this.container.git.onDidChangeRepository(this.onRepositoryChanged, this), - ]; + getSplitArgs(): WebviewShowingArgs { + return this._context.uri != null ? [this._context.uri] : []; } - protected override onShowCommand(uri?: Uri): void { - if (uri != null) { - this.updatePendingUri(uri); + onShowing( + loading: boolean, + _options?: WebviewShowOptions, + ...args: WebviewShowingArgs + ): boolean { + const [arg] = args; + if (arg != null) { + if (arg instanceof Uri) { + this.updatePendingUri(arg); + } else if (isViewFileNode(arg)) { + this.updatePendingUri(arg.uri); + } else if (isSerializedState(arg)) { + this.updatePendingContext({ + period: arg.state.period, + uri: arg.state.uri != null ? Uri.parse(arg.state.uri) : undefined, + }); + } } else { this.updatePendingEditor(window.activeTextEditor); } - this._context = { ...this._context, ...this._pendingContext }; - this._pendingContext = undefined; - super.onShowCommand(); + if (loading) { + this._context = { ...this._context, ...this._pendingContext }; + this._pendingContext = undefined; + } else { + this.updateState(); + } + + return true; } - protected override async includeBootstrap(): Promise { + includeBootstrap(): Promise { this._bootstraping = true; this._context = { ...this._context, ...this._pendingContext }; @@ -106,13 +150,34 @@ export class TimelineWebview extends WebviewBase { return this.getState(this._context); } - protected override registerCommands(): Disposable[] { - return [registerCommand(Commands.RefreshTimelinePage, () => this.refresh(true))]; + registerCommands(): Disposable[] { + const commands: Disposable[] = []; + + if (this.host.isHost('view')) { + commands.push( + registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true), this), + registerCommand( + `${this.host.id}.openInTab`, + () => { + if (this._context.uri == null) return; + + void executeCommand(Commands.ShowInTimeline, this._context.uri); + }, + this, + ), + ); + } + + return commands; } - protected override onVisibilityChanged(visible: boolean) { + onVisibilityChanged(visible: boolean) { if (!visible) return; + if (this.host.isHost('view')) { + this.updatePendingEditor(window.activeTextEditor); + } + // Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data if (this._bootstraping) { this._bootstraping = false; @@ -127,31 +192,81 @@ export class TimelineWebview extends WebviewBase { this.updateState(); } - protected override onMessageReceived(e: IpcMessage) { - switch (e.method) { - case OpenDataPointCommandType.method: - onIpc(OpenDataPointCommandType, e, async params => { - if (params.data == null || !params.data.selected || this._context.uri == null) return; + async onMessageReceived(e: IpcMessage) { + switch (true) { + case OpenDataPointCommand.is(e): { + if (e.params.data == null || !e.params.data.selected || this._context.uri == null) return; + + const repository = this.container.git.getRepository(this._context.uri); + if (repository == null) return; + + const commit = await repository.getCommit(e.params.data.id); + if (commit == null) return; + + this.container.events.fire( + 'commit:selected', + { + commit: commit, + interaction: 'active', + preserveFocus: true, + preserveVisibility: false, + }, + { source: this.host.id }, + ); - const repository = this.container.git.getRepository(this._context.uri); - if (repository == null) return; + if (!this.container.commitDetailsView.ready) { + void this.container.commitDetailsView.show({ preserveFocus: true }, { + commit: commit, + interaction: 'active', + preserveVisibility: false, + } satisfies CommitSelectedEvent['data']); + } - const commit = await repository.getCommit(params.data.id); - if (commit == null) return; + break; + } + case UpdatePeriodCommand.is(e): + if (this.updatePendingContext({ period: e.params.period })) { + this.updateState(true); + } + break; + } + } - void showDetailsView(commit, { pin: false, preserveFocus: true }); - }); + @debug() + private onActiveEditorChanged(editor: TextEditor | undefined) { + if (editor != null) { + if (!isTrackableTextEditor(editor)) return; - break; + if (!this.container.git.isTrackable(editor.document.uri)) { + editor = undefined; + } + } - case UpdatePeriodCommandType.method: - onIpc(UpdatePeriodCommandType, e, params => { - if (this.updatePendingContext({ period: params.period })) { - this.updateState(true); - } - }); + if (!this.updatePendingEditor(editor)) return; - break; + this.updateState(); + } + + @debug({ args: false }) + private onFileSelected(e: FileSelectedEvent) { + if (e.data == null) return; + + let uri: Uri | undefined = e.data.uri; + if (uri != null && !this.container.git.isTrackable(uri)) { + uri = undefined; + } + + if (!this.updatePendingUri(uri)) return; + + this.updateState(); + } + + @debug({ args: false }) + private onRepositoriesChanged(e: RepositoriesChangeEvent) { + const changed = this.updatePendingUri(this._context.uri); + + if (this.updatePendingContext({ etagRepositories: e.etag }) || changed) { + this.updateState(); } } @@ -179,53 +294,47 @@ export class TimelineWebview extends WebviewBase { const shortDateFormat = configuration.get('defaultDateShortFormat') ?? 'short'; const period = current.period ?? defaultPeriod; - if (current.uri == null) { - const access = await this.container.git.access(PlusFeatures.Timeline); - return { - emptyMessage: 'There are no editors open that can provide file history information', - period: period, - title: '', - dateFormat: dateFormat, - shortDateFormat: shortDateFormat, - access: access, - }; - } + const gitUri = current.uri != null ? await GitUri.fromUri(current.uri) : undefined; + const repoPath = gitUri?.repoPath; - const gitUri = await GitUri.fromUri(current.uri); - const repoPath = gitUri.repoPath!; + if (this.host.isHost('editor')) { + this.host.title = + gitUri == null ? this.host.originalTitle : `${this.host.originalTitle}: ${gitUri.fileName}`; + } else { + this.host.description = gitUri?.fileName ?? proBadge; + } const access = await this.container.git.access(PlusFeatures.Timeline, repoPath); - if (access.allowed === false) { - const dataset = generateRandomTimelineDataset(); + + if (current.uri == null || gitUri == null || repoPath == null || access.allowed === false) { + const access = await this.container.git.access(PlusFeatures.Timeline, repoPath); return { - dataset: dataset.sort((a, b) => b.sort - a.sort), + ...this.host.baseWebviewState, period: period, - title: 'src/app/index.ts', - uri: Uri.file('src/app/index.ts').toString(), + title: gitUri?.relativePath, + sha: gitUri?.shortSha, + uri: current.uri?.toString(), dateFormat: dateFormat, shortDateFormat: shortDateFormat, access: access, }; } - const title = gitUri.relativePath; - this.title = `${this.originalTitle}: ${gitUri.fileName}`; - const [currentUser, log] = await Promise.all([ this.container.git.getCurrentUser(repoPath), this.container.git.getLogForFile(repoPath, gitUri.fsPath, { limit: 0, ref: gitUri.sha, - since: this.getPeriodDate(period).toISOString(), + since: getPeriodDate(period)?.toISOString(), }), ]); if (log == null) { return { + ...this.host.baseWebviewState, dataset: [], - emptyMessage: 'No commits found for the specified time period', period: period, - title: title, + title: gitUri.relativePath, sha: gitUri.shortSha, uri: current.uri.toString(), dateFormat: dateFormat, @@ -280,9 +389,10 @@ export class TimelineWebview extends WebviewBase { dataset.sort((a, b) => b.sort - a.sort); return { + ...this.host.baseWebviewState, dataset: dataset, period: period, - title: title, + title: gitUri.relativePath, sha: gitUri.shortSha, uri: current.uri.toString(), dateFormat: dateFormat, @@ -291,51 +401,27 @@ export class TimelineWebview extends WebviewBase { }; } - private getPeriodDate(period: Period): Date { - const [number, unit] = period.split('|'); - - switch (unit) { - case 'D': - return createFromDateDelta(new Date(), { days: -parseInt(number, 10) }); - case 'M': - return createFromDateDelta(new Date(), { months: -parseInt(number, 10) }); - case 'Y': - return createFromDateDelta(new Date(), { years: -parseInt(number, 10) }); - default: - return createFromDateDelta(new Date(), { months: -3 }); - } - } - - private updatePendingContext(context: Partial): boolean { - let changed = false; - for (const [key, value] of Object.entries(context)) { - const current = (this._context as unknown as Record)[key]; - if ( - current === value || - ((current instanceof Uri || value instanceof Uri) && (current as any)?.toString() === value?.toString()) - ) { - continue; - } - - if (this._pendingContext == null) { - this._pendingContext = {}; - } - - (this._pendingContext as Record)[key] = value; - changed = true; + private updatePendingContext(context: Partial, force?: boolean): boolean { + const [changed, pending] = updatePendingContext(this._context, this._pendingContext, context, force); + if (changed) { + this._pendingContext = pending; } return changed; } - private updatePendingEditor(editor: TextEditor | undefined): boolean { - if (editor == null && hasVisibleTextEditor()) return false; - if (editor != null && !isTextEditor(editor)) return false; + private updatePendingEditor(editor: TextEditor | undefined, force?: boolean): boolean { + if ( + (editor == null && hasVisibleTrackableTextEditor(this._context.uri ?? this._pendingContext?.uri)) || + (editor != null && !isTrackableTextEditor(editor)) + ) { + return false; + } - return this.updatePendingUri(editor?.document.uri); + return this.updatePendingUri(editor?.document.uri, force); } - private updatePendingUri(uri: Uri | undefined): boolean { + private updatePendingUri(uri: Uri | undefined, force?: boolean): boolean { let etag; if (uri != null) { const repository = this.container.git.getRepository(uri); @@ -344,15 +430,13 @@ export class TimelineWebview extends WebviewBase { etag = 0; } - return this.updatePendingContext({ uri: uri, etagRepository: etag }); + return this.updatePendingContext({ uri: uri, etagRepository: etag }, force); } private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; @debug() private updateState(immediate: boolean = false) { - if (!this.isReady || !this.visible) return; - if (immediate) { void this.notifyDidChangeState(); return; @@ -366,22 +450,50 @@ export class TimelineWebview extends WebviewBase { } @debug() - private async notifyDidChangeState() { - if (!this.isReady || !this.visible) return false; - + private async notifyDidChangeState(force: boolean = false) { this._notifyDidChangeStateDebounced?.cancel(); - if (this._pendingContext == null) return false; + if (!force && this._pendingContext == null) return false; - const context = { ...this._context, ...this._pendingContext }; + let context: Context; + if (this._pendingContext != null) { + context = { ...this._context, ...this._pendingContext }; + this._context = context; + this._pendingContext = undefined; + } else { + context = this._context; + } - return window.withProgress({ location: { viewId: this.id } }, async () => { - const success = await this.notify(DidChangeNotificationType, { - state: await this.getState(context), - }); - if (success) { - this._context = context; - this._pendingContext = undefined; - } + return this.host.notify(DidChangeNotification, { + state: await this.getState(context), }); } } + +function getPeriodDate(period: Period): Date | undefined { + if (period == 'all') return undefined; + + const [number, unit] = period.split('|'); + + let date; + switch (unit) { + case 'D': + date = createFromDateDelta(new Date(), { days: -parseInt(number, 10) }); + break; + case 'M': + date = createFromDateDelta(new Date(), { months: -parseInt(number, 10) }); + break; + case 'Y': + date = createFromDateDelta(new Date(), { years: -parseInt(number, 10) }); + break; + default: + date = createFromDateDelta(new Date(), { months: -3 }); + break; + } + + // If we are more than 1/2 way through the day, then set the date to the next day + if (date.getHours() >= 12) { + date.setDate(date.getDate() + 1); + } + date.setHours(0, 0, 0, 0); + return date; +} diff --git a/src/plus/webviews/timeline/timelineWebviewView.ts b/src/plus/webviews/timeline/timelineWebviewView.ts deleted file mode 100644 index 5e5ee51d2d191..0000000000000 --- a/src/plus/webviews/timeline/timelineWebviewView.ts +++ /dev/null @@ -1,467 +0,0 @@ -'use strict'; -import type { Disposable, TextEditor } from 'vscode'; -import { commands, Uri, window } from 'vscode'; -import { configuration } from '../../../configuration'; -import { Commands, ContextKeys } from '../../../constants'; -import type { Container } from '../../../container'; -import type { FileSelectedEvent } from '../../../eventBus'; -import { PlusFeatures } from '../../../features'; -import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; -import { GitUri } from '../../../git/gitUri'; -import { getChangedFilesCount } from '../../../git/models/commit'; -import type { RepositoryChangeEvent } from '../../../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; -import { registerCommand } from '../../../system/command'; -import { createFromDateDelta } from '../../../system/date'; -import { debug } from '../../../system/decorators/log'; -import type { Deferrable } from '../../../system/function'; -import { debounce } from '../../../system/function'; -import { filter } from '../../../system/iterable'; -import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; -import type { IpcMessage } from '../../../webviews/protocol'; -import { onIpc } from '../../../webviews/protocol'; -import { WebviewViewBase } from '../../../webviews/webviewViewBase'; -import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; -import { ensurePlusFeaturesEnabled } from '../../subscription/utils'; -import type { Commit, Period, State } from './protocol'; -import { DidChangeNotificationType, OpenDataPointCommandType, UpdatePeriodCommandType } from './protocol'; - -interface Context { - uri: Uri | undefined; - period: Period | undefined; - etagRepositories: number | undefined; - etagRepository: number | undefined; - etagSubscription: number | undefined; -} - -const defaultPeriod: Period = '3|M'; - -export class TimelineWebviewView extends WebviewViewBase { - private _bootstraping = true; - /** The context the webview has */ - private _context: Context; - /** The context the webview should have */ - private _pendingContext: Partial | undefined; - - constructor(container: Container) { - super( - container, - 'gitlens.views.timeline', - 'timeline.html', - 'Visual File History', - `${ContextKeys.WebviewViewPrefix}timeline`, - 'timelineView', - ); - - this._context = { - uri: undefined, - period: defaultPeriod, - etagRepositories: 0, - etagRepository: 0, - etagSubscription: 0, - }; - } - - override async show(options?: { preserveFocus?: boolean | undefined }): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - - return super.show(options); - } - - protected override onInitializing(): Disposable[] | undefined { - this._context = { - uri: undefined, - period: defaultPeriod, - etagRepositories: this.container.git.etag, - etagRepository: 0, - etagSubscription: this.container.subscription.etag, - }; - - this.updatePendingEditor(window.activeTextEditor); - this._context = { ...this._context, ...this._pendingContext }; - this._pendingContext = undefined; - - return [ - this.container.subscription.onDidChange(this.onSubscriptionChanged, this), - window.onDidChangeActiveTextEditor(debounce(this.onActiveEditorChanged, 250), this), - this.container.events.on('file:selected', debounce(this.onFileSelected, 250), this), - this.container.git.onDidChangeRepository(this.onRepositoryChanged, this), - this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), - ]; - } - - protected override async includeBootstrap(): Promise { - this._bootstraping = true; - - this._context = { ...this._context, ...this._pendingContext }; - this._pendingContext = undefined; - - return this.getState(this._context); - } - - protected override registerCommands(): Disposable[] { - return [ - registerCommand(`${this.id}.refresh`, () => this.refresh(), this), - registerCommand(`${this.id}.openInTab`, () => this.openInTab(), this), - ]; - } - - protected override onVisibilityChanged(visible: boolean) { - if (!visible) return; - - // Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data - if (this._bootstraping) { - this._bootstraping = false; - - // If the uri changed since bootstrap still send the update - if (this._pendingContext == null || !('uri' in this._pendingContext)) { - return; - } - } - - // Should be immediate, but it causes the bubbles to go missing on the chart, since the update happens while it still rendering - this.updateState(); - } - - protected override onMessageReceived(e: IpcMessage) { - switch (e.method) { - case OpenDataPointCommandType.method: - onIpc(OpenDataPointCommandType, e, async params => { - if (params.data == null || !params.data.selected || this._context.uri == null) return; - - const repository = this.container.git.getRepository(this._context.uri); - if (repository == null) return; - - const commit = await repository.getCommit(params.data.id); - if (commit == null) return; - - this.container.events.fire( - 'commit:selected', - { - commit: commit, - pin: false, - preserveFocus: false, - preserveVisibility: false, - }, - { source: this.id }, - ); - }); - - break; - - case UpdatePeriodCommandType.method: - onIpc(UpdatePeriodCommandType, e, params => { - if (this.updatePendingContext({ period: params.period })) { - this.updateState(true); - } - }); - - break; - } - } - - @debug({ args: false }) - private onActiveEditorChanged(editor: TextEditor | undefined) { - if (editor != null) { - if (!isTextEditor(editor)) return; - - if (!this.container.git.isTrackable(editor.document.uri)) { - editor = undefined; - } - } - - if (!this.updatePendingEditor(editor)) return; - - this.updateState(); - } - - @debug({ args: false }) - private onFileSelected(e: FileSelectedEvent) { - if (e.data == null) return; - - let uri: Uri | undefined = e.data.uri; - if (uri != null) { - if (!this.container.git.isTrackable(uri)) { - uri = undefined; - } - } - - if (!this.updatePendingUri(uri)) return; - - this.updateState(); - } - - @debug({ args: false }) - private onRepositoriesChanged(e: RepositoriesChangeEvent) { - const changed = this.updatePendingUri(this._context.uri); - - if (this.updatePendingContext({ etagRepositories: e.etag }) || changed) { - this.updateState(); - } - } - - @debug({ args: false }) - private onRepositoryChanged(e: RepositoryChangeEvent) { - if (!e.changed(RepositoryChange.Heads, RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) { - return; - } - - if (this.updatePendingContext({ etagRepository: e.repository.etag })) { - this.updateState(); - } - } - - @debug({ args: false }) - private onSubscriptionChanged(e: SubscriptionChangeEvent) { - if (this.updatePendingContext({ etagSubscription: e.etag })) { - this.updateState(); - } - } - - @debug({ args: false }) - private async getState(current: Context): Promise { - const dateFormat = configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma'; - const shortDateFormat = configuration.get('defaultDateShortFormat') ?? 'short'; - const period = current.period ?? defaultPeriod; - - if (current.uri == null) { - const access = await this.container.git.access(PlusFeatures.Timeline); - return { - emptyMessage: 'There are no editors open that can provide file history information', - period: period, - title: '', - dateFormat: dateFormat, - shortDateFormat: shortDateFormat, - access: access, - }; - } - - const gitUri = await GitUri.fromUri(current.uri); - const repoPath = gitUri.repoPath!; - - const access = await this.container.git.access(PlusFeatures.Timeline, repoPath); - if (access.allowed === false) { - const dataset = generateRandomTimelineDataset(); - return { - dataset: dataset.sort((a, b) => b.sort - a.sort), - period: period, - title: 'src/app/index.ts', - uri: Uri.file('src/app/index.ts').toString(), - dateFormat: dateFormat, - shortDateFormat: shortDateFormat, - access: access, - }; - } - - const title = gitUri.relativePath; - this.description = gitUri.fileName; - - const [currentUser, log] = await Promise.all([ - this.container.git.getCurrentUser(repoPath), - this.container.git.getLogForFile(repoPath, gitUri.fsPath, { - limit: 0, - ref: gitUri.sha, - since: this.getPeriodDate(period).toISOString(), - }), - ]); - - if (log == null) { - return { - dataset: [], - emptyMessage: 'No commits found for the specified time period', - period: period, - title: title, - sha: gitUri.shortSha, - uri: current.uri.toString(), - dateFormat: dateFormat, - shortDateFormat: shortDateFormat, - access: access, - }; - } - - let queryRequiredCommits = [ - ...filter( - log.commits.values(), - c => c.file?.stats == null && getChangedFilesCount(c.stats?.changedFiles) !== 1, - ), - ]; - - if (queryRequiredCommits.length !== 0) { - const limit = configuration.get('visualHistory.queryLimit') ?? 20; - - const repository = this.container.git.getRepository(current.uri); - const name = repository?.provider.name; - - if (queryRequiredCommits.length > limit) { - void window.showWarningMessage( - `Unable able to show more than the first ${limit} commits for the specified time period because of ${ - name ? `${name} ` : '' - }rate limits.`, - ); - queryRequiredCommits = queryRequiredCommits.slice(0, 20); - } - - void (await Promise.allSettled(queryRequiredCommits.map(c => c.ensureFullDetails()))); - } - - const name = currentUser?.name ? `${currentUser.name} (you)` : 'You'; - - const dataset: Commit[] = []; - for (const commit of log.commits.values()) { - const stats = - commit.file?.stats ?? - (getChangedFilesCount(commit.stats?.changedFiles) === 1 ? commit.stats : undefined); - dataset.push({ - author: commit.author.name === 'You' ? name : commit.author.name, - additions: stats?.additions, - deletions: stats?.deletions, - commit: commit.sha, - date: commit.date.toISOString(), - message: commit.message ?? commit.summary, - sort: commit.date.getTime(), - }); - } - - dataset.sort((a, b) => b.sort - a.sort); - - return { - dataset: dataset, - period: period, - title: title, - sha: gitUri.shortSha, - uri: current.uri.toString(), - dateFormat: dateFormat, - shortDateFormat: shortDateFormat, - access: access, - }; - } - - private getPeriodDate(period: Period): Date { - const [number, unit] = period.split('|'); - - switch (unit) { - case 'D': - return createFromDateDelta(new Date(), { days: -parseInt(number, 10) }); - case 'M': - return createFromDateDelta(new Date(), { months: -parseInt(number, 10) }); - case 'Y': - return createFromDateDelta(new Date(), { years: -parseInt(number, 10) }); - default: - return createFromDateDelta(new Date(), { months: -3 }); - } - } - - private openInTab() { - const uri = this._context.uri; - if (uri == null) return; - - void commands.executeCommand(Commands.ShowTimelinePage, uri); - } - - private updatePendingContext(context: Partial): boolean { - let changed = false; - for (const [key, value] of Object.entries(context)) { - const current = (this._context as unknown as Record)[key]; - if ( - current === value || - ((current instanceof Uri || value instanceof Uri) && (current as any)?.toString() === value?.toString()) - ) { - continue; - } - - if (this._pendingContext == null) { - this._pendingContext = {}; - } - - (this._pendingContext as Record)[key] = value; - changed = true; - } - - return changed; - } - - private updatePendingEditor(editor: TextEditor | undefined): boolean { - if (editor == null && hasVisibleTextEditor()) return false; - if (editor != null && !isTextEditor(editor)) return false; - - return this.updatePendingUri(editor?.document.uri); - } - - private updatePendingUri(uri: Uri | undefined): boolean { - let etag; - if (uri != null) { - const repository = this.container.git.getRepository(uri); - etag = repository?.etag ?? 0; - } else { - etag = 0; - } - - return this.updatePendingContext({ uri: uri, etagRepository: etag }); - } - - private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; - - @debug() - private updateState(immediate: boolean = false) { - if (!this.isReady || !this.visible) return; - - if (this._pendingContext == null) { - this.updatePendingEditor(window.activeTextEditor); - } - - if (immediate) { - void this.notifyDidChangeState(); - return; - } - - if (this._notifyDidChangeStateDebounced == null) { - this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); - } - - this._notifyDidChangeStateDebounced(); - } - - @debug() - private async notifyDidChangeState() { - if (!this.isReady || !this.visible) return false; - - this._notifyDidChangeStateDebounced?.cancel(); - if (this._pendingContext == null) return false; - - const context = { ...this._context, ...this._pendingContext }; - - return window.withProgress({ location: { viewId: this.id } }, async () => { - const success = await this.notify(DidChangeNotificationType, { - state: await this.getState(context), - }); - if (success) { - this._context = context; - this._pendingContext = undefined; - } - }); - } -} - -export function generateRandomTimelineDataset(): Commit[] { - const dataset: Commit[] = []; - const authors = ['Eric Amodio', 'Justin Roberts', 'Ada Lovelace', 'Grace Hopper']; - - const count = 10; - for (let i = 0; i < count; i++) { - // Generate a random date between now and 3 months ago - const date = new Date(new Date().getTime() - Math.floor(Math.random() * (3 * 30 * 24 * 60 * 60 * 1000))); - - dataset.push({ - commit: String(i), - author: authors[Math.floor(Math.random() * authors.length)], - date: date.toISOString(), - message: '', - // Generate random additions/deletions between 1 and 20, but ensure we have a tiny and large commit - additions: i === 0 ? 2 : i === count - 1 ? 50 : Math.floor(Math.random() * 20) + 1, - deletions: i === 0 ? 1 : i === count - 1 ? 25 : Math.floor(Math.random() * 20) + 1, - sort: date.getTime(), - }); - } - - return dataset; -} diff --git a/src/plus/workspaces/models.ts b/src/plus/workspaces/models.ts new file mode 100644 index 0000000000000..564ec8dcc46ac --- /dev/null +++ b/src/plus/workspaces/models.ts @@ -0,0 +1,623 @@ +import type { Disposable } from '../../api/gitlens'; +import type { Container } from '../../container'; +import type { Repository } from '../../git/models/repository'; + +export type WorkspaceType = 'cloud' | 'local'; +export type WorkspaceAutoAddSetting = 'disabled' | 'enabled' | 'prompt'; + +export enum WorkspaceRepositoryRelation { + Direct = 'DIRECT', + ProviderProject = 'PROVIDER_PROJECT', +} + +export type CodeWorkspaceFileContents = { + folders: { path: string }[]; + settings: Record; +}; + +export type WorkspaceRepositoriesByName = Map; + +export interface RepositoryMatch { + repository: Repository; + descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor; +} + +export interface RemoteDescriptor { + provider: string; + owner: string; + repoName: string; + url?: string; +} + +export interface GetWorkspacesResponse { + cloudWorkspaces: CloudWorkspace[]; + localWorkspaces: LocalWorkspace[]; + cloudWorkspaceInfo: string | undefined; + localWorkspaceInfo: string | undefined; +} + +export interface LoadCloudWorkspacesResponse { + cloudWorkspaces: CloudWorkspace[] | undefined; + cloudWorkspaceInfo: string | undefined; +} + +export interface LoadLocalWorkspacesResponse { + localWorkspaces: LocalWorkspace[] | undefined; + localWorkspaceInfo: string | undefined; +} + +export interface GetCloudWorkspaceRepositoriesResponse { + repositories: CloudWorkspaceRepositoryDescriptor[] | undefined; + repositoriesInfo: string | undefined; +} + +// Cloud Workspace types +export class CloudWorkspace { + readonly type = 'cloud' satisfies WorkspaceType; + + private _repositoryDescriptors: CloudWorkspaceRepositoryDescriptor[] | undefined; + private _repositoriesByName: WorkspaceRepositoriesByName | undefined; + private _localPath: string | undefined; + private _disposable: Disposable; + + constructor( + private readonly container: Container, + public readonly id: string, + public readonly name: string, + public readonly organizationId: string | undefined, + public readonly provider: CloudWorkspaceProviderType, + public readonly repoRelation: WorkspaceRepositoryRelation, + public readonly current: boolean, + public readonly azureInfo?: { + organizationId?: string; + project?: string; + }, + repositories?: CloudWorkspaceRepositoryDescriptor[], + localPath?: string, + ) { + this._repositoryDescriptors = repositories; + this._localPath = localPath; + this._disposable = this.container.git.onDidChangeRepositories(this.resetRepositoriesByName, this); + } + + dispose() { + this._disposable.dispose(); + } + + get shared(): boolean { + return this.organizationId != null; + } + + get localPath(): string | undefined { + return this._localPath; + } + + resetRepositoriesByName() { + this._repositoriesByName = undefined; + } + + async getRepositoriesByName(options?: { force?: boolean }): Promise { + if (this._repositoriesByName == null || options?.force) { + this._repositoriesByName = await this.container.workspaces.resolveWorkspaceRepositoriesByName(this.id, { + resolveFromPath: true, + usePathMapping: true, + }); + } + + return this._repositoriesByName; + } + + async getRepositoryDescriptors(options?: { force?: boolean }): Promise { + if (this._repositoryDescriptors == null || options?.force) { + this._repositoryDescriptors = await this.container.workspaces.getCloudWorkspaceRepositories(this.id); + this.resetRepositoriesByName(); + } + + return this._repositoryDescriptors; + } + + async getRepositoryDescriptor(name: string): Promise { + return (await this.getRepositoryDescriptors()).find(r => r.name === name); + } + + // TODO@axosoft-ramint this should be the entry point, not a backdoor to update the cache + addRepositories(repositories: CloudWorkspaceRepositoryDescriptor[]): void { + if (this._repositoryDescriptors == null) { + this._repositoryDescriptors = repositories; + } else { + this._repositoryDescriptors = this._repositoryDescriptors.concat(repositories); + } + + this.resetRepositoriesByName(); + } + + // TODO@axosoft-ramint this should be the entry point, not a backdoor to update the cache + removeRepositories(repoNames: string[]): void { + if (this._repositoryDescriptors == null) return; + + this._repositoryDescriptors = this._repositoryDescriptors.filter(r => !repoNames.includes(r.name)); + this.resetRepositoriesByName(); + } + + setLocalPath(localPath: string | undefined): void { + this._localPath = localPath; + } +} + +export interface CloudWorkspaceRepositoryDescriptor { + id: string; + name: string; + description: string; + repository_id: string; + provider: CloudWorkspaceProviderType | null; + provider_project_name: string | null; + provider_organization_id: string; + provider_organization_name: string | null; + url: string | null; + workspaceId: string; +} + +export enum CloudWorkspaceProviderInputType { + GitHub = 'GITHUB', + GitHubEnterprise = 'GITHUB_ENTERPRISE', + GitLab = 'GITLAB', + GitLabSelfHosted = 'GITLAB_SELF_HOSTED', + Bitbucket = 'BITBUCKET', + Azure = 'AZURE', +} + +export enum CloudWorkspaceProviderType { + GitHub = 'github', + GitHubEnterprise = 'github_enterprise', + GitLab = 'gitlab', + GitLabSelfHosted = 'gitlab_self_hosted', + Bitbucket = 'bitbucket', + Azure = 'azure', +} + +export const cloudWorkspaceProviderTypeToRemoteProviderId = { + [CloudWorkspaceProviderType.Azure]: 'azure-devops', + [CloudWorkspaceProviderType.Bitbucket]: 'bitbucket', + [CloudWorkspaceProviderType.GitHub]: 'github', + [CloudWorkspaceProviderType.GitHubEnterprise]: 'github', + [CloudWorkspaceProviderType.GitLab]: 'gitlab', + [CloudWorkspaceProviderType.GitLabSelfHosted]: 'gitlab', +}; + +export const cloudWorkspaceProviderInputTypeToRemoteProviderId = { + [CloudWorkspaceProviderInputType.Azure]: 'azure-devops', + [CloudWorkspaceProviderInputType.Bitbucket]: 'bitbucket', + [CloudWorkspaceProviderInputType.GitHub]: 'github', + [CloudWorkspaceProviderInputType.GitHubEnterprise]: 'github', + [CloudWorkspaceProviderInputType.GitLab]: 'gitlab', + [CloudWorkspaceProviderInputType.GitLabSelfHosted]: 'gitlab', +}; + +export enum WorkspaceAddRepositoriesChoice { + CurrentWindow = 'Current Window', + ParentFolder = 'Parent Folder', +} + +export const defaultWorkspaceCount = 100; +export const defaultWorkspaceRepoCount = 100; + +export interface CloudWorkspaceData { + id: string; + name: string; + description: string; + type: CloudWorkspaceType; + icon_url: string | null; + host_url: string; + status: string; + provider: string; + repo_relation: string; + azure_organization_id: string | null; + azure_project: string | null; + created_date: Date; + updated_date: Date; + created_by: string; + updated_by: string; + members: CloudWorkspaceMember[]; + organization: CloudWorkspaceOrganization; + issue_tracker: CloudWorkspaceIssueTracker; + settings: CloudWorkspaceSettings; + current_user: UserCloudWorkspaceSettings; + errors: string[]; + provider_data: ProviderCloudWorkspaceData; +} + +export type CloudWorkspaceType = 'GK_PROJECT' | 'GK_ORG_VELOCITY' | 'GK_CLI'; + +export interface CloudWorkspaceMember { + id: string; + role: string; + name: string; + username: string; + avatar_url: string; +} + +interface CloudWorkspaceOrganization { + id: string; + team_ids: string[]; +} + +interface CloudWorkspaceIssueTracker { + provider: string; + settings: CloudWorkspaceIssueTrackerSettings; +} + +interface CloudWorkspaceIssueTrackerSettings { + resource_id: string; +} + +interface CloudWorkspaceSettings { + gkOrgVelocity: GKOrgVelocitySettings; + goals: ProjectGoalsSettings; +} + +type GKOrgVelocitySettings = Record; +type ProjectGoalsSettings = Record; + +interface UserCloudWorkspaceSettings { + project_id: string; + user_id: string; + tab_settings: UserCloudWorkspaceTabSettings; +} + +interface UserCloudWorkspaceTabSettings { + issue_tracker: CloudWorkspaceIssueTracker; +} + +export interface ProviderCloudWorkspaceData { + id: string; + provider_organization_id: string; + repository: CloudWorkspaceRepositoryData; + repositories: CloudWorkspaceConnection; + pull_requests: CloudWorkspacePullRequestData[]; + issues: CloudWorkspaceIssue[]; + repository_members: CloudWorkspaceRepositoryMemberData[]; + milestones: CloudWorkspaceMilestone[]; + labels: CloudWorkspaceLabel[]; + issue_types: CloudWorkspaceIssueType[]; + provider_identity: ProviderCloudWorkspaceIdentity; + metrics: ProviderCloudWorkspaceMetrics; +} + +type ProviderCloudWorkspaceMetrics = Record; + +interface ProviderCloudWorkspaceIdentity { + avatar_url: string; + id: string; + name: string; + username: string; + pat_organization: string; + is_using_pat: boolean; + scopes: string; +} + +export interface Branch { + id: string; + node_id: string; + name: string; + commit: BranchCommit; +} + +interface BranchCommit { + id: string; + url: string; + build_status: { + context: string; + state: string; + description: string; + }; +} + +export interface CloudWorkspaceRepositoryData { + id: string; + name: string; + description: string; + repository_id: string; + provider: CloudWorkspaceProviderType | null; + provider_project_name: string | null; + provider_organization_id: string; + provider_organization_name: string | null; + url: string | null; + default_branch: string; + branches: Branch[]; + pull_requests: CloudWorkspacePullRequestData[]; + issues: CloudWorkspaceIssue[]; + members: CloudWorkspaceRepositoryMemberData[]; + milestones: CloudWorkspaceMilestone[]; + labels: CloudWorkspaceLabel[]; + issue_types: CloudWorkspaceIssueType[]; + possibly_deleted: boolean; + has_webhook: boolean; +} + +interface CloudWorkspaceRepositoryMemberData { + avatar_url: string; + name: string; + node_id: string; + username: string; +} + +type CloudWorkspaceMilestone = Record; +type CloudWorkspaceLabel = Record; +type CloudWorkspaceIssueType = Record; + +export interface CloudWorkspacePullRequestData { + id: string; + node_id: string; + number: string; + title: string; + description: string; + url: string; + milestone_id: string; + labels: CloudWorkspaceLabel[]; + author_id: string; + author_username: string; + created_date: Date; + updated_date: Date; + closed_date: Date; + merged_date: Date; + first_commit_date: Date; + first_response_date: Date; + comment_count: number; + repository: CloudWorkspaceRepositoryData; + head_commit: { + id: string; + url: string; + build_status: { + context: string; + state: string; + description: string; + }; + }; + lifecycle_stages: { + stage: string; + start_date: Date; + end_date: Date; + }[]; + reviews: CloudWorkspacePullRequestReviews[]; + head: { + name: string; + }; +} + +interface CloudWorkspacePullRequestReviews { + user_id: string; + avatar_url: string; + state: string; +} + +export interface CloudWorkspaceIssue { + id: string; + node_id: string; + title: string; + author_id: string; + assignee_ids: string[]; + milestone_id: string; + label_ids: string[]; + issue_type: string; + url: string; + created_date: Date; + updated_date: Date; + comment_count: number; + repository: CloudWorkspaceRepositoryData; +} + +export interface CloudWorkspaceConnection { + total_count: number; + page_info: { + start_cursor: string; + end_cursor: string; + has_next_page: boolean; + }; + nodes: i[]; +} + +interface CloudWorkspaceFetchedConnection extends CloudWorkspaceConnection { + is_fetching: boolean; +} + +export interface WorkspaceResponse { + data: { + project: CloudWorkspaceData; + }; +} + +export interface WorkspacesResponse { + data: { + projects: CloudWorkspaceConnection; + }; +} + +export interface WorkspaceRepositoriesResponse { + data: { + project: { + provider_data: { + repositories: CloudWorkspaceConnection; + }; + }; + }; +} + +export interface WorkspacePullRequestsResponse { + data: { + project: { + provider_data: { + pull_requests: CloudWorkspaceFetchedConnection; + }; + }; + }; +} + +export interface WorkspacesWithPullRequestsResponse { + data: { + projects: { + nodes: { + provider_data: { + pull_requests: CloudWorkspaceFetchedConnection; + }; + }[]; + }; + }; + errors?: { + message: string; + path: unknown[]; + statusCode: number; + }[]; +} + +export interface WorkspaceIssuesResponse { + data: { + project: { + provider_data: { + issues: CloudWorkspaceFetchedConnection; + }; + }; + }; +} + +export interface CreateWorkspaceResponse { + data: { + create_project: CloudWorkspaceData | null; + }; +} + +export interface DeleteWorkspaceResponse { + data: { + delete_project: CloudWorkspaceData | null; + }; + errors?: { code: number; message: string }[]; +} + +export type AddRepositoriesToWorkspaceResponse = { + data: { + add_repositories_to_project: { + id: string; + provider_data: Record; + } | null; + }; + errors?: { code: number; message: string }[]; +}; + +export interface RemoveRepositoriesFromWorkspaceResponse { + data: { + remove_repositories_from_project: { + id: string; + } | null; + }; + errors?: { code: number; message: string }[]; +} + +export interface AddWorkspaceRepoDescriptor { + owner: string; + repoName: string; +} + +// TODO@ramint Switch to using repo id once that is no longer bugged +export interface RemoveWorkspaceRepoDescriptor { + owner: string; + repoName: string; +} + +// Local Workspace Types +export class LocalWorkspace { + readonly type = 'local' satisfies WorkspaceType; + + private _localPath: string | undefined; + private _repositoriesByName: WorkspaceRepositoriesByName | undefined; + private _disposable: Disposable; + + constructor( + public readonly container: Container, + public readonly id: string, + public readonly name: string, + private readonly repositoryDescriptors: LocalWorkspaceRepositoryDescriptor[], + public readonly current: boolean, + localPath?: string, + ) { + this._localPath = localPath; + this._disposable = this.container.git.onDidChangeRepositories(this.resetRepositoriesByName, this); + } + + dispose() { + this._disposable.dispose(); + } + + get shared(): boolean { + return false; + } + + get localPath(): string | undefined { + return this._localPath; + } + + resetRepositoriesByName() { + this._repositoriesByName = undefined; + } + + async getRepositoriesByName(options?: { force?: boolean }): Promise { + if (this._repositoriesByName == null || options?.force) { + this._repositoriesByName = await this.container.workspaces.resolveWorkspaceRepositoriesByName(this.id, { + resolveFromPath: true, + usePathMapping: true, + }); + } + + return this._repositoriesByName; + } + + getRepositoryDescriptors(): Promise { + return Promise.resolve(this.repositoryDescriptors); + } + + getRepositoryDescriptor(name: string): Promise { + return Promise.resolve(this.repositoryDescriptors.find(r => r.name === name)); + } + + setLocalPath(localPath: string | undefined): void { + this._localPath = localPath; + } +} + +export interface LocalWorkspaceFileData { + workspaces: LocalWorkspaceData; +} + +export type LocalWorkspaceData = Record; + +export interface LocalWorkspaceDescriptor { + localId: string; + profileId: string; + name: string; + description: string; + repositories: LocalWorkspaceRepositoryPath[]; + version: number; +} + +export interface LocalWorkspaceRepositoryPath { + localPath: string; +} + +export interface LocalWorkspaceRepositoryDescriptor extends LocalWorkspaceRepositoryPath { + id?: undefined; + name: string; + workspaceId: string; +} + +export interface CloudWorkspaceFileData { + workspaces: CloudWorkspacesPathMap; +} + +export type CloudWorkspacesPathMap = Record; + +export interface CloudWorkspacePaths { + repoPaths: CloudWorkspaceRepoPathMap; + externalLinks: CloudWorkspaceExternalLinkMap; +} + +export type CloudWorkspaceRepoPathMap = Record; + +export type CloudWorkspaceExternalLinkMap = Record; diff --git a/src/plus/workspaces/workspacesApi.ts b/src/plus/workspaces/workspacesApi.ts new file mode 100644 index 0000000000000..adf31f85c958f --- /dev/null +++ b/src/plus/workspaces/workspacesApi.ts @@ -0,0 +1,476 @@ +import type { RequestInit } from '@env/fetch'; +import type { Container } from '../../container'; +import { log } from '../../system/decorators/log'; +import { Logger } from '../../system/logger'; +import type { GraphQLRequest, ServerConnection } from '../gk/serverConnection'; +import type { + AddRepositoriesToWorkspaceResponse, + AddWorkspaceRepoDescriptor, + CloudWorkspaceConnection, + CloudWorkspaceData, + CreateWorkspaceResponse, + DeleteWorkspaceResponse, + RemoveRepositoriesFromWorkspaceResponse, + RemoveWorkspaceRepoDescriptor, + WorkspaceRepositoriesResponse, + WorkspaceResponse, + WorkspacesResponse, +} from './models'; +import { CloudWorkspaceProviderInputType, defaultWorkspaceCount, defaultWorkspaceRepoCount } from './models'; + +export class WorkspacesApi { + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) {} + + @log() + async getWorkspace( + id: string, + options?: { + includeRepositories?: boolean; + repoCount?: number; + repoPage?: number; + }, + ): Promise { + let repoQuery: string | undefined; + if (options?.includeRepositories) { + let repoQueryParams = `(first: ${options?.repoCount ?? defaultWorkspaceRepoCount}`; + if (options?.repoPage) { + repoQueryParams += `, page: ${options.repoPage}`; + } + repoQueryParams += ')'; + repoQuery = ` + provider_data { + repositories ${repoQueryParams} { + total_count + page_info { + end_cursor + has_next_page + } + nodes { + id + name + repository_id + provider + provider_project_name + provider_organization_id + provider_organization_name + url + } + } + } + `; + } + + const queryData = ` + id + description + name + organization { + id + } + provider + azure_organization_id + azure_project + repo_relation + ${repoQuery ?? ''} + `; + + const query = ` + query getWorkspace { + project(id: "${id}") { ${queryData} } + } + `; + + const rsp = await this.fetch({ query: query }); + + if (!rsp.ok) { + Logger.error(undefined, `Getting workspace failed: (${rsp.status}) ${rsp.statusText}`); + throw new Error(rsp.statusText); + } + + const json: WorkspaceResponse | undefined = (await rsp.json()) as WorkspaceResponse | undefined; + + return json; + } + + @log() + async getWorkspaces(options?: { + count?: number; + cursor?: string; + includeOrganizations?: boolean; + includeRepositories?: boolean; + page?: number; + repoCount?: number; + repoPage?: number; + }): Promise { + let repoQuery: string | undefined; + if (options?.includeRepositories) { + let repoQueryParams = `(first: ${options?.repoCount ?? defaultWorkspaceRepoCount}`; + if (options?.repoPage) { + repoQueryParams += `, page: ${options.repoPage}`; + } + repoQueryParams += ')'; + repoQuery = ` + provider_data { + repositories ${repoQueryParams} { + total_count + page_info { + end_cursor + has_next_page + } + nodes { + id + name + repository_id + provider + provider_project_name + provider_organization_id + provider_organization_name + url + } + } + } + `; + } + + const queryData = ` + total_count + page_info { + end_cursor + has_next_page + } + nodes { + id + description + name + organization { + id + } + provider + azure_organization_id + azure_project + repo_relation + ${repoQuery ?? ''} + } + `; + + let queryParams = `(first: ${options?.count ?? defaultWorkspaceCount}`; + if (options?.cursor) { + queryParams += `, after: "${options.cursor}"`; + } else if (options?.page) { + queryParams += `, page: ${options.page}`; + } + queryParams += ')'; + + const query = ` + query getWorkpaces { + memberProjects: projects ${queryParams} { ${queryData} } + } + `; + + const rsp = await this.fetch({ query: query }); + + if (!rsp.ok) { + Logger.error(undefined, `Getting workspaces failed: (${rsp.status}) ${rsp.statusText}`); + throw new Error(rsp.statusText); + } + + const addedWorkspaceIds = new Set(); + const json: { data: Record | null> } | undefined = + await rsp.json(); + if (json?.data == null) return undefined; + let outputData: WorkspacesResponse | undefined; + for (const workspaceData of Object.values(json.data)) { + if (workspaceData == null) continue; + if (outputData == null) { + outputData = { data: { projects: workspaceData } }; + for (const node of workspaceData.nodes) { + addedWorkspaceIds.add(node.id); + } + } else { + for (const node of workspaceData.nodes) { + if (addedWorkspaceIds.has(node.id)) continue; + addedWorkspaceIds.add(node.id); + outputData.data.projects.nodes.push(node); + } + } + } + + if (outputData != null) { + outputData.data.projects.total_count = addedWorkspaceIds.size; + } + + return outputData; + } + + @log() + async getWorkspaceRepositories( + workspaceId: string, + options?: { + count?: number; + cursor?: string; + page?: number; + }, + ): Promise { + let queryparams = `(first: ${options?.count ?? defaultWorkspaceRepoCount}`; + if (options?.cursor) { + queryparams += `, after: "${options.cursor}"`; + } else if (options?.page) { + queryparams += `, page: ${options.page}`; + } + queryparams += ')'; + + const rsp = await this.fetch({ + query: ` + query getWorkspaceRepos { + project (id: "${workspaceId}") { + provider_data { + repositories ${queryparams} { + total_count + page_info { + end_cursor + has_next_page + } + nodes { + id + name + repository_id + provider + provider_project_name + provider_organization_id + provider_organization_name + url + } + } + } + } + } + `, + }); + + if (!rsp.ok) { + Logger.error(undefined, `Getting workspace repos failed: (${rsp.status}) ${rsp.statusText}`); + throw new Error(rsp.statusText); + } + + const json: WorkspaceRepositoriesResponse | undefined = (await rsp.json()) as + | WorkspaceRepositoriesResponse + | undefined; + + return json; + } + + @log() + async createWorkspace(options: { + name: string; + description: string; + provider: CloudWorkspaceProviderInputType; + hostUrl?: string; + azureOrganizationName?: string; + azureProjectName?: string; + }): Promise { + if (!options.name || !options.description || !options.provider) { + return; + } + + if ( + options.provider === CloudWorkspaceProviderInputType.Azure && + (!options.azureOrganizationName || !options.azureProjectName) + ) { + return; + } + + if ( + (options.provider === CloudWorkspaceProviderInputType.GitHubEnterprise || + options.provider === CloudWorkspaceProviderInputType.GitLabSelfHosted) && + !options.hostUrl + ) { + return; + } + + const rsp = await this.fetch({ + query: ` + mutation createWorkspace { + create_project( + input: { + type: GK_PROJECT + name: "${options.name}" + description: "${options.description}" + provider: ${options.provider} + ${options.hostUrl ? `host_url: "${options.hostUrl}"` : ''} + ${options.azureOrganizationName ? `azure_organization_id: "${options.azureOrganizationName}"` : ''} + ${options.azureProjectName ? `azure_project: "${options.azureProjectName}"` : ''} + profile_id: "shared-services" + } + ) { + id, + name, + description, + organization { + id + } + provider + azure_organization_id + azure_project + repo_relation + } + } + `, + }); + + if (!rsp.ok) { + Logger.error(undefined, `Creating workspace failed: (${rsp.status}) ${rsp.statusText}`); + throw new Error(rsp.statusText); + } + + const json: CreateWorkspaceResponse | undefined = (await rsp.json()) as CreateWorkspaceResponse | undefined; + + return json; + } + + @log() + async deleteWorkspace(workspaceId: string): Promise { + const rsp = await this.fetch({ + query: ` + mutation deleteWorkspace { + delete_project( + id: "${workspaceId}" + ) { + id + } + } + `, + }); + + if (!rsp.ok) { + Logger.error(undefined, `Deleting workspace failed: (${rsp.status}) ${rsp.statusText}`); + throw new Error(rsp.statusText); + } + + const json: DeleteWorkspaceResponse | undefined = (await rsp.json()) as DeleteWorkspaceResponse | undefined; + + if (json?.errors?.some(error => error.message.includes('permission'))) { + const errorMessage = + 'Adding repositories to workspace failed: you do not have permission to delete this workspace'; + Logger.error(undefined, errorMessage); + throw new Error(errorMessage); + } + + return json; + } + + @log() + async addReposToWorkspace( + workspaceId: string, + repos: AddWorkspaceRepoDescriptor[], + ): Promise { + if (repos.length === 0) return; + + let reposQuery = '['; + reposQuery += repos.map(r => `{ provider_organization_id: "${r.owner}", name: "${r.repoName}" }`).join(','); + reposQuery += ']'; + + let count = 1; + const reposReturnQuery = repos + .map( + r => `Repository${count++}: repository(provider_organization_id: "${r.owner}", name: "${r.repoName}") { + id + name + repository_id + provider + provider_project_name + provider_organization_id + provider_organization_name + url + }`, + ) + .join(','); + + const rsp = await this.fetch({ + query: ` + mutation addReposToWorkspace { + add_repositories_to_project( + input: { + project_id: "${workspaceId}", + repositories: ${reposQuery} + } + ) { + id + provider_data { + ${reposReturnQuery} + } + } + } + `, + }); + + if (!rsp.ok) { + Logger.error(undefined, `Adding repositories to workspace failed: (${rsp.status}) ${rsp.statusText}`); + throw new Error(rsp.statusText); + } + + const json: AddRepositoriesToWorkspaceResponse | undefined = (await rsp.json()) as + | AddRepositoriesToWorkspaceResponse + | undefined; + + if (json?.errors?.some(error => error.message.includes('permission'))) { + const errorMessage = + 'Adding repositories to workspace failed: you do not have permission to add repositories to this workspace'; + Logger.error(undefined, errorMessage); + throw new Error(errorMessage); + } + + return json; + } + + @log() + async removeReposFromWorkspace( + workspaceId: string, + repos: RemoveWorkspaceRepoDescriptor[], + ): Promise { + if (repos.length === 0) return; + + let reposQuery = '['; + reposQuery += repos.map(r => `{ provider_organization_id: "${r.owner}", name: "${r.repoName}" }`).join(','); + reposQuery += ']'; + + const rsp = await this.fetch({ + query: ` + mutation removeReposFromWorkspace { + remove_repositories_from_project( + input: { + project_id: "${workspaceId}", + repositories: ${reposQuery} + } + ) { + id + } + } + `, + }); + + if (!rsp.ok) { + Logger.error(undefined, `Removing repositories from workspace failed: (${rsp.status}) ${rsp.statusText}`); + throw new Error(rsp.statusText); + } + + const json: RemoveRepositoriesFromWorkspaceResponse | undefined = (await rsp.json()) as + | RemoveRepositoriesFromWorkspaceResponse + | undefined; + + if (json?.errors?.some(error => error.message.includes('permission'))) { + const errorMessage = + 'Adding repositories to workspace failed: you do not have permission to remove repositories from this workspace'; + Logger.error(undefined, errorMessage); + throw new Error(errorMessage); + } + + return json; + } + + private async fetch(request: GraphQLRequest, init?: RequestInit) { + return this.connection.fetchApiGraphQL('api/projects/graphql', request, init); + } +} diff --git a/src/plus/workspaces/workspacesPathMappingProvider.ts b/src/plus/workspaces/workspacesPathMappingProvider.ts new file mode 100644 index 0000000000000..b7c185f95f5a5 --- /dev/null +++ b/src/plus/workspaces/workspacesPathMappingProvider.ts @@ -0,0 +1,36 @@ +import type { Uri } from 'vscode'; +import type { LocalWorkspaceFileData, WorkspaceAutoAddSetting } from './models'; + +export interface WorkspacesPathMappingProvider { + getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise; + + getCloudWorkspaceCodeWorkspacePath(cloudWorkspaceId: string): Promise; + + removeCloudWorkspaceCodeWorkspaceFilePath(cloudWorkspaceId: string): Promise; + + writeCloudWorkspaceCodeWorkspaceFilePathToMap( + cloudWorkspaceId: string, + codeWorkspaceFilePath: string, + ): Promise; + + confirmCloudWorkspaceCodeWorkspaceFilePath(cloudWorkspaceId: string): Promise; + + writeCloudWorkspaceRepoDiskPathToMap( + cloudWorkspaceId: string, + repoId: string, + repoLocalPath: string, + ): Promise; + + getLocalWorkspaceData(): Promise; + + writeCodeWorkspaceFile( + uri: Uri, + workspaceRepoFilePaths: string[], + options?: { workspaceId?: string; workspaceAutoAddSetting?: WorkspaceAutoAddSetting }, + ): Promise; + + updateCodeWorkspaceFileSettings( + uri: Uri, + options: { workspaceAutoAddSetting?: WorkspaceAutoAddSetting }, + ): Promise; +} diff --git a/src/plus/workspaces/workspacesService.ts b/src/plus/workspaces/workspacesService.ts new file mode 100644 index 0000000000000..b6e382fd9a2b8 --- /dev/null +++ b/src/plus/workspaces/workspacesService.ts @@ -0,0 +1,1371 @@ +import { getSupportedWorkspacesPathMappingProvider } from '@env/providers'; +import type { CancellationToken, Event, MessageItem, QuickPickItem } from 'vscode'; +import { Disposable, EventEmitter, ProgressLocation, Uri, window, workspace } from 'vscode'; +import type { Container } from '../../container'; +import type { GitRemote } from '../../git/models/remote'; +import { RemoteResourceType } from '../../git/models/remoteResource'; +import { Repository } from '../../git/models/repository'; +import { showRepositoriesPicker } from '../../quickpicks/repositoryPicker'; +import { log } from '../../system/decorators/log'; +import { normalizePath } from '../../system/path'; +import type { OpenWorkspaceLocation } from '../../system/vscode/utils'; +import { openWorkspace } from '../../system/vscode/utils'; +import { SubscriptionState } from '../gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../gk/account/subscriptionService'; +import type { ServerConnection } from '../gk/serverConnection'; +import type { + AddWorkspaceRepoDescriptor, + CloudWorkspaceData, + CloudWorkspaceRepositoryDescriptor, + GetWorkspacesResponse, + LoadCloudWorkspacesResponse, + LoadLocalWorkspacesResponse, + LocalWorkspaceData, + LocalWorkspaceRepositoryDescriptor, + RemoteDescriptor, + RepositoryMatch, + WorkspaceAutoAddSetting, + WorkspaceRepositoriesByName, + WorkspaceRepositoryRelation, + WorkspacesResponse, +} from './models'; +import { + CloudWorkspace, + CloudWorkspaceProviderInputType, + CloudWorkspaceProviderType, + cloudWorkspaceProviderTypeToRemoteProviderId, + LocalWorkspace, + WorkspaceAddRepositoriesChoice, +} from './models'; +import { WorkspacesApi } from './workspacesApi'; +import type { WorkspacesPathMappingProvider } from './workspacesPathMappingProvider'; + +export class WorkspacesService implements Disposable { + private _onDidResetWorkspaces: EventEmitter = new EventEmitter(); + get onDidResetWorkspaces(): Event { + return this._onDidResetWorkspaces.event; + } + + private _cloudWorkspaces: CloudWorkspace[] | undefined; + private _disposable: Disposable; + private _localWorkspaces: LocalWorkspace[] | undefined; + private _workspacesApi: WorkspacesApi; + private _workspacesPathProvider: WorkspacesPathMappingProvider; + private _currentWorkspaceId: string | undefined; + private _currentWorkspaceAutoAddSetting: WorkspaceAutoAddSetting = 'disabled'; + private _currentWorkspace: CloudWorkspace | LocalWorkspace | undefined; + + constructor( + private readonly container: Container, + private readonly connection: ServerConnection, + ) { + this._workspacesApi = new WorkspacesApi(this.container, this.connection); + this._workspacesPathProvider = getSupportedWorkspacesPathMappingProvider(); + this._currentWorkspaceId = getCurrentWorkspaceId(); + this._currentWorkspaceAutoAddSetting = + workspace.getConfiguration('gitkraken')?.get('workspaceAutoAddSetting') ?? + 'disabled'; + this._disposable = Disposable.from(container.subscription.onDidChange(this.onSubscriptionChanged, this)); + } + + dispose(): void { + this._disposable.dispose(); + } + + get currentWorkspaceId(): string | undefined { + return this._currentWorkspaceId; + } + + get currentWorkspace(): CloudWorkspace | LocalWorkspace | undefined { + return this._currentWorkspace; + } + + private onSubscriptionChanged(event: SubscriptionChangeEvent): void { + if ( + event.current.account == null || + event.current.account.id !== event.previous?.account?.id || + event.current.state !== event.previous?.state + ) { + this.resetWorkspaces({ cloud: true }); + } + } + + private async loadCloudWorkspaces(excludeRepositories: boolean = false): Promise { + const subscription = await this.container.subscription.getSubscription(); + if (subscription?.account == null) { + return { + cloudWorkspaces: undefined, + cloudWorkspaceInfo: 'Please sign in to use cloud workspaces.', + }; + } + + const cloudWorkspaces: CloudWorkspace[] = []; + let workspaces: CloudWorkspaceData[] | undefined; + try { + const workspaceResponse: WorkspacesResponse | undefined = await this._workspacesApi.getWorkspaces({ + includeRepositories: !excludeRepositories, + includeOrganizations: true, + }); + workspaces = workspaceResponse?.data?.projects?.nodes; + } catch { + return { + cloudWorkspaces: undefined, + cloudWorkspaceInfo: 'Failed to load cloud workspaces.', + }; + } + + let filteredSharedWorkspaceCount = 0; + const isPlusEnabled = + subscription.state === SubscriptionState.FreeInPreviewTrial || + subscription.state === SubscriptionState.FreePlusInTrial || + subscription.state === SubscriptionState.Paid; + + if (workspaces?.length) { + for (const workspace of workspaces) { + const localPath = await this._workspacesPathProvider.getCloudWorkspaceCodeWorkspacePath(workspace.id); + if (!isPlusEnabled && workspace.organization?.id) { + filteredSharedWorkspaceCount += 1; + continue; + } + + const repoDescriptors = workspace.provider_data?.repositories?.nodes; + let repositories = + repoDescriptors != null + ? repoDescriptors.map(descriptor => ({ ...descriptor, workspaceId: workspace.id })) + : repoDescriptors; + if (repositories == null && !excludeRepositories) { + repositories = []; + } + + cloudWorkspaces.push( + new CloudWorkspace( + this.container, + workspace.id, + workspace.name, + workspace.organization?.id, + workspace.provider as CloudWorkspaceProviderType, + workspace.repo_relation as WorkspaceRepositoryRelation, + this._currentWorkspaceId != null && this._currentWorkspaceId === workspace.id, + workspace.provider === CloudWorkspaceProviderType.Azure + ? { + organizationId: workspace.azure_organization_id ?? undefined, + project: workspace.azure_project ?? undefined, + } + : undefined, + repositories, + localPath, + ), + ); + } + } + + return { + cloudWorkspaces: cloudWorkspaces, + cloudWorkspaceInfo: + filteredSharedWorkspaceCount > 0 + ? `${filteredSharedWorkspaceCount} shared workspaces hidden - upgrade to GitLens Pro to access.` + : undefined, + }; + } + + // TODO@ramint: When we interact more with local workspaces, this should return more info about failures. + private async loadLocalWorkspaces(): Promise { + const localWorkspaces: LocalWorkspace[] = []; + const workspaceFileData: LocalWorkspaceData = + (await this._workspacesPathProvider.getLocalWorkspaceData())?.workspaces || {}; + for (const workspace of Object.values(workspaceFileData)) { + if (workspace.localId == null || workspace.name == null) continue; + localWorkspaces.push( + new LocalWorkspace( + this.container, + workspace.localId, + workspace.name, + workspace.repositories?.map(repositoryPath => ({ + localPath: repositoryPath.localPath, + name: repositoryPath.localPath.split(/[\\/]/).pop() ?? 'unknown', + workspaceId: workspace.localId, + })) ?? [], + this._currentWorkspaceId != null && this._currentWorkspaceId === workspace.localId, + ), + ); + } + + return { + localWorkspaces: localWorkspaces, + localWorkspaceInfo: undefined, + }; + } + + private getCloudWorkspace(workspaceId: string): CloudWorkspace | undefined { + return this._cloudWorkspaces?.find(workspace => workspace.id === workspaceId); + } + + private getLocalWorkspace(workspaceId: string): LocalWorkspace | undefined { + return this._localWorkspaces?.find(workspace => workspace.id === workspaceId); + } + + @log() + async getWorkspaces(options?: { excludeRepositories?: boolean; force?: boolean }): Promise { + const getWorkspacesResponse: GetWorkspacesResponse = { + cloudWorkspaces: [], + localWorkspaces: [], + cloudWorkspaceInfo: undefined, + localWorkspaceInfo: undefined, + }; + + if (this._cloudWorkspaces == null || options?.force) { + const loadCloudWorkspacesResponse = await this.loadCloudWorkspaces(options?.excludeRepositories); + this._cloudWorkspaces = loadCloudWorkspacesResponse.cloudWorkspaces; + getWorkspacesResponse.cloudWorkspaceInfo = loadCloudWorkspacesResponse.cloudWorkspaceInfo; + } + + if (this._localWorkspaces == null || options?.force) { + const loadLocalWorkspacesResponse = await this.loadLocalWorkspaces(); + this._localWorkspaces = loadLocalWorkspacesResponse.localWorkspaces; + getWorkspacesResponse.localWorkspaceInfo = loadLocalWorkspacesResponse.localWorkspaceInfo; + } + + const currentWorkspace = [...(this._cloudWorkspaces ?? []), ...(this._localWorkspaces ?? [])].find( + workspace => workspace.current, + ); + + if (currentWorkspace != null) { + this._currentWorkspaceId = currentWorkspace.id; + this._currentWorkspace = currentWorkspace; + } + + getWorkspacesResponse.cloudWorkspaces = this._cloudWorkspaces ?? []; + getWorkspacesResponse.localWorkspaces = this._localWorkspaces ?? []; + + return getWorkspacesResponse; + } + + async getCloudWorkspaceRepositories(workspaceId: string): Promise { + // TODO@ramint Add error handling/logging when this is used. + const workspaceRepos = await this._workspacesApi.getWorkspaceRepositories(workspaceId); + const descriptors = workspaceRepos?.data?.project?.provider_data?.repositories?.nodes; + return descriptors?.map(d => ({ ...d, workspaceId: workspaceId })) ?? []; + } + + @log() + async addMissingCurrentWorkspaceRepos(options?: { force?: boolean }): Promise { + if (this._currentWorkspaceId == null) return; + let currentWorkspace = [...(this._cloudWorkspaces ?? []), ...(this._localWorkspaces ?? [])].find( + workspace => workspace.current, + ); + + if (currentWorkspace == null) { + try { + const workspaceData = await this._workspacesApi.getWorkspace(this._currentWorkspaceId, { + includeRepositories: true, + }); + if (workspaceData?.data?.project == null) return; + const repoDescriptors = workspaceData.data.project.provider_data?.repositories?.nodes; + const repositories = + repoDescriptors != null + ? repoDescriptors.map(descriptor => ({ + ...descriptor, + workspaceId: workspaceData.data.project.id, + })) + : []; + currentWorkspace = new CloudWorkspace( + this.container, + workspaceData.data.project.id, + workspaceData.data.project.name, + workspaceData.data.project.organization?.id, + workspaceData.data.project.provider as CloudWorkspaceProviderType, + workspaceData.data.project.repo_relation as WorkspaceRepositoryRelation, + true, + workspaceData.data.project.provider === CloudWorkspaceProviderType.Azure + ? { + organizationId: workspaceData.data.project.azure_organization_id ?? undefined, + project: workspaceData.data.project.azure_project ?? undefined, + } + : undefined, + repositories, + workspace.workspaceFile?.fsPath, + ); + } catch { + return; + } + } + + if ((!options?.force && this._currentWorkspaceAutoAddSetting === 'disabled') || !currentWorkspace?.current) { + return; + } + + this._currentWorkspace = currentWorkspace; + + if (!(await currentWorkspace.getRepositoryDescriptors())?.length) return; + + const repositories = [ + ...( + await this.resolveWorkspaceRepositoriesByName(currentWorkspace, { + resolveFromPath: true, + usePathMapping: true, + }) + ).values(), + ].map(r => r.repository); + const currentWorkspaceRepositoryIdMap = new Map(); + for (const repository of this.container.git.openRepositories) { + currentWorkspaceRepositoryIdMap.set(repository.id, repository); + } + const repositoriesToAdd = repositories.filter(r => !currentWorkspaceRepositoryIdMap.has(r.id)); + if (repositoriesToAdd.length === 0) { + if (options?.force) { + void window.showInformationMessage('No new repositories found to add.', { modal: true }); + } + return; + } + let chosenRepoPaths: string[] = []; + if (!options?.force && this._currentWorkspaceAutoAddSetting === 'prompt') { + const add = { title: 'Add...' }; + const change = { title: 'Change Auto-Add Behavior...' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const addChoice = await window.showInformationMessage( + 'New repositories found in the linked GitKraken workspace. Would you like to add them to the current VS Code workspace?', + add, + change, + cancel, + ); + + if (addChoice == null || addChoice === cancel) return; + if (addChoice === change) { + void this.chooseCodeWorkspaceAutoAddSetting({ current: true }); + return; + } + } + + if (options?.force || this._currentWorkspaceAutoAddSetting === 'prompt') { + const pick = await showRepositoriesPicker( + 'Add Repositories to Workspace', + 'Choose which repositories to add to the current workspace', + repositoriesToAdd, + ); + if (pick.length === 0) return; + chosenRepoPaths = pick.map(p => p.path); + } else { + chosenRepoPaths = repositoriesToAdd.map(r => r.path); + } + + if (chosenRepoPaths.length === 0) return; + const count = workspace.workspaceFolders?.length ?? 0; + void window.withProgress( + { + location: ProgressLocation.Notification, + title: `Adding new repositories from linked cloud workspace...`, + cancellable: false, + }, + () => { + return new Promise(resolve => { + workspace.updateWorkspaceFolders(count, 0, ...chosenRepoPaths.map(p => ({ uri: Uri.file(p) }))); + resolve(true); + }); + }, + ); + } + + @log() + resetWorkspaces(options?: { cloud?: boolean; local?: boolean }) { + if (options?.cloud ?? true) { + this._cloudWorkspaces = undefined; + } + if (options?.local ?? true) { + this._localWorkspaces = undefined; + } + + this._onDidResetWorkspaces.fire(); + } + + async getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise { + return this._workspacesPathProvider.getCloudWorkspaceRepoPath(cloudWorkspaceId, repoId); + } + + async updateCloudWorkspaceRepoLocalPath(workspaceId: string, repoId: string, localPath: string): Promise { + await this._workspacesPathProvider.writeCloudWorkspaceRepoDiskPathToMap(workspaceId, repoId, localPath); + } + + private async getRepositoriesInParentFolder(cancellation?: CancellationToken): Promise { + const parentUri = ( + await window.showOpenDialog({ + title: `Choose a folder containing repositories for this workspace`, + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + }) + )?.[0]; + + if (parentUri == null || cancellation?.isCancellationRequested) return undefined; + + try { + return await this.container.git.findRepositories(parentUri, { + cancellation: cancellation, + depth: 1, + silent: true, + }); + } catch (_ex) { + return undefined; + } + } + + async locateAllCloudWorkspaceRepos(workspaceId: string, cancellation?: CancellationToken): Promise { + const workspace = this.getCloudWorkspace(workspaceId); + if (workspace == null) return; + + const repoDescriptors = await workspace.getRepositoryDescriptors(); + if (repoDescriptors == null || repoDescriptors.length === 0) return; + + const foundRepos = await this.getRepositoriesInParentFolder(cancellation); + if (foundRepos == null || foundRepos.length === 0 || cancellation?.isCancellationRequested) return; + + for (const repoMatch of ( + await this.resolveWorkspaceRepositoriesByName(workspaceId, { + cancellation: cancellation, + repositories: foundRepos, + }) + ).values()) { + await this.locateWorkspaceRepo(workspaceId, repoMatch.descriptor, repoMatch.repository); + + if (cancellation?.isCancellationRequested) return; + } + } + + async locateWorkspaceRepo( + workspaceId: string, + descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, + ): Promise; + async locateWorkspaceRepo( + workspaceId: string, + descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, + // eslint-disable-next-line @typescript-eslint/unified-signatures + uri: Uri, + ): Promise; + async locateWorkspaceRepo( + workspaceId: string, + descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, + // eslint-disable-next-line @typescript-eslint/unified-signatures + repository: Repository, + ): Promise; + @log({ args: { 1: false, 2: false } }) + async locateWorkspaceRepo( + workspaceId: string, + descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, + uriOrRepository?: Uri | Repository, + ): Promise { + let repo; + if (uriOrRepository == null || uriOrRepository instanceof Uri) { + let repoLocatedUri = uriOrRepository; + if (repoLocatedUri == null) { + repoLocatedUri = ( + await window.showOpenDialog({ + title: `Choose a location for ${descriptor.name}`, + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + }) + )?.[0]; + } + + if (repoLocatedUri == null) return; + + repo = await this.container.git.getOrOpenRepository(repoLocatedUri, { + closeOnOpen: true, + detectNested: false, + }); + if (repo == null) return; + } else { + repo = uriOrRepository; + } + + const repoPath = repo.uri.fsPath; + + const remotes = await repo.getRemotes(); + const remoteUrls: string[] = []; + for (const remote of remotes) { + const remoteUrl = remote.provider?.url({ type: RemoteResourceType.Repo }); + if (remoteUrl != null) { + remoteUrls.push(remoteUrl); + } + } + + for (const remoteUrl of remoteUrls) { + await this.container.repositoryPathMapping.writeLocalRepoPath({ remoteUrl: remoteUrl }, repoPath); + } + + const workspace = this.getCloudWorkspace(workspaceId) ?? this.getLocalWorkspace(workspaceId); + let provider: string | undefined; + if (provider == null && workspace?.type === 'cloud') { + provider = workspace.provider; + } + + if ( + descriptor.id != null && + (descriptor.url != null || + (descriptor.provider_organization_id != null && descriptor.name != null && provider != null)) + ) { + await this.container.repositoryPathMapping.writeLocalRepoPath( + { + remoteUrl: descriptor.url ?? undefined, + repoInfo: { + provider: provider, + owner: descriptor.provider_organization_id, + repoName: descriptor.name, + }, + }, + repoPath, + ); + } + + if (descriptor.id != null) { + await this.updateCloudWorkspaceRepoLocalPath(workspaceId, descriptor.id, repoPath); + } + } + + @log({ args: false }) + async createCloudWorkspace(options?: { repos?: Repository[] }): Promise { + const input = window.createInputBox(); + input.title = 'Create Cloud Workspace'; + const quickpick = window.createQuickPick(); + quickpick.title = 'Create Cloud Workspace'; + const quickpickLabelToProviderType: Record = { + GitHub: CloudWorkspaceProviderInputType.GitHub, + 'GitHub Enterprise': CloudWorkspaceProviderInputType.GitHubEnterprise, + // TODO add support for these in the future + // GitLab: CloudWorkspaceProviderInputType.GitLab, + // 'GitLab Self-Managed': CloudWorkspaceProviderInputType.GitLabSelfHosted, + // Bitbucket: CloudWorkspaceProviderInputType.Bitbucket, + // Azure: CloudWorkspaceProviderInputType.Azure, + }; + + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let workspaceName: string | undefined; + let workspaceDescription: string | undefined; + + let hostUrl: string | undefined; + let azureOrganizationName: string | undefined; + let azureProjectName: string | undefined; + let workspaceProvider: CloudWorkspaceProviderInputType | undefined; + if (options?.repos != null && options.repos.length > 0) { + // Currently only GitHub is supported. + for (const repo of options.repos) { + const repoRemotes = await repo.getRemotes({ filter: r => r.domain === 'github.com' }); + if (repoRemotes.length === 0) { + await window.showErrorMessage( + `Only GitHub is supported for this operation. Please ensure all open repositories are hosted on GitHub.`, + { modal: true }, + ); + return; + } + } + + workspaceProvider = CloudWorkspaceProviderInputType.GitHub; + } + + try { + workspaceName = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = 'Please enter a non-empty name for the workspace'; + return; + } + + resolve(value); + }), + ); + + input.placeholder = 'Please enter a name for the new workspace'; + input.prompt = 'Enter your workspace name'; + input.show(); + }); + + if (!workspaceName) return; + + workspaceDescription = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = 'Please enter a non-empty description for the workspace'; + return; + } + + resolve(value); + }), + ); + + input.value = ''; + input.title = 'Create Workspace'; + input.placeholder = 'Please enter a description for the new workspace'; + input.prompt = 'Enter your workspace description'; + input.show(); + }); + + if (!workspaceDescription) return; + + if (workspaceProvider == null) { + workspaceProvider = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpickLabelToProviderType[quickpick.activeItems[0].label]); + } + }), + ); + + quickpick.placeholder = 'Please select a provider for the new workspace'; + quickpick.items = Object.keys(quickpickLabelToProviderType).map(label => ({ label: label })); + quickpick.canSelectMany = false; + quickpick.show(); + }); + } + + if (!workspaceProvider) return; + + if ( + workspaceProvider == CloudWorkspaceProviderInputType.GitHubEnterprise || + workspaceProvider == CloudWorkspaceProviderInputType.GitLabSelfHosted + ) { + hostUrl = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = 'Please enter a non-empty host URL for the workspace'; + return; + } + + resolve(value); + }), + ); + + input.value = ''; + input.placeholder = 'Please enter a host URL for the new workspace'; + input.prompt = 'Enter your workspace host URL'; + input.show(); + }); + + if (!hostUrl) return; + } + + if (workspaceProvider == CloudWorkspaceProviderInputType.Azure) { + azureOrganizationName = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = + 'Please enter a non-empty organization name for the workspace'; + return; + } + + resolve(value); + }), + ); + + input.value = ''; + input.placeholder = 'Please enter an organization name for the new workspace'; + input.prompt = 'Enter your workspace organization name'; + input.show(); + }); + + if (!azureOrganizationName) return; + + azureProjectName = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = 'Please enter a non-empty project name for the workspace'; + return; + } + + resolve(value); + }), + ); + + input.value = ''; + input.placeholder = 'Please enter a project name for the new workspace'; + input.prompt = 'Enter your workspace project name'; + input.show(); + }); + + if (!azureProjectName) return; + } + } finally { + input.dispose(); + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); + } + + const createOptions = { + name: workspaceName, + description: workspaceDescription, + provider: workspaceProvider, + hostUrl: hostUrl, + azureOrganizationName: azureOrganizationName, + azureProjectName: azureProjectName, + }; + + let createdProjectData: CloudWorkspaceData | null | undefined; + try { + const response = await this._workspacesApi.createWorkspace(createOptions); + createdProjectData = response?.data?.create_project; + } catch { + return; + } + + if (createdProjectData != null) { + // Add the new workspace to cloud workspaces + if (this._cloudWorkspaces == null) { + this._cloudWorkspaces = []; + } + + const localPath = await this._workspacesPathProvider.getCloudWorkspaceCodeWorkspacePath( + createdProjectData.id, + ); + + this._cloudWorkspaces?.push( + new CloudWorkspace( + this.container, + createdProjectData.id, + createdProjectData.name, + createdProjectData.organization?.id, + createdProjectData.provider as CloudWorkspaceProviderType, + createdProjectData.repo_relation as WorkspaceRepositoryRelation, + this._currentWorkspaceId != null && this._currentWorkspaceId === createdProjectData.id, + createdProjectData.provider === CloudWorkspaceProviderType.Azure + ? { + organizationId: createdProjectData.azure_organization_id ?? undefined, + project: createdProjectData.azure_project ?? undefined, + } + : undefined, + [], + localPath, + ), + ); + + const newWorkspace = this.getCloudWorkspace(createdProjectData.id); + if (newWorkspace != null) { + await this.addCloudWorkspaceRepos(newWorkspace.id, { + repos: options?.repos, + suppressNotifications: true, + }); + } + } + } + + @log() + async deleteCloudWorkspace(workspaceId: string) { + const confirmation = await window.showWarningMessage( + `Are you sure you want to delete this workspace? This cannot be undone.`, + { modal: true }, + { title: 'Confirm' }, + { title: 'Cancel', isCloseAffordance: true }, + ); + if (confirmation == null || confirmation.title == 'Cancel') return; + try { + const response = await this._workspacesApi.deleteWorkspace(workspaceId); + if (response?.data?.delete_project?.id === workspaceId) { + // Remove the workspace from the local workspace list. + this._cloudWorkspaces = this._cloudWorkspaces?.filter(w => w.id !== workspaceId); + } + } catch (error) { + void window.showErrorMessage(error.message); + } + } + + private async filterReposForProvider( + repos: Repository[], + provider: CloudWorkspaceProviderType, + ): Promise { + const validRepos: Repository[] = []; + for (const repo of repos) { + const matchingRemotes = await repo.getRemotes({ + filter: r => r.provider?.id === cloudWorkspaceProviderTypeToRemoteProviderId[provider], + }); + if (matchingRemotes.length) { + validRepos.push(repo); + } + } + + return validRepos; + } + + private async filterReposForCloudWorkspace(repos: Repository[], workspaceId: string): Promise { + const workspace = this.getCloudWorkspace(workspaceId) ?? this.getLocalWorkspace(workspaceId); + if (workspace == null) return repos; + const workspaceRepos = [...(await workspace.getRepositoriesByName()).values()].map(match => match.repository); + return repos.filter(repo => !workspaceRepos.find(r => r.id === repo.id)); + } + + @log({ args: { 1: false } }) + async addCloudWorkspaceRepos( + workspaceId: string, + options?: { repos?: Repository[]; suppressNotifications?: boolean }, + ) { + const workspace = this.getCloudWorkspace(workspaceId); + if (workspace == null) return; + + const repoInputs: (AddWorkspaceRepoDescriptor & { repo: Repository; url?: string })[] = []; + let reposOrRepoPaths: Repository[] | string[] | undefined = options?.repos; + if (!options?.repos) { + let validRepos = await this.filterReposForProvider(this.container.git.openRepositories, workspace.provider); + validRepos = await this.filterReposForCloudWorkspace(validRepos, workspaceId); + const choices: { + label: string; + description?: string; + choice: WorkspaceAddRepositoriesChoice; + picked?: boolean; + }[] = [ + { + label: 'Choose repositories from a folder', + description: undefined, + choice: WorkspaceAddRepositoriesChoice.ParentFolder, + }, + ]; + + if (validRepos.length > 0) { + choices.unshift({ + label: 'Choose repositories from the current window', + description: undefined, + choice: WorkspaceAddRepositoriesChoice.CurrentWindow, + }); + } + + choices[0].picked = true; + + const repoChoice = await window.showQuickPick(choices, { + placeHolder: 'Choose repositories from the current window or a folder', + ignoreFocusOut: true, + }); + + if (repoChoice == null) return; + + if (repoChoice.choice === WorkspaceAddRepositoriesChoice.ParentFolder) { + await window.withProgress( + { + location: ProgressLocation.Notification, + title: `Finding repositories to add to the workspace...`, + cancellable: true, + }, + async (_progress, token) => { + const foundRepos = await this.getRepositoriesInParentFolder(token); + if (foundRepos == null) return; + if (foundRepos.length === 0) { + if (!options?.suppressNotifications) { + void window.showInformationMessage(`No repositories found in the chosen folder.`, { + modal: true, + }); + } + return; + } + + if (token.isCancellationRequested) return; + validRepos = await this.filterReposForProvider(foundRepos, workspace.provider); + if (validRepos.length === 0) { + if (!options?.suppressNotifications) { + void window.showInformationMessage( + `No matching repositories found for provider ${workspace.provider}.`, + { + modal: true, + }, + ); + } + return; + } + + if (token.isCancellationRequested) return; + validRepos = await this.filterReposForCloudWorkspace(validRepos, workspaceId); + if (validRepos.length === 0) { + if (!options?.suppressNotifications) { + void window.showInformationMessage( + `All possible repositories are already in this workspace.`, + { + modal: true, + }, + ); + } + } + }, + ); + } + + const pick = await showRepositoriesPicker( + 'Add Repositories to Workspace', + 'Choose which repositories to add to the workspace', + validRepos, + ); + if (pick.length === 0) return; + reposOrRepoPaths = pick.map(p => p.path); + } + + if (reposOrRepoPaths == null) return; + for (const repoOrPath of reposOrRepoPaths) { + const repo = + repoOrPath instanceof Repository + ? repoOrPath + : await this.container.git.getOrOpenRepository(Uri.file(repoOrPath), { closeOnOpen: true }); + if (repo == null) continue; + const remote = (await repo.getRemote('origin')) || (await repo.getRemotes())?.[0]; + const remoteDescriptor = getRemoteDescriptor(remote); + if (remoteDescriptor == null) continue; + repoInputs.push({ + owner: remoteDescriptor.owner, + repoName: remoteDescriptor.repoName, + repo: repo, + url: remoteDescriptor.url, + }); + } + + if (repoInputs.length === 0) return; + + let newRepoDescriptors: CloudWorkspaceRepositoryDescriptor[] = []; + const oldDescriptorIds = new Set((await workspace.getRepositoryDescriptors()).map(r => r.id)); + + await window.withProgress( + { + location: ProgressLocation.Notification, + title: `Adding repositories to workspace ${workspace.name}...`, + cancellable: false, + }, + async () => { + try { + const response = await this._workspacesApi.addReposToWorkspace( + workspaceId, + repoInputs.map(r => ({ owner: r.owner, repoName: r.repoName })), + ); + + if (response?.data.add_repositories_to_project == null) return; + newRepoDescriptors = Object.values(response.data.add_repositories_to_project.provider_data) + .filter(descriptor => descriptor != null) + .map(descriptor => ({ + ...descriptor, + workspaceId: workspaceId, + })) as CloudWorkspaceRepositoryDescriptor[]; + } catch (error) { + void window.showErrorMessage(error.message); + return; + } + + if (newRepoDescriptors.length > 0) { + workspace.addRepositories(newRepoDescriptors); + } + + if (newRepoDescriptors.length < repoInputs.length) { + newRepoDescriptors = (await workspace.getRepositoryDescriptors({ force: true })).filter( + r => !oldDescriptorIds.has(r.id), + ); + } + + for (const { repo, repoName, url } of repoInputs) { + const successfullyAddedDescriptor = newRepoDescriptors.find( + r => r.name.toLowerCase() === repoName || r.url === url, + ); + if (successfullyAddedDescriptor == null) continue; + await this.locateWorkspaceRepo(workspaceId, successfullyAddedDescriptor, repo); + } + }, + ); + } + + @log({ args: { 1: false } }) + async removeCloudWorkspaceRepo(workspaceId: string, descriptor: CloudWorkspaceRepositoryDescriptor) { + const workspace = this.getCloudWorkspace(workspaceId); + if (workspace == null) return; + + const confirmation = await window.showWarningMessage( + `Are you sure you want to remove ${descriptor.name} from this workspace? This cannot be undone.`, + { modal: true }, + { title: 'Confirm' }, + { title: 'Cancel', isCloseAffordance: true }, + ); + if (confirmation == null || confirmation.title == 'Cancel') return; + try { + const response = await this._workspacesApi.removeReposFromWorkspace(workspaceId, [ + { owner: descriptor.provider_organization_id, repoName: descriptor.name }, + ]); + + if (response?.data.remove_repositories_from_project == null) return; + + workspace.removeRepositories([descriptor.name]); + } catch (error) { + void window.showErrorMessage(error.message); + } + } + + async resolveWorkspaceRepositoriesByName( + workspace: CloudWorkspace | LocalWorkspace, + options?: { + cancellation?: CancellationToken; + repositories?: Repository[]; + resolveFromPath?: boolean; + usePathMapping?: boolean; + }, + ): Promise; + async resolveWorkspaceRepositoriesByName( + workspaceId: string, + options?: { + cancellation?: CancellationToken; + repositories?: Repository[]; + resolveFromPath?: boolean; + usePathMapping?: boolean; + }, + ): Promise; + @log({ args: { 1: false } }) + async resolveWorkspaceRepositoriesByName( + workspaceOrId: CloudWorkspace | LocalWorkspace | string, + options?: { + cancellation?: CancellationToken; + repositories?: Repository[]; + resolveFromPath?: boolean; + usePathMapping?: boolean; + }, + ): Promise { + const workspaceRepositoriesByName: WorkspaceRepositoriesByName = new Map(); + + const workspace = + workspaceOrId instanceof CloudWorkspace || workspaceOrId instanceof LocalWorkspace + ? workspaceOrId + : this.getLocalWorkspace(workspaceOrId) ?? this.getCloudWorkspace(workspaceOrId); + if (workspace == null) return workspaceRepositoriesByName; + + const repoDescriptors = await workspace.getRepositoryDescriptors(); + if (repoDescriptors == null || repoDescriptors.length === 0) return workspaceRepositoriesByName; + + const currentRepositories = options?.repositories ?? this.container.git.repositories; + + const reposProviderMap = new Map(); + const reposPathMap = new Map(); + for (const repo of currentRepositories) { + if (options?.cancellation?.isCancellationRequested) break; + reposPathMap.set(normalizePath(repo.uri.fsPath.toLowerCase()), repo); + + if (workspace instanceof CloudWorkspace) { + const remotes = await repo.getRemotes(); + for (const remote of remotes) { + const remoteDescriptor = getRemoteDescriptor(remote); + if (remoteDescriptor == null) continue; + reposProviderMap.set( + `${remoteDescriptor.provider}/${remoteDescriptor.owner}/${remoteDescriptor.repoName}`, + repo, + ); + } + } + } + + for (const descriptor of repoDescriptors) { + let repoLocalPath = null; + let foundRepo = null; + + // Local workspace repo descriptors should match on local path + if (descriptor.id == null) { + repoLocalPath = descriptor.localPath; + // Cloud workspace repo descriptors should match on either provider/owner/name or url on any remote + } else if (options?.usePathMapping === true) { + repoLocalPath = await this.getMappedPathForCloudWorkspaceRepoDescriptor(descriptor); + } + + if (repoLocalPath != null) { + foundRepo = reposPathMap.get(normalizePath(repoLocalPath.toLowerCase())); + } + + if (foundRepo == null && descriptor.id != null && descriptor.provider != null) { + foundRepo = reposProviderMap.get( + `${descriptor.provider.toLowerCase()}/${descriptor.provider_organization_id.toLowerCase()}/${descriptor.name.toLowerCase()}`, + ); + } + + if (repoLocalPath != null && foundRepo == null && options?.resolveFromPath === true) { + foundRepo = await this.container.git.getOrOpenRepository(Uri.file(repoLocalPath), { + closeOnOpen: true, + force: true, + }); + // TODO: Add this logic back in once we think through virtual repository support a bit more. + // We want to support virtual repositories not just as an automatic backup, but as a user choice. + /*if (!foundRepo) { + let uri: Uri | undefined = undefined; + if (repoLocalPath) { + uri = Uri.file(repoLocalPath); + } else if (descriptor.url) { + uri = Uri.parse(descriptor.url); + uri = uri.with({ + scheme: Schemes.Virtual, + authority: encodeAuthority('github'), + path: uri.path, + }); + } + if (uri) { + foundRepo = await this.container.git.getOrOpenRepository(uri, { closeOnOpen: true }); + } + }*/ + } + + if (foundRepo != null) { + workspaceRepositoriesByName.set(descriptor.name, { descriptor: descriptor, repository: foundRepo }); + } + } + + return workspaceRepositoriesByName; + } + + @log() + async saveAsCodeWorkspaceFile(workspaceId: string): Promise { + const workspace = this.getCloudWorkspace(workspaceId) ?? this.getLocalWorkspace(workspaceId); + if (workspace == null) return; + + const repoDescriptors = await workspace.getRepositoryDescriptors(); + if (repoDescriptors == null) return; + + const workspaceRepositoriesByName = await workspace.getRepositoriesByName(); + + if (workspaceRepositoriesByName.size === 0) { + void window.showErrorMessage( + 'No repositories in this workspace could be found locally. Please locate at least one repository.', + { modal: true }, + ); + return; + } + + const workspaceFolderPaths: string[] = []; + for (const repoMatch of workspaceRepositoriesByName.values()) { + const repo = repoMatch.repository; + if (!repo.virtual) { + workspaceFolderPaths.push(repo.uri.fsPath); + } + } + + if (workspaceFolderPaths.length < repoDescriptors.length) { + const confirmation = await window.showWarningMessage( + `Some repositories in this workspace could not be located locally. Do you want to continue?`, + { modal: true }, + { title: 'Continue' }, + { title: 'Cancel', isCloseAffordance: true }, + ); + if (confirmation == null || confirmation.title == 'Cancel') return; + } + + // Have the user choose a name and location for the new workspace file + const newWorkspaceUri = await window.showSaveDialog({ + defaultUri: Uri.file(`${workspace.name}.code-workspace`), + filters: { + 'Code Workspace': ['code-workspace'], + }, + title: 'Choose a location for the new code workspace file', + }); + + if (newWorkspaceUri == null) return; + + const newWorkspaceAutoAddSetting = await this.chooseCodeWorkspaceAutoAddSetting(); + + const created = await this._workspacesPathProvider.writeCodeWorkspaceFile( + newWorkspaceUri, + workspaceFolderPaths, + { + workspaceId: workspaceId, + workspaceAutoAddSetting: newWorkspaceAutoAddSetting, + }, + ); + + if (!created) { + void window.showErrorMessage('Could not create the new workspace file. Check logs for details'); + return; + } + + workspace.setLocalPath(newWorkspaceUri.fsPath); + + type LocationMessageItem = MessageItem & { location?: OpenWorkspaceLocation }; + + const openNewWindow: LocationMessageItem = { title: 'Open in New Window', location: 'newWindow' }; + const openCurrent: LocationMessageItem = { title: 'Open in Current Window', location: 'currentWindow' }; + const cancel: LocationMessageItem = { title: 'Cancel', isCloseAffordance: true } as const; + const result = await window.showInformationMessage( + `Workspace file created for ${workspace.name}. Would you like to open it now?`, + { modal: true }, + openNewWindow, + openCurrent, + cancel, + ); + + if (result == null || result === cancel) return; + + void this.openCodeWorkspaceFile(workspaceId, { location: result.location }); + } + + @log() + async chooseCodeWorkspaceAutoAddSetting(options?: { current?: boolean }): Promise { + if ( + options?.current && + (workspace.workspaceFile == null || + this._currentWorkspaceId == null || + this._currentWorkspaceAutoAddSetting == null) + ) { + return 'disabled'; + } + + const defaultOption = options?.current ? this._currentWorkspaceAutoAddSetting : 'disabled'; + + type QuickPickItemWithOption = QuickPickItem & { option: WorkspaceAutoAddSetting }; + + const autoAddOptions: QuickPickItemWithOption[] = [ + { + label: 'Add on Workspace (Window) Open', + description: this._currentWorkspaceAutoAddSetting === 'enabled' ? 'current' : undefined, + option: 'enabled', + }, + { + label: 'Prompt on Workspace (Window) Open', + description: this._currentWorkspaceAutoAddSetting === 'prompt' ? 'current' : undefined, + option: 'prompt', + }, + { + label: 'Never', + description: this._currentWorkspaceAutoAddSetting === 'disabled' ? 'current' : undefined, + option: 'disabled', + }, + ]; + + const newWorkspaceAutoAddOption = await window.showQuickPick(autoAddOptions, { + placeHolder: + 'Choose the behavior of automatically adding missing repositories to the current VS Code workspace', + title: 'Linked Workspace: Automatically Add Repositories', + }); + if (newWorkspaceAutoAddOption?.option == null) return defaultOption; + + const newWorkspaceAutoAddSetting = newWorkspaceAutoAddOption.option; + + if (options?.current && workspace.workspaceFile != null) { + const updated = await this._workspacesPathProvider.updateCodeWorkspaceFileSettings( + workspace.workspaceFile, + { + workspaceAutoAddSetting: newWorkspaceAutoAddSetting, + }, + ); + if (!updated) return this._currentWorkspaceAutoAddSetting; + this._currentWorkspaceAutoAddSetting = newWorkspaceAutoAddSetting; + } + + return newWorkspaceAutoAddSetting; + } + + @log() + async openCodeWorkspaceFile(workspaceId: string, options?: { location?: OpenWorkspaceLocation }): Promise { + const workspace = this.getCloudWorkspace(workspaceId) ?? this.getLocalWorkspace(workspaceId); + if (workspace == null) return; + if (workspace.localPath == null) { + const create = await window.showInformationMessage( + `The workspace file for ${workspace.name} has not been created. Would you like to create it now?`, + { modal: true }, + { title: 'Create' }, + { title: 'Cancel', isCloseAffordance: true }, + ); + + if (create == null || create.title == 'Cancel') return; + return void this.saveAsCodeWorkspaceFile(workspaceId); + } + + let openLocation: OpenWorkspaceLocation = options?.location === 'currentWindow' ? 'currentWindow' : 'newWindow'; + if (!options?.location) { + const openLocationChoice = await window.showInformationMessage( + `How would you like to open the workspace file for ${workspace.name}?`, + { modal: true }, + { title: 'Open in New Window', location: 'newWindow' as const }, + { title: 'Open in Current Window', location: 'currentWindow' as const }, + { title: 'Cancel', isCloseAffordance: true }, + ); + + if (openLocationChoice == null || openLocationChoice.title == 'Cancel') return; + openLocation = openLocationChoice.location ?? 'newWindow'; + } + + if (!(await this._workspacesPathProvider.confirmCloudWorkspaceCodeWorkspaceFilePath(workspace.id))) { + await this._workspacesPathProvider.removeCloudWorkspaceCodeWorkspaceFilePath(workspace.id); + workspace.setLocalPath(undefined); + const locateChoice = await window.showInformationMessage( + `The workspace file for ${workspace.name} could not be found. Would you like to locate it now?`, + { modal: true }, + { title: 'Locate' }, + { title: 'Cancel', isCloseAffordance: true }, + ); + + if (locateChoice?.title !== 'Locate') return; + const newPath = ( + await window.showOpenDialog({ + defaultUri: Uri.file(workspace.localPath), + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { + 'Code Workspace': ['code-workspace'], + }, + title: 'Locate the workspace file', + }) + )?.[0]?.fsPath; + + if (newPath == null) return; + + await this._workspacesPathProvider.writeCloudWorkspaceCodeWorkspaceFilePathToMap(workspace.id, newPath); + workspace.setLocalPath(newPath); + } + + openWorkspace(Uri.file(workspace.localPath), { location: openLocation }); + } + + private async getMappedPathForCloudWorkspaceRepoDescriptor( + descriptor: CloudWorkspaceRepositoryDescriptor, + ): Promise { + let repoLocalPath = await this.getCloudWorkspaceRepoPath(descriptor.workspaceId, descriptor.id); + if (repoLocalPath == null) { + repoLocalPath = ( + await this.container.repositoryPathMapping.getLocalRepoPaths({ + remoteUrl: descriptor.url ?? undefined, + repoInfo: { + repoName: descriptor.name, + provider: descriptor.provider ?? undefined, + owner: descriptor.provider_organization_id, + }, + }) + )?.[0]; + } + + return repoLocalPath; + } +} + +function getRemoteDescriptor(remote: GitRemote): RemoteDescriptor | undefined { + if (remote.provider?.owner == null) return undefined; + const remoteRepoName = remote.provider.path.split('/').pop(); + if (remoteRepoName == null) return undefined; + return { + provider: remote.provider.id.toLowerCase(), + owner: remote.provider.owner.toLowerCase(), + repoName: remoteRepoName.toLowerCase(), + url: remote.provider.url({ type: RemoteResourceType.Repo }), + }; +} + +function getCurrentWorkspaceId(): string | undefined { + return workspace.getConfiguration('gitkraken')?.get('workspaceId'); +} + +export function scheduleAddMissingCurrentWorkspaceRepos(container: Container) { + const currentWorkspaceId = getCurrentWorkspaceId(); + if (currentWorkspaceId == null) return; + + setTimeout(() => container.workspaces.addMissingCurrentWorkspaceRepos(), 10000); +} + +// TODO: Add back in once we think through virtual repository support a bit more. +/* function encodeAuthority(scheme: string, metadata?: T): string { + return `${scheme}${metadata != null ? `+${encodeUtf8Hex(JSON.stringify(metadata))}` : ''}`; +} */ diff --git a/src/quickpicks/aiModelPicker.ts b/src/quickpicks/aiModelPicker.ts new file mode 100644 index 0000000000000..f75fad128ec2e --- /dev/null +++ b/src/quickpicks/aiModelPicker.ts @@ -0,0 +1,83 @@ +import type { Disposable, QuickInputButton, QuickPickItem } from 'vscode'; +import { QuickPickItemKind, ThemeIcon, window } from 'vscode'; +import type { AIModel } from '../ai/aiProviderService'; +import type { AIModels, AIProviders } from '../constants.ai'; +import { Commands } from '../constants.commands'; +import type { Container } from '../container'; +import { executeCommand } from '../system/vscode/command'; +import { getQuickPickIgnoreFocusOut } from '../system/vscode/utils'; + +export interface ModelQuickPickItem extends QuickPickItem { + model: AIModel; +} + +export async function showAIModelPicker( + container: Container, + current?: { provider: AIProviders; model: AIModels }, +): Promise { + const models = (await (await container.ai)?.getModels()) ?? []; + + const items: ModelQuickPickItem[] = []; + + let lastProvider: AIProviders | undefined; + for (const m of models) { + if (m.hidden) continue; + + if (lastProvider !== m.provider.id) { + lastProvider = m.provider.id; + items.push({ label: m.provider.name, kind: QuickPickItemKind.Separator } as unknown as ModelQuickPickItem); + } + + const picked = m.provider.id === current?.provider && m.id === current?.model; + + items.push({ + label: m.name, + iconPath: picked ? new ThemeIcon('check') : new ThemeIcon('blank'), + // description: ` ~${formatNumeric(m.maxTokens)} tokens`, + model: m, + picked: picked, + } satisfies ModelQuickPickItem); + } + + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + const disposables: Disposable[] = []; + + const ResetAIKeyButton: QuickInputButton = { + iconPath: new ThemeIcon('clear-all'), + tooltip: 'Reset AI Keys...', + }; + + try { + const pick = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpick.activeItems[0]); + } + }), + quickpick.onDidTriggerButton(e => { + if (e === ResetAIKeyButton) { + void executeCommand(Commands.ResetAIKey); + } + }), + ); + + quickpick.title = 'Choose AI Model'; + quickpick.placeholder = 'Select an AI model to use for experimental AI features'; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.buttons = [ResetAIKeyButton]; + quickpick.items = items; + + quickpick.show(); + }); + + return pick; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); + } +} diff --git a/src/quickpicks/branchPicker.ts b/src/quickpicks/branchPicker.ts new file mode 100644 index 0000000000000..1d6c1c055f1c1 --- /dev/null +++ b/src/quickpicks/branchPicker.ts @@ -0,0 +1,150 @@ +import type { Disposable, QuickPickItem } from 'vscode'; +import { window } from 'vscode'; +import { getBranches } from '../commands/quickCommand.steps'; +import type { GitBranch } from '../git/models/branch'; +import type { Repository } from '../git/models/repository'; +import { getQuickPickIgnoreFocusOut } from '../system/vscode/utils'; +import type { BranchQuickPickItem } from './items/gitCommands'; + +export async function showBranchPicker( + title: string | undefined, + placeholder?: string, + repository?: Repository, +): Promise { + if (repository == null) { + return undefined; + } + + const items: BranchQuickPickItem[] = await getBranches(repository, {}); + if (items.length === 0) return undefined; + + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + const disposables: Disposable[] = []; + + try { + const pick = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpick.activeItems[0]); + } + }), + ); + + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.items = items; + + quickpick.show(); + }); + + return pick?.item; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); + } +} + +export async function showNewBranchPicker( + title: string | undefined, + placeholder?: string, + _repository?: Repository, +): Promise { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let newBranchName: string | undefined; + try { + newBranchName = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (value == null) { + input.validationMessage = 'Please enter a valid branch name'; + return; + } + + resolve(value); + }), + ); + + input.title = title; + input.placeholder = placeholder; + input.prompt = 'Enter a name for the new branch'; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + return newBranchName; +} + +export async function showNewOrSelectBranchPicker( + title: string | undefined, + repository?: Repository, +): Promise { + if (repository == null) { + return undefined; + } + + // TODO: needs updating + const createNewBranch = { + label: 'Create new branch', + description: + 'Creates a branch to apply the Cloud Patch to. (Typing an existing branch name will use that branch.)', + }; + const selectExistingBranch = { + label: 'Select existing branch', + description: 'Selects an existing branch to apply the Cloud Patch to.', + }; + + const items: QuickPickItem[] = [createNewBranch, selectExistingBranch]; + + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + const disposables: Disposable[] = []; + + try { + const pick = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpick.activeItems[0]); + } + }), + ); + + quickpick.title = title; + quickpick.placeholder = 'Select a branch option'; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.items = items; + + quickpick.show(); + }); + + if (pick === createNewBranch) { + return await showNewBranchPicker(title, 'Enter a name for the new branch', repository); + } else if (pick === selectExistingBranch) { + return await showBranchPicker(title, 'Select an existing branch', repository); + } + + return undefined; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); + } +} diff --git a/src/quickpicks/commitPicker.ts b/src/quickpicks/commitPicker.ts index 900bd7358d2a9..0ccac0a16e386 100644 --- a/src/quickpicks/commitPicker.ts +++ b/src/quickpicks/commitPicker.ts @@ -1,313 +1,358 @@ import type { Disposable } from 'vscode'; import { window } from 'vscode'; -import { configuration } from '../configuration'; +import type { Keys } from '../constants'; import { Container } from '../container'; import type { GitCommit, GitStashCommit } from '../git/models/commit'; import type { GitLog } from '../git/models/log'; import type { GitStash } from '../git/models/stash'; -import type { KeyboardScope, Keys } from '../keyboard'; import { filter, map } from '../system/iterable'; import { isPromise } from '../system/promise'; -import { getQuickPickIgnoreFocusOut } from '../system/utils'; +import { configuration } from '../system/vscode/configuration'; +import type { KeyboardScope } from '../system/vscode/keyboard'; +import { getQuickPickIgnoreFocusOut } from '../system/vscode/utils'; import { CommandQuickPickItem } from './items/common'; import type { DirectiveQuickPickItem } from './items/directive'; import { createDirectiveQuickPickItem, Directive, isDirectiveQuickPickItem } from './items/directive'; import type { CommitQuickPickItem } from './items/gitCommands'; -import { createCommitQuickPickItem } from './items/gitCommands'; - -export namespace CommitPicker { - export async function show( - log: GitLog | undefined | Promise, - title: string, - placeholder: string, - options?: { - picked?: string; - keys?: Keys[]; - onDidPressKey?(key: Keys, item: CommitQuickPickItem): void | Promise; - showOtherReferences?: CommandQuickPickItem[]; - }, - ): Promise { - const quickpick = window.createQuickPick(); - quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); - - quickpick.title = title; - quickpick.placeholder = placeholder; - quickpick.matchOnDescription = true; - quickpick.matchOnDetail = true; - - if (isPromise(log)) { - quickpick.busy = true; - quickpick.show(); +import { createCommitQuickPickItem, createStashQuickPickItem } from './items/gitCommands'; + +type Item = CommandQuickPickItem | CommitQuickPickItem | DirectiveQuickPickItem; + +export async function showCommitPicker( + log: GitLog | undefined | Promise, + title: string, + placeholder: string, + options?: { + empty?: { + getState?: () => + | { items: Item[]; placeholder?: string; title?: string } + | Promise<{ items: Item[]; placeholder?: string; title?: string }>; + }; + picked?: string; + keyboard?: { + keys: Keys[]; + onDidPressKey(key: Keys, item: CommitQuickPickItem): void | Promise; + }; + showOtherReferences?: CommandQuickPickItem[]; + }, +): Promise { + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + + if (isPromise(log)) { + quickpick.busy = true; + quickpick.show(); + + log = await log; + } - log = await log; + if (log == null) { + quickpick.placeholder = 'No commits found'; - if (log == null) { - quickpick.placeholder = 'Unable to show commit history'; + if (options?.empty?.getState != null) { + const empty = await options.empty.getState(); + quickpick.items = empty.items; + if (empty.placeholder != null) { + quickpick.placeholder = empty.placeholder; + } + if (empty.title != null) { + quickpick.title = empty.title; } + } else { + quickpick.items = [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })]; } + } else { + quickpick.items = await getItems(log); + } + + if (options?.picked) { + quickpick.activeItems = quickpick.items.filter(i => (CommandQuickPickItem.is(i) ? false : i.picked)); + } - quickpick.items = getItems(log); + async function getItems(log: GitLog) { + const items = []; + if (options?.showOtherReferences != null) { + items.push(...options.showOtherReferences); + } - if (options?.picked) { - quickpick.activeItems = quickpick.items.filter(i => (CommandQuickPickItem.is(i) ? false : i.picked)); + for await (const item of map(log.commits.values(), async commit => + createCommitQuickPickItem(commit, options?.picked === commit.ref, { compact: true, icon: 'avatar' }), + )) { + items.push(item); } - function getItems(log: GitLog | undefined) { - return log == null - ? [createDirectiveQuickPickItem(Directive.Cancel)] - : [ - ...(options?.showOtherReferences ?? []), - ...map(log.commits.values(), commit => - createCommitQuickPickItem(commit, options?.picked === commit.ref, { - compact: true, - icon: true, - }), - ), - ...(log?.hasMore ? [createDirectiveQuickPickItem(Directive.LoadMore)] : []), - ]; + if (log.hasMore) { + items.push(createDirectiveQuickPickItem(Directive.LoadMore)); } - async function loadMore() { - quickpick.busy = true; + return items; + } - try { - log = await (await log)?.more?.(configuration.get('advanced.maxListItems')); - const items = getItems(log); + async function loadMore() { + quickpick.ignoreFocusOut = true; + quickpick.busy = true; - let activeIndex = -1; - if (quickpick.activeItems.length !== 0) { - const active = quickpick.activeItems[0]; - activeIndex = quickpick.items.indexOf(active); + try { + log = await (await log)?.more?.(configuration.get('advanced.maxListItems')); - // If the active item is the "Load more" directive, then select the previous item - if (isDirectiveQuickPickItem(active)) { - activeIndex--; + let items; + if (log == null) { + if (options?.empty?.getState != null) { + const empty = await options.empty.getState(); + items = empty.items; + if (empty.placeholder != null) { + quickpick.placeholder = empty.placeholder; + } + if (empty.title != null) { + quickpick.title = empty.title; } + } else { + items = [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })]; } + } else { + items = await getItems(log); + } - quickpick.items = items; + let activeIndex = -1; + if (quickpick.activeItems.length !== 0) { + const active = quickpick.activeItems[0]; + activeIndex = quickpick.items.indexOf(active); - if (activeIndex) { - quickpick.activeItems = [quickpick.items[activeIndex]]; + // If the active item is the "Load more" directive, then select the previous item + if (isDirectiveQuickPickItem(active)) { + activeIndex--; } - } finally { - quickpick.busy = false; } - } - const disposables: Disposable[] = []; - - let scope: KeyboardScope | undefined; - if (options?.keys != null && options.keys.length !== 0 && options?.onDidPressKey !== null) { - scope = Container.instance.keyboard.createScope( - Object.fromEntries( - options.keys.map(key => [ - key, - { - onDidPressKey: key => { - if (quickpick.activeItems.length !== 0) { - const [item] = quickpick.activeItems; - if ( - item != null && - !isDirectiveQuickPickItem(item) && - !CommandQuickPickItem.is(item) - ) { - void options.onDidPressKey!(key, item); - } - } - }, - }, - ]), - ), - ); - void scope.start(); - disposables.push(scope); + quickpick.items = items; + + if (activeIndex) { + quickpick.activeItems = [quickpick.items[activeIndex]]; + } + } finally { + quickpick.busy = false; } + } - try { - const pick = await new Promise< - CommandQuickPickItem | CommitQuickPickItem | DirectiveQuickPickItem | undefined - >(resolve => { - disposables.push( - quickpick.onDidHide(() => resolve(undefined)), - quickpick.onDidAccept(() => { - if (quickpick.activeItems.length !== 0) { - const [item] = quickpick.activeItems; - if (isDirectiveQuickPickItem(item)) { - switch (item.directive) { - case Directive.LoadMore: - void loadMore(); - return; - - default: - resolve(undefined); - return; + const disposables: Disposable[] = []; + + let scope: KeyboardScope | undefined; + if (options?.keyboard != null) { + const { keyboard } = options; + scope = Container.instance.keyboard.createScope( + Object.fromEntries( + keyboard.keys.map(key => [ + key, + { + onDidPressKey: async key => { + if (quickpick.activeItems.length !== 0) { + const [item] = quickpick.activeItems; + if (item != null && !isDirectiveQuickPickItem(item) && !CommandQuickPickItem.is(item)) { + const ignoreFocusOut = quickpick.ignoreFocusOut; + quickpick.ignoreFocusOut = true; + + await keyboard.onDidPressKey(key, item); + + quickpick.ignoreFocusOut = ignoreFocusOut; } } + }, + }, + ]), + ), + ); + void scope.start(); + disposables.push(scope); + } - resolve(item); - } - }), - quickpick.onDidChangeValue(async e => { - if (scope == null) return; - - // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly - if (e.length !== 0) { - await scope.pause(['left', 'right']); - } else { - await scope.resume(); + try { + const pick = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + const [item] = quickpick.activeItems; + if (isDirectiveQuickPickItem(item)) { + switch (item.directive) { + case Directive.LoadMore: + void loadMore(); + return; + + default: + resolve(undefined); + return; + } } - }), - ); - quickpick.busy = false; + resolve(item); + } + }), + quickpick.onDidChangeValue(value => { + if (scope == null) return; + + // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly + if (value.length !== 0) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } else { + void scope.resume(); + } + }), + ); - quickpick.show(); - }); - if (pick == null || isDirectiveQuickPickItem(pick)) return undefined; + quickpick.busy = false; - if (pick instanceof CommandQuickPickItem) { - void (await pick.execute()); + quickpick.show(); + }); + if (pick == null || isDirectiveQuickPickItem(pick)) return undefined; - return undefined; - } + if (pick instanceof CommandQuickPickItem) { + void (await pick.execute()); - return pick.item; - } finally { - quickpick.dispose(); - disposables.forEach(d => void d.dispose()); + return undefined; } + + return pick.item; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); } } -export namespace StashPicker { - export async function show( - stash: GitStash | undefined | Promise, - title: string, - placeholder: string, - options?: { - empty?: string; - filter?: (c: GitStashCommit) => boolean; - keys?: Keys[]; - onDidPressKey?(key: Keys, item: CommitQuickPickItem): void | Promise; - picked?: string; - showOtherReferences?: CommandQuickPickItem[]; - }, - ): Promise { - const quickpick = window.createQuickPick< - CommandQuickPickItem | CommitQuickPickItem | DirectiveQuickPickItem - >(); - quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); - - quickpick.title = title; - quickpick.placeholder = placeholder; - quickpick.matchOnDescription = true; - quickpick.matchOnDetail = true; - - if (isPromise(stash)) { - quickpick.busy = true; - quickpick.show(); - - stash = await stash; - } +export async function showStashPicker( + stash: GitStash | undefined | Promise, + title: string, + placeholder: string, + options?: { + empty?: string; + filter?: (c: GitStashCommit) => boolean; + keyboard?: { + keys: Keys[]; + onDidPressKey(key: Keys, item: CommitQuickPickItem): void | Promise; + }; + picked?: string; + showOtherReferences?: CommandQuickPickItem[]; + }, +): Promise { + const quickpick = window.createQuickPick< + CommandQuickPickItem | CommitQuickPickItem | DirectiveQuickPickItem + >(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + + if (isPromise(stash)) { + quickpick.busy = true; + quickpick.show(); + + stash = await stash; + } - if (stash != null) { - quickpick.items = [ - ...(options?.showOtherReferences ?? []), - ...map( - options?.filter != null ? filter(stash.commits.values(), options.filter) : stash.commits.values(), - commit => - createCommitQuickPickItem(commit, options?.picked === commit.ref, { - compact: true, - icon: true, - }), - ), - ]; - } + if (stash != null) { + quickpick.items = [ + ...(options?.showOtherReferences ?? []), + ...map( + options?.filter != null ? filter(stash.commits.values(), options.filter) : stash.commits.values(), + commit => + createStashQuickPickItem(commit, options?.picked === commit.ref, { + compact: true, + icon: true, + }), + ), + ]; + } - if (stash == null || quickpick.items.length <= (options?.showOtherReferences?.length ?? 0)) { - quickpick.placeholder = stash == null ? 'No stashes found' : options?.empty ?? `No matching stashes found`; - quickpick.items = [createDirectiveQuickPickItem(Directive.Cancel)]; - } + if (stash == null || quickpick.items.length <= (options?.showOtherReferences?.length ?? 0)) { + quickpick.placeholder = stash == null ? 'No stashes found' : options?.empty ?? `No matching stashes found`; + quickpick.items = [createDirectiveQuickPickItem(Directive.Cancel)]; + } - if (options?.picked) { - quickpick.activeItems = quickpick.items.filter(i => (CommandQuickPickItem.is(i) ? false : i.picked)); - } + if (options?.picked) { + quickpick.activeItems = quickpick.items.filter(i => (CommandQuickPickItem.is(i) ? false : i.picked)); + } - const disposables: Disposable[] = []; - - let scope: KeyboardScope | undefined; - if (options?.keys != null && options.keys.length !== 0 && options?.onDidPressKey !== null) { - scope = Container.instance.keyboard.createScope( - Object.fromEntries( - options.keys.map(key => [ - key, - { - onDidPressKey: key => { - if (quickpick.activeItems.length !== 0) { - const [item] = quickpick.activeItems; - if ( - item != null && - !isDirectiveQuickPickItem(item) && - !CommandQuickPickItem.is(item) - ) { - void options.onDidPressKey!(key, item); - } + const disposables: Disposable[] = []; + + let scope: KeyboardScope | undefined; + if (options?.keyboard != null) { + const { keyboard } = options; + scope = Container.instance.keyboard.createScope( + Object.fromEntries( + keyboard.keys.map(key => [ + key, + { + onDidPressKey: async key => { + if (quickpick.activeItems.length !== 0) { + const [item] = quickpick.activeItems; + if (item != null && !isDirectiveQuickPickItem(item) && !CommandQuickPickItem.is(item)) { + const ignoreFocusOut = quickpick.ignoreFocusOut; + quickpick.ignoreFocusOut = true; + + await keyboard.onDidPressKey(key, item); + + quickpick.ignoreFocusOut = ignoreFocusOut; } - }, - }, - ]), - ), - ); - void scope.start(); - disposables.push(scope); - } - - try { - const pick = await new Promise< - CommandQuickPickItem | CommitQuickPickItem | DirectiveQuickPickItem | undefined - >(resolve => { - disposables.push( - quickpick.onDidHide(() => resolve(undefined)), - quickpick.onDidAccept(() => { - if (quickpick.activeItems.length !== 0) { - const [item] = quickpick.activeItems; - if (isDirectiveQuickPickItem(item)) { - resolve(undefined); - return; } + }, + }, + ]), + ), + ); + void scope.start(); + disposables.push(scope); + } - resolve(item); - } - }), - quickpick.onDidChangeValue(async e => { - if (scope == null) return; - - // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly - if (e.length !== 0) { - await scope.pause(['left', 'right']); - } else { - await scope.resume(); + try { + const pick = await new Promise< + CommandQuickPickItem | CommitQuickPickItem | DirectiveQuickPickItem | undefined + >(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + const [item] = quickpick.activeItems; + if (isDirectiveQuickPickItem(item)) { + resolve(undefined); + return; } - }), - ); - quickpick.busy = false; + resolve(item); + } + }), + quickpick.onDidChangeValue(value => { + if (scope == null) return; + + // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly + if (value.length !== 0) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } else { + void scope.resume(); + } + }), + ); - quickpick.show(); - }); - if (pick == null || isDirectiveQuickPickItem(pick)) return undefined; + quickpick.busy = false; - if (pick instanceof CommandQuickPickItem) { - void (await pick.execute()); + quickpick.show(); + }); + if (pick == null || isDirectiveQuickPickItem(pick)) return undefined; - return undefined; - } + if (pick instanceof CommandQuickPickItem) { + void (await pick.execute()); - return pick.item; - } finally { - quickpick.dispose(); - disposables.forEach(d => void d.dispose()); + return undefined; } + + return pick.item; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); } } diff --git a/src/quickpicks/contributorsPicker.ts b/src/quickpicks/contributorsPicker.ts new file mode 100644 index 0000000000000..94c22241ccc65 --- /dev/null +++ b/src/quickpicks/contributorsPicker.ts @@ -0,0 +1,113 @@ +import type { Disposable } from 'vscode'; +import { window } from 'vscode'; +import { ClearQuickInputButton } from '../commands/quickCommand.buttons'; +import { GlyphChars, quickPickTitleMaxChars } from '../constants'; +import type { Container } from '../container'; +import type { ContributorQuickPickItem, GitContributor } from '../git/models/contributor'; +import { createContributorQuickPickItem, sortContributors } from '../git/models/contributor'; +import type { Repository } from '../git/models/repository'; +import { debounce } from '../system/function'; +import { defer } from '../system/promise'; +import { pad, truncate } from '../system/string'; +import { getQuickPickIgnoreFocusOut } from '../system/vscode/utils'; + +export async function showContributorsPicker( + container: Container, + repository: Repository, + title: string, + placeholder: string, + options?: { + appendReposToTitle?: boolean; + clearButton?: boolean; + ignoreFocusOut?: boolean; + multiselect?: boolean; + picked?: (contributor: GitContributor) => boolean; + }, +): Promise { + const deferred = defer(); + const disposables: Disposable[] = []; + + try { + const quickpick = window.createQuickPick(); + disposables.push( + quickpick, + quickpick.onDidHide(() => deferred.fulfill(undefined)), + quickpick.onDidAccept(() => + !quickpick.busy ? deferred.fulfill(quickpick.selectedItems.map(c => c.item)) : undefined, + ), + quickpick.onDidChangeSelection( + debounce(e => { + if (!quickpick.canSelectMany || quickpick.busy) return; + + let update = false; + for (const item of quickpick.items) { + const picked = e.includes(item); + if (item.picked !== picked || item.alwaysShow !== picked) { + item.alwaysShow = item.picked = picked; + update = true; + } + } + + if (update) { + quickpick.items = sortContributors([...quickpick.items]); + quickpick.selectedItems = e; + } + }, 10), + ), + quickpick.onDidTriggerButton(e => { + if (e === ClearQuickInputButton) { + if (quickpick.canSelectMany) { + quickpick.selectedItems = []; + } else { + deferred.fulfill([]); + } + } + }), + ); + + quickpick.ignoreFocusOut = options?.ignoreFocusOut ?? getQuickPickIgnoreFocusOut(); + + quickpick.title = options?.appendReposToTitle ? appendRepoToTitle(container, title, repository) : title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.canSelectMany = options?.multiselect ?? true; + + quickpick.buttons = options?.clearButton ? [ClearQuickInputButton] : []; + + quickpick.busy = true; + quickpick.show(); + + const contributors = await repository.getContributors(); + if (!deferred.pending) return; + + const items = await Promise.all( + contributors.map(c => createContributorQuickPickItem(c, options?.picked?.(c) ?? false)), + ); + + if (!deferred.pending) return; + + quickpick.items = sortContributors(items); + if (quickpick.canSelectMany) { + quickpick.selectedItems = items.filter(i => i.picked); + } else { + quickpick.activeItems = items.filter(i => i.picked); + } + + quickpick.busy = false; + + const picks = await deferred.promise; + return picks; + } finally { + disposables.forEach(d => void d.dispose()); + } +} + +function appendRepoToTitle(container: Container, title: string, repo: Repository) { + return container.git.openRepositoryCount <= 1 + ? title + : `${title}${truncate( + `${pad(GlyphChars.Dot, 2, 2)}${repo.formattedName}`, + quickPickTitleMaxChars - title.length, + )}`; +} diff --git a/src/quickpicks/items/commits.ts b/src/quickpicks/items/commits.ts index 642168c7554d9..69b15a23229a7 100644 --- a/src/quickpicks/items/commits.ts +++ b/src/quickpicks/items/commits.ts @@ -1,19 +1,21 @@ -import type { QuickPickItem } from 'vscode'; -import { window } from 'vscode'; +import { ThemeIcon, window } from 'vscode'; import type { OpenChangedFilesCommandArgs } from '../../commands/openChangedFiles'; -import { QuickCommandButtons } from '../../commands/quickCommand.buttons'; -import { Commands, GlyphChars } from '../../constants'; +import type { OpenOnlyChangedFilesCommandArgs } from '../../commands/openOnlyChangedFiles'; +import { RevealInSideBarQuickInputButton, ShowDetailsViewQuickInputButton } from '../../commands/quickCommand.buttons'; +import type { Keys } from '../../constants'; +import { GlyphChars } from '../../constants'; +import { Commands } from '../../constants.commands'; import { Container } from '../../container'; import { browseAtRevision } from '../../git/actions'; import * as CommitActions from '../../git/actions/commit'; import { CommitFormatter } from '../../git/formatters/commitFormatter'; import type { GitCommit } from '../../git/models/commit'; -import type { GitFileChange } from '../../git/models/file'; -import { GitFile } from '../../git/models/file'; +import type { GitFile, GitFileChange } from '../../git/models/file'; +import { getGitFileFormattedDirectory, getGitFileStatusThemeIcon } from '../../git/models/file'; import type { GitStatusFile } from '../../git/models/status'; -import type { Keys } from '../../keyboard'; import { basename } from '../../system/path'; import { pad } from '../../system/string'; +import type { CompareResultsNode } from '../../views/nodes/compareResultsNode'; import { CommandQuickPickItem } from './common'; export class CommitFilesQuickPickItem extends CommandQuickPickItem { @@ -47,10 +49,11 @@ export class CommitFilesQuickPickItem extends CommandQuickPickItem { }${options?.hint != null ? `${pad(GlyphChars.Dash, 4, 2, GlyphChars.Space)}${options.hint}` : ''}`, alwaysShow: true, picked: options?.picked ?? true, - buttons: [QuickCommandButtons.ShowDetailsView, QuickCommandButtons.RevealInSideBar], + buttons: [ShowDetailsViewQuickInputButton, RevealInSideBarQuickInputButton], }, undefined, undefined, + undefined, { suppressKeyPress: true }, ); } @@ -61,11 +64,16 @@ export class CommitFilesQuickPickItem extends CommandQuickPickItem { } export class CommitFileQuickPickItem extends CommandQuickPickItem { - constructor(readonly commit: GitCommit, readonly file: GitFile, picked?: boolean) { + constructor( + readonly commit: GitCommit, + readonly file: GitFile, + picked?: boolean, + ) { super({ - label: `${pad(GitFile.getStatusCodicon(file.status), 0, 2)}${basename(file.path)}`, - description: GitFile.getFormattedDirectory(file, true), + label: basename(file.path), + description: getGitFileFormattedDirectory(file, true), picked: picked, + iconPath: getGitFileStatusThemeIcon(file.status), }); // TODO@eamodio - add line diff details @@ -104,14 +112,13 @@ export class CommitBrowseRepositoryFromHereCommandQuickPickItem extends CommandQ before?: boolean; openInNewWindow: boolean; }, - item?: QuickPickItem, ) { super( - item ?? - `$(folder-opened) Browse Repository from${executeOptions?.before ? ' Before' : ''} Here${ - executeOptions?.openInNewWindow ? ' in New Window' : '' - }`, + `Browse Repository from${executeOptions?.before ? ' Before' : ''} Here${ + executeOptions?.openInNewWindow ? ' in New Window' : '' + }`, ); + this.iconPath = new ThemeIcon('folder-opened'); } override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -123,28 +130,29 @@ export class CommitBrowseRepositoryFromHereCommandQuickPickItem extends CommandQ } export class CommitCompareWithHEADCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(compare-changes) Compare with HEAD'); + constructor(private readonly commit: GitCommit) { + super('Compare with HEAD'); + this.iconPath = new ThemeIcon('compare-changes'); } - override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { + override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { return Container.instance.searchAndCompareView.compare(this.commit.repoPath, this.commit.ref, 'HEAD'); } } export class CommitCompareWithWorkingCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(compare-changes) Compare with Working Tree'); + constructor(private readonly commit: GitCommit) { + super('Compare with Working Tree', new ThemeIcon('compare-changes')); } - override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { + override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { return Container.instance.searchAndCompareView.compare(this.commit.repoPath, this.commit.ref, ''); } } export class CommitCopyIdQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(copy) Copy SHA'); + constructor(private readonly commit: GitCommit) { + super('Copy SHA', new ThemeIcon('copy')); } override execute(): Promise { @@ -158,8 +166,8 @@ export class CommitCopyIdQuickPickItem extends CommandQuickPickItem { } export class CommitCopyMessageQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(copy) Copy Message'); + constructor(private readonly commit: GitCommit) { + super('Copy Message', new ThemeIcon('copy')); } override execute(): Promise { @@ -175,8 +183,8 @@ export class CommitCopyMessageQuickPickItem extends CommandQuickPickItem { } export class CommitOpenAllChangesCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(git-compare) Open All Changes'); + constructor(private readonly commit: GitCommit) { + super('Open All Changes', new ThemeIcon('git-compare')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -185,8 +193,8 @@ export class CommitOpenAllChangesCommandQuickPickItem extends CommandQuickPickIt } export class CommitOpenAllChangesWithDiffToolCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(git-compare) Open All Changes (difftool)'); + constructor(private readonly commit: GitCommit) { + super('Open All Changes (difftool)', new ThemeIcon('git-compare')); } override execute(): Promise { @@ -195,8 +203,8 @@ export class CommitOpenAllChangesWithDiffToolCommandQuickPickItem extends Comman } export class CommitOpenAllChangesWithWorkingCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(git-compare) Open All Changes with Working Tree'); + constructor(private readonly commit: GitCommit) { + super('Open All Changes with Working Tree', new ThemeIcon('git-compare')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -205,8 +213,11 @@ export class CommitOpenAllChangesWithWorkingCommandQuickPickItem extends Command } export class CommitOpenChangesCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, private readonly file: string | GitFile, item?: QuickPickItem) { - super(item ?? '$(git-compare) Open Changes'); + constructor( + private readonly commit: GitCommit, + private readonly file: string | GitFile, + ) { + super('Open Changes', new ThemeIcon('git-compare')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -215,8 +226,11 @@ export class CommitOpenChangesCommandQuickPickItem extends CommandQuickPickItem } export class CommitOpenChangesWithDiffToolCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, private readonly file: string | GitFile, item?: QuickPickItem) { - super(item ?? '$(git-compare) Open Changes (difftool)'); + constructor( + private readonly commit: GitCommit, + private readonly file: string | GitFile, + ) { + super('Open Changes (difftool)', new ThemeIcon('git-compare')); } override execute(): Promise { @@ -225,8 +239,11 @@ export class CommitOpenChangesWithDiffToolCommandQuickPickItem extends CommandQu } export class CommitOpenChangesWithWorkingCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, private readonly file: string | GitFile, item?: QuickPickItem) { - super(item ?? '$(git-compare) Open Changes with Working File'); + constructor( + private readonly commit: GitCommit, + private readonly file: string | GitFile, + ) { + super('Open Changes with Working File', new ThemeIcon('git-compare')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -235,8 +252,8 @@ export class CommitOpenChangesWithWorkingCommandQuickPickItem extends CommandQui } export class CommitOpenDirectoryCompareCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(git-compare) Open Directory Compare'); + constructor(private readonly commit: GitCommit) { + super('Open Directory Compare', new ThemeIcon('git-compare')); } override execute(): Promise { @@ -245,8 +262,8 @@ export class CommitOpenDirectoryCompareCommandQuickPickItem extends CommandQuick } export class CommitOpenDirectoryCompareWithWorkingCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(git-compare) Open Directory Compare with Working Tree'); + constructor(private readonly commit: GitCommit) { + super('Open Directory Compare with Working Tree', new ThemeIcon('git-compare')); } override execute(): Promise { @@ -255,8 +272,8 @@ export class CommitOpenDirectoryCompareWithWorkingCommandQuickPickItem extends C } export class CommitOpenDetailsCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(eye) Open Details'); + constructor(private readonly commit: GitCommit) { + super('Inspect Commit Details', new ThemeIcon('eye')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -265,8 +282,8 @@ export class CommitOpenDetailsCommandQuickPickItem extends CommandQuickPickItem } export class CommitOpenInGraphCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(gitlens-graph) Open in Commit Graph'); + constructor(private readonly commit: GitCommit) { + super('Open in Commit Graph', new ThemeIcon('gitlens-graph')); } override execute(options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -275,8 +292,8 @@ export class CommitOpenInGraphCommandQuickPickItem extends CommandQuickPickItem } export class CommitOpenFilesCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(files) Open Files'); + constructor(private readonly commit: GitCommit) { + super('Open Files', new ThemeIcon('files')); } override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -285,8 +302,11 @@ export class CommitOpenFilesCommandQuickPickItem extends CommandQuickPickItem { } export class CommitOpenFileCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, private readonly file: string | GitFile, item?: QuickPickItem) { - super(item ?? '$(file) Open File'); + constructor( + private readonly commit: GitCommit, + private readonly file: string | GitFile, + ) { + super('Open File', new ThemeIcon('file')); } override execute(options?: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -295,8 +315,8 @@ export class CommitOpenFileCommandQuickPickItem extends CommandQuickPickItem { } export class CommitOpenRevisionsCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, item?: QuickPickItem) { - super(item ?? '$(files) Open Files at Revision'); + constructor(private readonly commit: GitCommit) { + super('Open Files at Revision', new ThemeIcon('files')); } override execute(_options: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -305,8 +325,11 @@ export class CommitOpenRevisionsCommandQuickPickItem extends CommandQuickPickIte } export class CommitOpenRevisionCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, private readonly file: string | GitFile, item?: QuickPickItem) { - super(item ?? '$(file) Open File at Revision'); + constructor( + private readonly commit: GitCommit, + private readonly file: string | GitFile, + ) { + super('Open File at Revision', new ThemeIcon('file')); } override execute(options?: { preserveFocus?: boolean; preview?: boolean }): Promise { @@ -315,8 +338,11 @@ export class CommitOpenRevisionCommandQuickPickItem extends CommandQuickPickItem } export class CommitApplyFileChangesCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, private readonly file: string | GitFile, item?: QuickPickItem) { - super(item ?? 'Apply Changes'); + constructor( + private readonly commit: GitCommit, + private readonly file: string | GitFile, + ) { + super('Apply Changes'); } override async execute(): Promise { @@ -325,13 +351,14 @@ export class CommitApplyFileChangesCommandQuickPickItem extends CommandQuickPick } export class CommitRestoreFileChangesCommandQuickPickItem extends CommandQuickPickItem { - constructor(private readonly commit: GitCommit, private readonly file: string | GitFile, item?: QuickPickItem) { - super( - item ?? { - label: 'Restore', - description: 'aka checkout', - }, - ); + constructor( + private readonly commit: GitCommit, + private readonly file: string | GitFile, + ) { + super({ + label: 'Restore', + description: 'aka checkout', + }); } override execute(): Promise { @@ -340,11 +367,23 @@ export class CommitRestoreFileChangesCommandQuickPickItem extends CommandQuickPi } export class OpenChangedFilesCommandQuickPickItem extends CommandQuickPickItem { - constructor(files: GitStatusFile[], item?: QuickPickItem) { + constructor(files: GitStatusFile[], label?: string) { const commandArgs: OpenChangedFilesCommandArgs = { uris: files.map(f => f.uri), }; - super(item ?? '$(files) Open All Changed Files', Commands.OpenChangedFiles, [commandArgs]); + super(label ?? 'Open All Changed Files', new ThemeIcon('files'), Commands.OpenChangedFiles, [commandArgs]); + } +} + +export class OpenOnlyChangedFilesCommandQuickPickItem extends CommandQuickPickItem { + constructor(files: GitStatusFile[], label?: string) { + const commandArgs: OpenOnlyChangedFilesCommandArgs = { + uris: files.map(f => f.uri), + }; + + super(label ?? 'Open Changed & Close Unchanged Files', new ThemeIcon('files'), Commands.OpenOnlyChangedFiles, [ + commandArgs, + ]); } } diff --git a/src/quickpicks/items/common.ts b/src/quickpicks/items/common.ts index 6efb97a374a90..a30600608d11a 100644 --- a/src/quickpicks/items/common.ts +++ b/src/quickpicks/items/common.ts @@ -1,7 +1,7 @@ -import type { QuickPickItem } from 'vscode'; +import type { QuickPickItem, ThemeIcon, Uri } from 'vscode'; import { commands, QuickPickItemKind } from 'vscode'; -import type { Commands } from '../../constants'; -import type { Keys } from '../../keyboard'; +import type { Keys } from '../../constants'; +import type { Commands } from '../../constants.commands'; declare module 'vscode' { interface QuickPickItem { @@ -14,22 +14,27 @@ export interface QuickPickSeparator extends QuickPickItem { kind: QuickPickItemKind.Separator; } -export function createQuickPickSeparator(label?: string): QuickPickSeparator { - return { kind: QuickPickItemKind.Separator, label: label ?? '' }; +export function createQuickPickSeparator(label?: string): T { + return { kind: QuickPickItemKind.Separator, label: label ?? '' } as unknown as T; } export interface QuickPickItemOfT extends QuickPickItem { readonly item: T; } +export function createQuickPickItemOfT(labelOrItem: string | QuickPickItem, item: T): QuickPickItemOfT { + return typeof labelOrItem === 'string' ? { label: labelOrItem, item: item } : { ...labelOrItem, item: item }; +} + export class CommandQuickPickItem implements QuickPickItem { static fromCommand(label: string, command: Commands, args?: T): CommandQuickPickItem; static fromCommand(item: QuickPickItem, command: Commands, args?: T): CommandQuickPickItem; static fromCommand(labelOrItem: string | QuickPickItem, command: Commands, args?: T): CommandQuickPickItem { return new CommandQuickPickItem( typeof labelOrItem === 'string' ? { label: labelOrItem } : labelOrItem, + undefined, command, - args == null ? [] : [args], + args == null ? [] : Array.isArray(args) ? args : [args], ); } @@ -40,9 +45,11 @@ export class CommandQuickPickItem implements Qu label!: string; description?: string; detail?: string | undefined; + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon | undefined; constructor( label: string, + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon | undefined, command?: Commands, args?: Arguments, options?: { @@ -52,6 +59,7 @@ export class CommandQuickPickItem implements Qu ); constructor( item: QuickPickItem, + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon | undefined, command?: Commands, args?: Arguments, options?: { @@ -61,6 +69,7 @@ export class CommandQuickPickItem implements Qu ); constructor( labelOrItem: string | QuickPickItem, + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon | undefined, command?: Commands, args?: Arguments, options?: { @@ -70,6 +79,7 @@ export class CommandQuickPickItem implements Qu ); constructor( labelOrItem: string | QuickPickItem, + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon | undefined, protected readonly command?: Commands, protected readonly args?: Arguments, protected readonly options?: { @@ -89,6 +99,10 @@ export class CommandQuickPickItem implements Qu } else { Object.assign(this, labelOrItem); } + + if (iconPath != null) { + this.iconPath = iconPath; + } } execute(_options?: { preserveFocus?: boolean; preview?: boolean }): Promise { diff --git a/src/quickpicks/items/directive.ts b/src/quickpicks/items/directive.ts index b3dd3f5eb223f..f1a2f34c90856 100644 --- a/src/quickpicks/items/directive.ts +++ b/src/quickpicks/items/directive.ts @@ -1,16 +1,17 @@ -import type { QuickPickItem } from 'vscode'; -import type { Subscription } from '../../subscription'; +import type { QuickPickItem, ThemeIcon, Uri } from 'vscode'; export enum Directive { Back, Cancel, LoadMore, Noop, + Reload, RequiresVerification, - ExtendTrial, + SignIn, + StartPreview, + StartProTrial, RequiresPaidSubscription, - StartPreviewTrial, } export function isDirective(value: Directive | T): value is Directive { @@ -19,15 +20,24 @@ export function isDirective(value: Directive | T): value is Directive { export interface DirectiveQuickPickItem extends QuickPickItem { directive: Directive; + onDidSelect?: () => void | Promise; } export function createDirectiveQuickPickItem( directive: Directive, picked?: boolean, - options?: { label?: string; description?: string; detail?: string; subscription?: Subscription }, + options?: { + label?: string; + description?: string; + detail?: string; + buttons?: QuickPickItem['buttons']; + iconPath?: Uri | { light: Uri; dark: Uri } | ThemeIcon; + onDidSelect?: () => void | Promise; + }, ) { let label = options?.label; let detail = options?.detail; + let description = options?.description; if (label == null) { switch (directive) { case Directive.Back: @@ -42,32 +52,45 @@ export function createDirectiveQuickPickItem( case Directive.Noop: label = 'Try again'; break; - case Directive.StartPreviewTrial: - label = 'Start a GitLens Pro Trial'; - detail = 'Try GitLens+ features on private repos, free for 3 days, without an account'; + case Directive.Reload: + label = 'Refresh'; break; - case Directive.ExtendTrial: - label = 'Extend Your GitLens Pro Trial'; - detail = 'To continue to use GitLens+ features on private repos, free for an additional 7-days'; + case Directive.SignIn: + label = 'Sign In'; + break; + case Directive.StartPreview: + label = 'Continue'; + detail = 'Continuing gives you 3 days to preview this and other local Pro features'; + break; + case Directive.StartProTrial: + label = 'Start Pro Trial'; + detail = 'Start your free 7-day Pro trial for full access to Pro features'; break; case Directive.RequiresVerification: - label = 'Resend Verification Email'; - detail = 'You must verify your email address before you can continue'; + label = 'Resend Email'; + detail = 'You must verify your email before you can continue'; break; case Directive.RequiresPaidSubscription: label = 'Upgrade to Pro'; - detail = 'To use GitLens+ features on private repos'; + if (detail != null) { + description ??= ' \u2014\u00a0\u00a0 a paid plan is required to use this Pro feature'; + } else { + detail = 'Upgrading to a paid plan is required to use this Pro feature'; + } break; } } const item: DirectiveQuickPickItem = { label: label, - description: options?.description, + description: description, detail: detail, + iconPath: options?.iconPath, + buttons: options?.buttons, alwaysShow: true, picked: picked, directive: directive, + onDidSelect: options?.onDidSelect, }; return item; diff --git a/src/quickpicks/items/flags.ts b/src/quickpicks/items/flags.ts index 4b3943e897cbc..e5c771b59ca3e 100644 --- a/src/quickpicks/items/flags.ts +++ b/src/quickpicks/items/flags.ts @@ -1,9 +1,7 @@ import type { QuickPickItem } from 'vscode'; import type { QuickPickItemOfT } from './common'; -export type FlagsQuickPickItem = Context extends void - ? QuickPickItemOfT - : QuickPickItemOfT & { context: Context }; +export type FlagsQuickPickItem = QuickPickItemOfT & { context: Context }; export function createFlagsQuickPickItem(flags: T[], item: T[], options: QuickPickItem): FlagsQuickPickItem; export function createFlagsQuickPickItem( @@ -18,7 +16,7 @@ export function createFlagsQuickPickItem( options: QuickPickItem, context?: Context, ): any { - return { ...options, item: item, picked: hasFlags(flags, item), context: context }; + return { ...options, item: item, picked: options.picked ?? hasFlags(flags, item), context: context }; } function hasFlags(flags: T[], has?: T | T[]): boolean { diff --git a/src/quickpicks/items/gitCommands.ts b/src/quickpicks/items/gitCommands.ts index 0fea8c8b81d57..aa9959419faf2 100644 --- a/src/quickpicks/items/gitCommands.ts +++ b/src/quickpicks/items/gitCommands.ts @@ -1,22 +1,25 @@ import type { QuickInputButton, QuickPickItem } from 'vscode'; +import { ThemeIcon } from 'vscode'; import type { GitCommandsCommandArgs } from '../../commands/gitCommands'; import { getSteps } from '../../commands/gitCommands.utils'; -import { Commands, GlyphChars } from '../../constants'; +import { GlyphChars } from '../../constants'; +import { Commands } from '../../constants.commands'; import { Container } from '../../container'; import { emojify } from '../../emojis'; import type { GitBranch } from '../../git/models/branch'; -import type { GitCommit } from '../../git/models/commit'; +import type { GitCommit, GitStashCommit } from '../../git/models/commit'; import { isStash } from '../../git/models/commit'; -import type { GitContributor } from '../../git/models/contributor'; -import { GitReference, GitRevision } from '../../git/models/reference'; +import type { GitReference } from '../../git/models/reference'; +import { createReference, isRevisionRange, shortenRevision } from '../../git/models/reference'; import type { GitRemote } from '../../git/models/remote'; -import { getRemoteUpstreamDescription, GitRemoteType } from '../../git/models/remote'; +import { getRemoteUpstreamDescription } from '../../git/models/remote'; import type { Repository } from '../../git/models/repository'; -import type { GitStatus } from '../../git/models/status'; import type { GitTag } from '../../git/models/tag'; -import type { GitWorktree } from '../../git/models/worktree'; +import { getBranchIconPath } from '../../git/utils/branch-utils'; +import { getWorktreeBranchIconPath } from '../../git/utils/worktree-utils'; import { fromNow } from '../../system/date'; import { pad } from '../../system/string'; +import { configuration } from '../../system/vscode/configuration'; import type { QuickPickItemOfT } from './common'; import { CommandQuickPickItem } from './common'; @@ -24,7 +27,7 @@ export class GitCommandQuickPickItem extends CommandQuickPickItem<[GitCommandsCo constructor(label: string, args: GitCommandsCommandArgs); constructor(item: QuickPickItem, args: GitCommandsCommandArgs); constructor(labelOrItem: string | QuickPickItem, args: GitCommandsCommandArgs) { - super(labelOrItem, Commands.GitCommands, [args], { suppressKeyPress: true }); + super(labelOrItem, undefined, Commands.GitCommands, [args], { suppressKeyPress: true }); } executeSteps(pickedVia: 'menu' | 'command') { @@ -49,9 +52,11 @@ export async function createBranchQuickPickItem( ref?: boolean; status?: boolean; type?: boolean | 'remote'; + worktree?: boolean; }, ): Promise { let description = ''; + if (options?.type === true) { if (options.current === true && branch.current) { description = 'current branch'; @@ -75,11 +80,11 @@ export async function createBranchQuickPickItem( let left; let right; for (const { type } of remote.urls) { - if (type === GitRemoteType.Fetch) { + if (type === 'fetch') { left = true; if (right) break; - } else if (type === GitRemoteType.Push) { + } else if (type === 'push') { right = true; if (left) break; @@ -101,14 +106,14 @@ export async function createBranchQuickPickItem( const status = `${branch.getTrackingStatus({ suffix: `${GlyphChars.Space} ` })}${arrows}${GlyphChars.Space} ${ branch.upstream.name }`; - description = `${description ? `${description}${GlyphChars.Space.repeat(2)}${status}` : status}`; + description = description ? `${description}${GlyphChars.Space.repeat(2)}${status}` : status; } if (options?.ref) { if (branch.sha) { description = description - ? `${description} $(git-commit)${GlyphChars.Space}${GitRevision.shorten(branch.sha)}` - : `$(git-commit)${GlyphChars.Space}${GitRevision.shorten(branch.sha)}`; + ? `${description} $(git-commit)${GlyphChars.Space}${shortenRevision(branch.sha)}` + : `$(git-commit)${GlyphChars.Space}${shortenRevision(branch.sha)}`; } if (branch.date !== undefined) { @@ -121,9 +126,7 @@ export async function createBranchQuickPickItem( const checked = options?.checked || (options?.checked == null && options?.current === 'checkmark' && branch.current); const item: BranchQuickPickItem = { - label: `$(git-branch)${GlyphChars.Space}${branch.starred ? `$(star-full)${GlyphChars.Space}` : ''}${ - branch.name - }${checked ? pad('$(check)', 2) : ''}`, + label: checked ? `${branch.name}${pad('$(check)', 2)}` : branch.name, description: description, alwaysShow: options?.alwaysShow, buttons: options?.buttons, @@ -132,6 +135,11 @@ export async function createBranchQuickPickItem( current: branch.current, ref: branch.name, remote: branch.remote, + iconPath: branch.starred + ? new ThemeIcon('star-full') + : options?.worktree + ? getWorktreeBranchIconPath(Container.instance, branch) + : getBranchIconPath(Container.instance, branch), }; return item; @@ -144,49 +152,34 @@ export class CommitLoadMoreQuickPickItem implements QuickPickItem { export type CommitQuickPickItem = QuickPickItemOfT; -export function createCommitQuickPickItem( +export async function createCommitQuickPickItem( commit: T, picked?: boolean, - options?: { alwaysShow?: boolean; buttons?: QuickInputButton[]; compact?: boolean; icon?: boolean }, + options?: { alwaysShow?: boolean; buttons?: QuickInputButton[]; compact?: boolean; icon?: boolean | 'avatar' }, ) { if (isStash(commit)) { - const number = commit.number == null ? '' : `${commit.number}: `; - - if (options?.compact) { - const item: CommitQuickPickItem = { - label: `${options.icon ? `$(archive)${GlyphChars.Space}` : ''}${number}${commit.summary}`, - description: `${commit.formattedDate}${pad(GlyphChars.Dot, 2, 2)}${commit.formatStats({ - compact: true, - })}`, - alwaysShow: options.alwaysShow, - buttons: options.buttons, - picked: picked, - item: commit, - }; - - return item; - } + return createStashQuickPickItem(commit, picked, { + ...options, + icon: options?.icon === 'avatar' ? true : options?.icon, + }); + } - const item: CommitQuickPickItem = { - label: `${options?.icon ? `$(archive)${GlyphChars.Space}` : ''}${number}${commit.summary}`, - description: '', - detail: `${GlyphChars.Space.repeat(2)}${commit.formattedDate}${pad( - GlyphChars.Dot, - 2, - 2, - )}${commit.formatStats({ compact: true })}`, - alwaysShow: options?.alwaysShow, - buttons: options?.buttons, - picked: picked, - item: commit, - }; + let iconPath; + if (options?.icon === 'avatar') { + if (configuration.get('gitCommands.avatars')) { + iconPath = await commit.getAvatarUri(); + } else { + options.icon = true; + } + } - return item; + if (options?.icon === true) { + iconPath = new ThemeIcon('git-commit'); } if (options?.compact) { const item: CommitQuickPickItem = { - label: `${options.icon ? `$(git-commit)${GlyphChars.Space}` : ''}${commit.summary}`, + label: commit.summary, description: `${commit.author.name}, ${commit.formattedDate}${pad('$(git-commit)', 2, 1)}${ commit.shortSha }${pad(GlyphChars.Dot, 2, 2)}${commit.formatStats({ compact: true })}`, @@ -194,12 +187,13 @@ export function createCommitQuickPickItem( buttons: options.buttons, picked: picked, item: commit, + iconPath: iconPath, }; return item; } const item: CommitQuickPickItem = { - label: `${options?.icon ? `$(git-commit)${GlyphChars.Space}` : ''}${commit.summary}`, + label: commit.summary, description: '', detail: `${GlyphChars.Space.repeat(2)}${commit.author.name}, ${commit.formattedDate}${pad( '$(git-commit)', @@ -212,25 +206,47 @@ export function createCommitQuickPickItem( buttons: options?.buttons, picked: picked, item: commit, + iconPath: iconPath, }; return item; } -export type ContributorQuickPickItem = QuickPickItemOfT; - -export function createContributorQuickPickItem( - contributor: GitContributor, +export function createStashQuickPickItem( + commit: GitStashCommit, picked?: boolean, - options?: { alwaysShow?: boolean; buttons?: QuickInputButton[] }, -): ContributorQuickPickItem { - const item: ContributorQuickPickItem = { - label: contributor.label, - description: contributor.email, + options?: { alwaysShow?: boolean; buttons?: QuickInputButton[]; compact?: boolean; icon?: boolean }, +) { + const number = commit.number == null ? '' : `${commit.number}: `; + + if (options?.compact) { + const item: CommitQuickPickItem = { + label: `${number}${commit.summary}`, + description: `${commit.formattedDate}${pad(GlyphChars.Dot, 2, 2)}${commit.formatStats({ + compact: true, + })}`, + alwaysShow: options.alwaysShow, + buttons: options.buttons, + picked: picked, + item: commit, + iconPath: options.icon ? new ThemeIcon('archive') : undefined, + }; + + return item; + } + + const item: CommitQuickPickItem = { + label: `${number}${commit.summary}`, + description: '', + detail: `${GlyphChars.Space.repeat(2)}${commit.formattedDate}${pad(GlyphChars.Dot, 2, 2)}${commit.formatStats({ + compact: true, + })}`, alwaysShow: options?.alwaysShow, buttons: options?.buttons, picked: picked, - item: contributor, + item: commit, + iconPath: options?.icon ? new ThemeIcon('archive') : undefined, }; + return item; } @@ -248,41 +264,43 @@ export function createRefQuickPickItem( ): RefQuickPickItem { if (ref === '') { return { - label: `${options?.icon ? `$(file-directory)${GlyphChars.Space}` : ''}Working Tree`, + label: 'Working Tree', description: '', alwaysShow: options?.alwaysShow, buttons: options?.buttons, picked: picked, - item: GitReference.create(ref, repoPath, { refType: 'revision', name: 'Working Tree' }), + item: createReference(ref, repoPath, { refType: 'revision', name: 'Working Tree' }), current: false, ref: ref, remote: false, + iconPath: options?.icon ? new ThemeIcon('file-directory') : undefined, }; } if (ref === 'HEAD') { return { - label: `${options?.icon ? `$(git-branch)${GlyphChars.Space}` : ''}HEAD`, + label: 'HEAD', description: '', alwaysShow: options?.alwaysShow, buttons: options?.buttons, picked: picked, - item: GitReference.create(ref, repoPath, { refType: 'revision', name: 'HEAD' }), + item: createReference(ref, repoPath, { refType: 'revision', name: 'HEAD' }), current: false, ref: ref, remote: false, + iconPath: options?.icon ? new ThemeIcon('git-branch') : undefined, }; } let gitRef; if (typeof ref === 'string') { - gitRef = GitReference.create(ref, repoPath); + gitRef = createReference(ref, repoPath); } else { gitRef = ref; ref = gitRef.ref; } - if (GitRevision.isRange(ref)) { + if (isRevisionRange(ref)) { return { label: `Range ${gitRef.name}`, description: '', @@ -335,12 +353,13 @@ export function createRemoteQuickPickItem( } const item: RemoteQuickPickItem = { - label: `$(cloud)${GlyphChars.Space}${remote.name}${options?.checked ? pad('$(check)', 2) : ''}`, + label: options?.checked ? `${remote.name}${pad('$(check)', 2)}` : remote.name, description: description, alwaysShow: options?.alwaysShow, buttons: options?.buttons, picked: picked, item: remote, + iconPath: new ThemeIcon('cloud'), }; return item; @@ -386,7 +405,7 @@ export async function createRepositoryQuickPickItem( const status = `${upstreamStatus}${workingStatus}`; if (status) { - description = `${description ? `${description}${status}` : status}`; + description = description ? `${description}${status}` : status; } } @@ -394,7 +413,7 @@ export async function createRepositoryQuickPickItem( const lastFetched = await repository.getLastFetched(); if (lastFetched !== 0) { const fetched = `Last fetched ${fromNow(new Date(lastFetched))}`; - description = `${description ? `${description}${pad(GlyphChars.Dot, 2, 2)}${fetched}` : fetched}`; + description = description ? `${description}${pad(GlyphChars.Dot, 2, 2)}${fetched}` : fetched; } } @@ -435,7 +454,7 @@ export function createTagQuickPickItem( } if (options?.ref) { - description = `${description}${pad('$(git-commit)', description ? 2 : 0, 1)}${GitRevision.shorten(tag.sha)}`; + description = `${description}${pad('$(git-commit)', description ? 2 : 0, 1)}${shortenRevision(tag.sha)}`; description = `${description ? `${description}${pad(GlyphChars.Dot, 2, 2)}` : ''}${tag.formattedDate}`; } @@ -446,7 +465,7 @@ export function createTagQuickPickItem( } const item: TagQuickPickItem = { - label: `$(tag)${GlyphChars.Space}${tag.name}${options?.checked ? pad('$(check)', 2) : ''}`, + label: options?.checked ? `${tag.name}${pad('$(check)', 2)}` : tag.name, description: description, alwaysShow: options?.alwaysShow, buttons: options?.buttons, @@ -455,67 +474,7 @@ export function createTagQuickPickItem( current: false, ref: tag.name, remote: false, - }; - - return item; -} - -export interface WorktreeQuickPickItem extends QuickPickItemOfT { - readonly opened: boolean; - readonly hasChanges: boolean | undefined; -} - -export function createWorktreeQuickPickItem( - worktree: GitWorktree, - picked?: boolean, - options?: { - alwaysShow?: boolean; - buttons?: QuickInputButton[]; - checked?: boolean; - message?: boolean; - path?: boolean; - type?: boolean; - status?: GitStatus; - }, -) { - let description = ''; - if (options?.type) { - description = 'worktree'; - } - - if (options?.status != null) { - description += options.status.hasChanges - ? pad(`Uncommited changes (${options.status.getFormattedDiffStatus()})`, description ? 2 : 0, 0) - : pad('No changes', description ? 2 : 0, 0); - } - - let icon; - let label; - switch (worktree.type) { - case 'bare': - label = '(bare)'; - icon = '$(folder)'; - break; - case 'branch': - label = worktree.branch!; - icon = '$(git-branch)'; - break; - case 'detached': - label = GitRevision.shorten(worktree.sha); - icon = '$(git-commit)'; - break; - } - - const item: WorktreeQuickPickItem = { - label: `${icon}${GlyphChars.Space}${label}${options?.checked ? pad('$(check)', 2) : ''}`, - description: description, - detail: options?.path ? `In $(folder) ${worktree.friendlyPath}` : undefined, - alwaysShow: options?.alwaysShow, - buttons: options?.buttons, - picked: picked, - item: worktree, - opened: worktree.opened, - hasChanges: options?.status?.hasChanges, + iconPath: new ThemeIcon('tag'), }; return item; diff --git a/src/quickpicks/modePicker.ts b/src/quickpicks/modePicker.ts index 9ea3df2b3cbe5..129eaa3aa18d2 100644 --- a/src/quickpicks/modePicker.ts +++ b/src/quickpicks/modePicker.ts @@ -1,45 +1,41 @@ import type { QuickPickItem } from 'vscode'; import { window } from 'vscode'; -import { configuration } from '../configuration'; import { GlyphChars } from '../constants'; +import { configuration } from '../system/vscode/configuration'; export interface ModesQuickPickItem extends QuickPickItem { key: string | undefined; } -export namespace ModePicker { - export async function show(): Promise { - const modes = configuration.get('modes'); - if (modes == null) return undefined; - - const modeKeys = Object.keys(modes); - if (modeKeys.length === 0) return undefined; - - const mode = configuration.get('mode.active'); - - const items = modeKeys.map(key => { - const modeCfg = modes[key]; - const item: ModesQuickPickItem = { - label: `${mode === key ? '$(check)\u00a0\u00a0' : '\u00a0\u00a0\u00a0\u00a0\u00a0'}${ - modeCfg.name - } mode`, - description: modeCfg.description ? `\u00a0${GlyphChars.Dash}\u00a0 ${modeCfg.description}` : '', - key: key, - }; - return item; +export async function showModePicker(): Promise { + const modes = configuration.get('modes'); + if (modes == null) return undefined; + + const modeKeys = Object.keys(modes); + if (modeKeys.length === 0) return undefined; + + const mode = configuration.get('mode.active'); + + const items = modeKeys.map(key => { + const modeCfg = modes[key]; + const item: ModesQuickPickItem = { + label: `${mode === key ? '$(check)\u00a0\u00a0' : '\u00a0\u00a0\u00a0\u00a0\u00a0'}${modeCfg.name} mode`, + description: modeCfg.description ? `\u00a0${GlyphChars.Dash}\u00a0 ${modeCfg.description}` : '', + key: key, + }; + return item; + }); + + if (mode && modes[mode] != null) { + items.unshift({ + label: `Exit ${modes[mode].name} mode`, + key: undefined, }); + } - if (mode && modes[mode] != null) { - items.splice(0, 0, { - label: `Exit ${modes[mode].name} mode`, - key: undefined, - }); - } - - const pick = await window.showQuickPick(items, { - placeHolder: 'select a GitLens mode to enter', - }); + const pick = await window.showQuickPick(items, { + placeHolder: 'select a GitLens mode to enter', + }); - return pick; - } + return pick; } diff --git a/src/quickpicks/organizationMembersPicker.ts b/src/quickpicks/organizationMembersPicker.ts new file mode 100644 index 0000000000000..dfe6a938110e8 --- /dev/null +++ b/src/quickpicks/organizationMembersPicker.ts @@ -0,0 +1,120 @@ +import type { Disposable } from 'vscode'; +import { window } from 'vscode'; +import { getAvatarUri } from '../avatars'; +import { ClearQuickInputButton } from '../commands/quickCommand.buttons'; +import type { OrganizationMember } from '../plus/gk/account/organization'; +import { debounce } from '../system/function'; +import { defer } from '../system/promise'; +import { sortCompare } from '../system/string'; +import type { QuickPickItemOfT } from './items/common'; + +export async function showOrganizationMembersPicker( + title: string, + placeholder: string, + members: OrganizationMember[] | Promise, + options?: { + clearButton?: boolean; + filter?: (member: OrganizationMember) => boolean; + multiselect?: boolean; + picked?: (member: OrganizationMember) => boolean; + }, +): Promise { + const deferred = defer(); + const disposables: Disposable[] = []; + + type OrganizationMemberQuickPickItem = QuickPickItemOfT; + + function sortItems(items: OrganizationMemberQuickPickItem[]) { + return items.sort((a, b) => (a.picked ? -1 : 1) - (b.picked ? -1 : 1) || sortCompare(a.label, b.label)); + } + + try { + const quickpick = window.createQuickPick(); + disposables.push( + quickpick, + quickpick.onDidHide(() => deferred.fulfill(undefined)), + quickpick.onDidAccept(() => + !quickpick.busy ? deferred.fulfill(quickpick.selectedItems.map(c => c.item)) : undefined, + ), + quickpick.onDidTriggerButton(e => { + if (e === ClearQuickInputButton) { + if (quickpick.canSelectMany) { + quickpick.selectedItems = []; + } else { + deferred.fulfill([]); + } + } + }), + ); + + quickpick.ignoreFocusOut = true; + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.canSelectMany = options?.multiselect ?? true; + + quickpick.buttons = options?.clearButton ? [ClearQuickInputButton] : []; + + quickpick.busy = true; + quickpick.show(); + + members = await members; + if (options?.filter != null) { + members = members.filter(options.filter); + } + + if (!deferred.pending) return; + + const items = members.map(member => { + const item: OrganizationMemberQuickPickItem = { + label: member.name ?? member.username, + description: member.email, + picked: options?.picked?.(member) ?? false, + item: member, + iconPath: getAvatarUri(member.email, undefined), + }; + + item.alwaysShow = item.picked; + return item; + }); + + if (!deferred.pending) return; + + quickpick.items = sortItems(items); + if (quickpick.canSelectMany) { + quickpick.selectedItems = items.filter(i => i.picked); + } else { + quickpick.activeItems = items.filter(i => i.picked); + } + + quickpick.busy = false; + + disposables.push( + quickpick.onDidChangeSelection( + debounce(e => { + if (!quickpick.canSelectMany || quickpick.busy) return; + + let update = false; + for (const item of quickpick.items) { + const picked = e.includes(item); + if (item.picked !== picked || item.alwaysShow !== picked) { + item.alwaysShow = item.picked = picked; + update = true; + } + } + + if (update) { + quickpick.items = sortItems([...quickpick.items]); + quickpick.selectedItems = e; + } + }, 10), + ), + ); + + const picks = await deferred.promise; + return picks; + } finally { + disposables.forEach(d => void d.dispose()); + } +} diff --git a/src/quickpicks/referencePicker.ts b/src/quickpicks/referencePicker.ts index 0938b85d5c7a6..40e5617129bd7 100644 --- a/src/quickpicks/referencePicker.ts +++ b/src/quickpicks/referencePicker.ts @@ -1,16 +1,18 @@ -import type { Disposable, QuickPick } from 'vscode'; +import type { Disposable } from 'vscode'; import { CancellationTokenSource, window } from 'vscode'; -import { getBranchesAndOrTags, getValidateGitReferenceFn, QuickCommandButtons } from '../commands/quickCommand'; -import { GlyphChars } from '../constants'; +import { RevealInSideBarQuickInputButton } from '../commands/quickCommand.buttons'; +import { getBranchesAndOrTags, getValidateGitReferenceFn } from '../commands/quickCommand.steps'; +import type { Keys } from '../constants'; import { Container } from '../container'; import { reveal as revealBranch } from '../git/actions/branch'; import { showDetailsView } from '../git/actions/commit'; import { reveal as revealTag } from '../git/actions/tag'; import type { BranchSortOptions, GitBranch } from '../git/models/branch'; -import { GitReference } from '../git/models/reference'; +import type { GitReference } from '../git/models/reference'; +import { isBranchReference, isRevisionReference, isTagReference } from '../git/models/reference'; import type { GitTag, TagSortOptions } from '../git/models/tag'; -import type { KeyboardScope, Keys } from '../keyboard'; -import { getQuickPickIgnoreFocusOut } from '../system/utils'; +import type { KeyboardScope } from '../system/vscode/keyboard'; +import { getQuickPickIgnoreFocusOut } from '../system/vscode/utils'; import type { BranchQuickPickItem, RefQuickPickItem, TagQuickPickItem } from './items/gitCommands'; import { createRefQuickPickItem } from './items/gitCommands'; @@ -22,183 +24,189 @@ export const enum ReferencesQuickPickIncludes { WorkingTree = 1 << 2, HEAD = 1 << 3, - // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member BranchesAndTags = Branches | Tags, + // eslint-disable-next-line @typescript-eslint/prefer-literal-enum-member + All = Branches | Tags | WorkingTree | HEAD, } export interface ReferencesQuickPickOptions { - allowEnteringRefs?: boolean | { ranges?: boolean }; + allowRevisions?: boolean | { ranges?: boolean }; autoPick?: boolean; picked?: string; filter?: { branches?(b: GitBranch): boolean; tags?(t: GitTag): boolean }; include?: ReferencesQuickPickIncludes; - keys?: Keys[]; - onDidPressKey?(key: Keys, quickpick: QuickPick): void | Promise; + keyboard?: { + keys: Keys[]; + onDidPressKey(key: Keys, item: ReferencesQuickPickItem): void | Promise; + }; sort?: boolean | { branches?: BranchSortOptions; tags?: TagSortOptions }; } -export namespace ReferencePicker { - export async function show( - repoPath: string, - title: string, - placeHolder: string, - options: ReferencesQuickPickOptions = {}, - ): Promise { - const quickpick = window.createQuickPick(); - quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); - - quickpick.title = title; - quickpick.placeholder = - options.allowEnteringRefs != null - ? `${placeHolder}${GlyphChars.Space.repeat(3)}(or enter a reference using #)` - : placeHolder; - quickpick.matchOnDescription = true; - - const disposables: Disposable[] = []; - - let scope: KeyboardScope | undefined; - if (options?.keys != null && options.keys.length !== 0 && options?.onDidPressKey !== null) { - scope = Container.instance.keyboard.createScope( - Object.fromEntries( - options.keys.map(key => [ - key, - { - onDidPressKey: key => { - if (quickpick.activeItems.length !== 0) { - void options.onDidPressKey!(key, quickpick); +export async function showReferencePicker( + repoPath: string, + title: string, + placeholder: string, + options?: ReferencesQuickPickOptions, +): Promise { + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + quickpick.title = title; + quickpick.placeholder = + options?.allowRevisions != null && options.allowRevisions !== false + ? `${placeholder} (or enter a revision using #)` + : placeholder; + quickpick.matchOnDescription = true; + + const disposables: Disposable[] = []; + + let scope: KeyboardScope | undefined; + if (options?.keyboard != null) { + const { keyboard } = options; + scope = Container.instance.keyboard.createScope( + Object.fromEntries( + keyboard.keys.map(key => [ + key, + { + onDidPressKey: async key => { + if (quickpick.activeItems.length !== 0) { + const [item] = quickpick.activeItems; + if (item != null) { + const ignoreFocusOut = quickpick.ignoreFocusOut; + quickpick.ignoreFocusOut = true; + + await keyboard.onDidPressKey(key, item); + + quickpick.ignoreFocusOut = ignoreFocusOut; } - }, + } }, - ]), - ), - ); - void scope.start(); - disposables.push(scope); - } - - const cancellation = new CancellationTokenSource(); - - let autoPick; - let items = getItems(repoPath, options); - if (options.autoPick) { - items = items.then(itms => { - if (itms.length <= 1) { - autoPick = itms[0]; - cancellation.cancel(); - } - return itms; - }); - } - - quickpick.busy = true; + }, + ]), + ), + ); + void scope.start(); + disposables.push(scope); + } - quickpick.show(); + const cancellation = new CancellationTokenSource(); - const getValidateGitReference = getValidateGitReferenceFn(Container.instance.git.getRepository(repoPath), { - buttons: [QuickCommandButtons.RevealInSideBar], - ranges: - options?.allowEnteringRefs && typeof options.allowEnteringRefs !== 'boolean' - ? options.allowEnteringRefs.ranges - : undefined, + let autoPick; + let items = getItems(repoPath, options); + if (options?.autoPick) { + items = items.then(itms => { + if (itms.length <= 1) { + autoPick = itms[0]; + cancellation.cancel(); + } + return itms; }); + } - quickpick.items = await items; - - quickpick.busy = false; - - try { - let pick = await new Promise(resolve => { - disposables.push( - cancellation.token.onCancellationRequested(() => quickpick.hide()), - quickpick.onDidHide(() => resolve(undefined)), - quickpick.onDidAccept(() => { - if (quickpick.activeItems.length === 0) return; - - resolve(quickpick.activeItems[0]); - }), - quickpick.onDidChangeValue(async e => { - if (options.allowEnteringRefs) { - if (!(await getValidateGitReference(quickpick, e))) { - quickpick.items = await items; - } - } - - if (scope == null) return; - + quickpick.busy = true; + quickpick.show(); + + const getValidateGitReference = getValidateGitReferenceFn(Container.instance.git.getRepository(repoPath), { + buttons: [RevealInSideBarQuickInputButton], + ranges: + options?.allowRevisions && typeof options.allowRevisions !== 'boolean' + ? options.allowRevisions.ranges + : undefined, + }); + + quickpick.items = await items; + quickpick.busy = false; + + try { + let pick = await new Promise(resolve => { + disposables.push( + cancellation.token.onCancellationRequested(() => quickpick.hide()), + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length === 0) return; + + resolve(quickpick.activeItems[0]); + }), + quickpick.onDidChangeValue(async e => { + if (scope != null) { // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly if (e.length !== 0) { - await scope.pause(['left', 'right']); + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); } else { - await scope.resume(); + void scope.resume(); } - }), - quickpick.onDidTriggerItemButton(({ button, item: { item } }) => { - if (button === QuickCommandButtons.RevealInSideBar) { - if (GitReference.isBranch(item)) { - void revealBranch(item, { select: true, expand: true }); - } else if (GitReference.isTag(item)) { - void revealTag(item, { select: true, expand: true }); - } else if (GitReference.isRevision(item)) { - void showDetailsView(item, { - pin: false, - preserveFocus: true, - }); - } - } - }), - ); - }); - if (pick == null && autoPick != null) { - pick = autoPick; - } - if (pick == null) return undefined; + } - return pick.item; - } finally { - quickpick.dispose(); - disposables.forEach(d => void d.dispose()); + if (options?.allowRevisions) { + if (!(await getValidateGitReference(quickpick, e))) { + quickpick.items = await items; + } + } + }), + quickpick.onDidTriggerItemButton(({ button, item: { item } }) => { + if (button === RevealInSideBarQuickInputButton) { + if (isBranchReference(item)) { + void revealBranch(item, { select: true, expand: true }); + } else if (isTagReference(item)) { + void revealTag(item, { select: true, expand: true }); + } else if (isRevisionReference(item)) { + void showDetailsView(item, { + pin: false, + preserveFocus: true, + }); + } + } + }), + ); + }); + if (pick == null && autoPick != null) { + pick = autoPick; } - } - - async function getItems( - repoPath: string, - { picked, filter, include, sort }: ReferencesQuickPickOptions, - ): Promise { - include = include ?? ReferencesQuickPickIncludes.BranchesAndTags; - - const items: ReferencesQuickPickItem[] = await getBranchesAndOrTags( - Container.instance.git.getRepository(repoPath), - include && ReferencesQuickPickIncludes.BranchesAndTags - ? ['branches', 'tags'] - : include && ReferencesQuickPickIncludes.Branches - ? ['branches'] - : include && ReferencesQuickPickIncludes.Tags - ? ['tags'] - : [], - { - buttons: [QuickCommandButtons.RevealInSideBar], - filter: filter, - picked: picked, - sort: sort ?? { branches: { current: false }, tags: {} }, - }, - ); + if (pick == null) return undefined; - // Move the picked item to the top - if (picked) { - const index = items.findIndex(i => i.ref === picked); - if (index !== -1) { - items.splice(0, 0, ...items.splice(index, 1)); - } - } + return pick.item; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); + } +} - if (include & ReferencesQuickPickIncludes.HEAD) { - items.splice(0, 0, createRefQuickPickItem('HEAD', repoPath, undefined, { icon: true })); +async function getItems(repoPath: string, options?: ReferencesQuickPickOptions): Promise { + const include = options?.include ?? ReferencesQuickPickIncludes.BranchesAndTags; + + const items: ReferencesQuickPickItem[] = await getBranchesAndOrTags( + Container.instance.git.getRepository(repoPath), + include && ReferencesQuickPickIncludes.BranchesAndTags + ? ['branches', 'tags'] + : include && ReferencesQuickPickIncludes.Branches + ? ['branches'] + : include && ReferencesQuickPickIncludes.Tags + ? ['tags'] + : [], + { + buttons: [RevealInSideBarQuickInputButton], + filter: options?.filter, + picked: options?.picked, + sort: options?.sort ?? { branches: { current: false }, tags: {} }, + }, + ); + + // Move the picked item to the top + const picked = options?.picked; + if (picked) { + const index = items.findIndex(i => i.ref === picked); + if (index !== -1) { + items.unshift(...items.splice(index, 1)); } + } - if (include & ReferencesQuickPickIncludes.WorkingTree) { - items.splice(0, 0, createRefQuickPickItem('', repoPath, undefined, { icon: true })); - } + if (include & ReferencesQuickPickIncludes.HEAD) { + items.unshift(createRefQuickPickItem('HEAD', repoPath, undefined, { icon: true })); + } - return items; + if (include & ReferencesQuickPickIncludes.WorkingTree) { + items.unshift(createRefQuickPickItem('', repoPath, undefined, { icon: true })); } + + return items; } diff --git a/src/quickpicks/remotePicker.ts b/src/quickpicks/remotePicker.ts index 5b9b17f65567c..e891163c97a4f 100644 --- a/src/quickpicks/remotePicker.ts +++ b/src/quickpicks/remotePicker.ts @@ -1,89 +1,86 @@ import type { Disposable } from 'vscode'; import { window } from 'vscode'; -import { QuickCommandButtons } from '../commands/quickCommand.buttons'; +import { SetRemoteAsDefaultQuickInputButton } from '../commands/quickCommand.buttons'; import type { GitRemote } from '../git/models/remote'; -import { getQuickPickIgnoreFocusOut } from '../system/utils'; +import { getQuickPickIgnoreFocusOut } from '../system/vscode/utils'; import type { RemoteQuickPickItem } from './items/gitCommands'; import { createRemoteQuickPickItem } from './items/gitCommands'; -export namespace RemotePicker { - export async function show( - title: string | undefined, - placeholder: string = 'Choose a remote', - remotes: GitRemote[], - options?: { - autoPick?: 'default' | boolean; - picked?: string; - setDefault?: boolean; - }, - ): Promise { - const items: RemoteQuickPickItem[] = []; - let picked: RemoteQuickPickItem | undefined; +export async function showRemotePicker( + title: string | undefined, + placeholder: string = 'Choose a remote', + remotes: GitRemote[], + options?: { + autoPick?: 'default' | boolean; + picked?: string; + setDefault?: boolean; + }, +): Promise { + const items: RemoteQuickPickItem[] = []; + let picked: RemoteQuickPickItem | undefined; - if (remotes.length === 0) { - placeholder = 'No remotes found'; - } else { - if (options?.autoPick === 'default' && remotes.length > 1) { - // If there is a default just execute it directly - const remote = remotes.find(r => r.default); - if (remote != null) { - remotes = [remote]; - } + if (remotes.length === 0) { + placeholder = 'No remotes found'; + } else { + if (options?.autoPick === 'default' && remotes.length > 1) { + // If there is a default just execute it directly + const remote = remotes.find(r => r.default); + if (remote != null) { + remotes = [remote]; } + } - const pickOpts: Parameters[2] = { - upstream: true, - buttons: options?.setDefault ? [QuickCommandButtons.SetRemoteAsDefault] : undefined, - }; + const pickOpts: Parameters[2] = { + upstream: true, + buttons: options?.setDefault ? [SetRemoteAsDefaultQuickInputButton] : undefined, + }; - for (const r of remotes) { - items.push(createRemoteQuickPickItem(r, undefined, pickOpts)); - if (r.name === options?.picked) { - picked = items[items.length - 1]; - } + for (const r of remotes) { + items.push(createRemoteQuickPickItem(r, undefined, pickOpts)); + if (r.name === options?.picked) { + picked = items[items.length - 1]; } } + } - if (options?.autoPick && remotes.length === 1) return items[0]; + if (options?.autoPick && remotes.length === 1) return items[0].item; - const quickpick = window.createQuickPick(); - quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); - const disposables: Disposable[] = []; + const disposables: Disposable[] = []; - try { - const pick = await new Promise(resolve => { - disposables.push( - quickpick.onDidHide(() => resolve(undefined)), - quickpick.onDidAccept(() => { - if (quickpick.activeItems.length !== 0) { - resolve(quickpick.activeItems[0]); - } - }), - quickpick.onDidTriggerItemButton(async e => { - if (e.button === QuickCommandButtons.SetRemoteAsDefault) { - await e.item.item.setAsDefault(); - resolve(e.item); - } - }), - ); + try { + const pick = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpick.activeItems[0]); + } + }), + quickpick.onDidTriggerItemButton(async e => { + if (e.button === SetRemoteAsDefaultQuickInputButton) { + await e.item.item.setAsDefault(); + resolve(e.item); + } + }), + ); - quickpick.title = title; - quickpick.placeholder = placeholder; - quickpick.matchOnDetail = true; - quickpick.items = items; - if (picked != null) { - quickpick.activeItems = [picked]; - } + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.matchOnDetail = true; + quickpick.items = items; + if (picked != null) { + quickpick.activeItems = [picked]; + } - quickpick.show(); - }); - if (pick == null) return undefined; + quickpick.show(); + }); - return pick; - } finally { - quickpick.dispose(); - disposables.forEach(d => void d.dispose()); - } + return pick?.item; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); } } diff --git a/src/quickpicks/remoteProviderPicker.ts b/src/quickpicks/remoteProviderPicker.ts index ee85701e002d3..633f9bbac7e56 100644 --- a/src/quickpicks/remoteProviderPicker.ts +++ b/src/quickpicks/remoteProviderPicker.ts @@ -1,17 +1,20 @@ import type { Disposable, QuickInputButton } from 'vscode'; -import { env, Uri, window } from 'vscode'; -import type { OpenOnRemoteCommandArgs } from '../commands'; -import { QuickCommandButtons } from '../commands/quickCommand.buttons'; -import { Commands, GlyphChars } from '../constants'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import type { OpenOnRemoteCommandArgs } from '../commands/openOnRemote'; +import { SetRemoteAsDefaultQuickInputButton } from '../commands/quickCommand.buttons'; +import type { Keys } from '../constants'; +import { GlyphChars } from '../constants'; +import { Commands } from '../constants.commands'; import { Container } from '../container'; -import { getBranchNameWithoutRemote, getRemoteNameFromBranchName } from '../git/models/branch'; -import { GitRemote } from '../git/models/remote'; +import { getBranchNameWithoutRemote, getDefaultBranchName, getRemoteNameFromBranchName } from '../git/models/branch'; +import type { GitRemote } from '../git/models/remote'; +import { getHighlanderProviders } from '../git/models/remote'; import type { RemoteResource } from '../git/models/remoteResource'; import { getNameFromRemoteResource, RemoteResourceType } from '../git/models/remoteResource'; import type { RemoteProvider } from '../git/remotes/remoteProvider'; -import type { Keys } from '../keyboard'; +import { filterMap } from '../system/array'; import { getSettledValue } from '../system/promise'; -import { getQuickPickIgnoreFocusOut } from '../system/utils'; +import { getQuickPickIgnoreFocusOut } from '../system/vscode/utils'; import { CommandQuickPickItem } from './items/common'; export class ConfigureCustomRemoteProviderCommandQuickPickItem extends CommandQuickPickItem { @@ -21,7 +24,7 @@ export class ConfigureCustomRemoteProviderCommandQuickPickItem extends CommandQu override async execute(): Promise { await env.openExternal( - Uri.parse('https://github.com/gitkraken/vscode-gitlens#remote-provider-integration-settings-'), + Uri.parse('https://help.gitkraken.com/gitlens/gitlens-settings/#remote-provider-integration-settings'), ); } } @@ -29,7 +32,7 @@ export class ConfigureCustomRemoteProviderCommandQuickPickItem extends CommandQu export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { constructor( private readonly remote: GitRemote, - private readonly resource: RemoteResource, + private readonly resources: RemoteResource[], private readonly clipboard?: boolean, buttons?: QuickInputButton[], ) { @@ -41,52 +44,58 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { } override async execute(): Promise { - let resource = this.resource; - if (resource.type === RemoteResourceType.Comparison) { - if (getRemoteNameFromBranchName(resource.base) === this.remote.name) { - resource = { ...resource, base: getBranchNameWithoutRemote(resource.base) }; - } - - if (getRemoteNameFromBranchName(resource.compare) === this.remote.name) { - resource = { ...resource, compare: getBranchNameWithoutRemote(resource.compare) }; - } - } else if (resource.type === RemoteResourceType.CreatePullRequest) { - let branch = resource.base.branch; - if (branch == null) { - branch = await Container.instance.git.getDefaultBranchName(this.remote.repoPath, this.remote.name); - if (branch == null && this.remote.hasRichProvider()) { - const defaultBranch = await this.remote.provider.getDefaultBranch?.(); - branch = defaultBranch?.name; + const resourcesResults = await Promise.allSettled( + this.resources.map(async resource => { + if (resource.type === RemoteResourceType.Comparison) { + if (getRemoteNameFromBranchName(resource.base) === this.remote.name) { + resource = { ...resource, base: getBranchNameWithoutRemote(resource.base) }; + } + + if (getRemoteNameFromBranchName(resource.compare) === this.remote.name) { + resource = { ...resource, compare: getBranchNameWithoutRemote(resource.compare) }; + } + } else if (resource.type === RemoteResourceType.CreatePullRequest) { + let branch = resource.base.branch; + if (branch == null) { + branch = await getDefaultBranchName(Container.instance, this.remote.repoPath, this.remote.name); + if (branch) { + branch = getBranchNameWithoutRemote(branch); + } + } + + resource = { + ...resource, + base: { branch: branch, remote: { path: this.remote.path, url: this.remote.url } }, + }; + } else if ( + resource.type === RemoteResourceType.File && + resource.branchOrTag != null && + (this.remote.provider.id === 'bitbucket' || this.remote.provider.id === 'bitbucket-server') + ) { + // HACK ALERT + // Since Bitbucket can't support branch names in the url (other than with the default branch), + // turn this into a `Revision` request + const { branchOrTag } = resource; + const [branches, tags] = await Promise.allSettled([ + Container.instance.git.getBranches(this.remote.repoPath, { + filter: b => b.name === branchOrTag || b.getNameWithoutRemote() === branchOrTag, + }), + Container.instance.git.getTags(this.remote.repoPath, { filter: t => t.name === branchOrTag }), + ]); + + const sha = getSettledValue(branches)?.values[0]?.sha ?? getSettledValue(tags)?.values[0]?.sha; + if (sha) { + resource = { ...resource, type: RemoteResourceType.Revision, sha: sha }; + } } - } - resource = { - ...resource, - base: { branch: branch, remote: { path: this.remote.path, url: this.remote.url } }, - }; - } else if ( - resource.type === RemoteResourceType.File && - resource.branchOrTag != null && - (this.remote.provider.id === 'bitbucket' || this.remote.provider.id === 'bitbucket-server') - ) { - // HACK ALERT - // Since Bitbucket can't support branch names in the url (other than with the default branch), - // turn this into a `Revision` request - const { branchOrTag } = resource; - const [branches, tags] = await Promise.allSettled([ - Container.instance.git.getBranches(this.remote.repoPath, { - filter: b => b.name === branchOrTag || b.getNameWithoutRemote() === branchOrTag, - }), - Container.instance.git.getTags(this.remote.repoPath, { filter: t => t.name === branchOrTag }), - ]); + return resource; + }), + ); - const sha = getSettledValue(branches)?.values[0]?.sha ?? getSettledValue(tags)?.values[0]?.sha; - if (sha) { - resource = { ...resource, type: RemoteResourceType.Revision, sha: sha }; - } - } + const resources = filterMap(resourcesResults, r => getSettledValue(r)); - void (await (this.clipboard ? this.remote.provider.copy(resource) : this.remote.provider.open(resource))); + void (await (this.clipboard ? this.remote.provider.copy(resources) : this.remote.provider.open(resources))); } setAsDefault(): Promise { @@ -96,16 +105,16 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { export class CopyRemoteResourceCommandQuickPickItem extends CommandQuickPickItem { constructor(remotes: GitRemote[], resource: RemoteResource) { - const providers = GitRemote.getHighlanderProviders(remotes); + const providers = getHighlanderProviders(remotes); const commandArgs: OpenOnRemoteCommandArgs = { resource: resource, remotes: remotes, clipboard: true, }; - const label = `$(copy) Copy Link to ${getNameFromRemoteResource(resource)} for ${ + const label = `Copy Link to ${getNameFromRemoteResource(resource)} for ${ providers?.length ? providers[0].name : 'Remote' }${providers?.length === 1 ? '' : GlyphChars.Ellipsis}`; - super(label, Commands.OpenOnRemote, [commandArgs]); + super(label, new ThemeIcon('copy'), Commands.OpenOnRemote, [commandArgs]); } override async onDidPressKey(key: Keys): Promise { @@ -116,111 +125,110 @@ export class CopyRemoteResourceCommandQuickPickItem extends CommandQuickPickItem export class OpenRemoteResourceCommandQuickPickItem extends CommandQuickPickItem { constructor(remotes: GitRemote[], resource: RemoteResource) { - const providers = GitRemote.getHighlanderProviders(remotes); + const providers = getHighlanderProviders(remotes); const commandArgs: OpenOnRemoteCommandArgs = { resource: resource, remotes: remotes, clipboard: false, }; super( - `$(link-external) Open ${getNameFromRemoteResource(resource)} on ${ + `Open ${getNameFromRemoteResource(resource)} on ${ providers?.length === 1 ? providers[0].name : `${providers?.length ? providers[0].name : 'Remote'}${GlyphChars.Ellipsis}` }`, + new ThemeIcon('link-external'), Commands.OpenOnRemote, [commandArgs], ); } } -export namespace RemoteProviderPicker { - export async function show( - title: string, - placeholder: string, - resource: RemoteResource, - remotes: GitRemote[], - options?: { - autoPick?: 'default' | boolean; - clipboard?: boolean; - setDefault?: boolean; - }, - ): Promise { - const { autoPick, clipboard, setDefault } = { - autoPick: false, - clipboard: false, - setDefault: true, - ...options, - }; - - let items: (ConfigureCustomRemoteProviderCommandQuickPickItem | CopyOrOpenRemoteCommandQuickPickItem)[]; - if (remotes.length === 0) { - items = [new ConfigureCustomRemoteProviderCommandQuickPickItem()]; - placeholder = 'No auto-detected or configured remote providers found'; - } else { - if (autoPick === 'default' && remotes.length > 1) { - // If there is a default just execute it directly - const remote = remotes.find(r => r.default); - if (remote != null) { - remotes = [remote]; - } +export async function showRemoteProviderPicker( + title: string, + placeholder: string, + resources: RemoteResource[], + remotes: GitRemote[], + options?: { + autoPick?: 'default' | boolean; + clipboard?: boolean; + setDefault?: boolean; + }, +): Promise { + const { autoPick, clipboard, setDefault } = { + autoPick: false, + clipboard: false, + setDefault: true, + ...options, + }; + + let items: (ConfigureCustomRemoteProviderCommandQuickPickItem | CopyOrOpenRemoteCommandQuickPickItem)[]; + if (remotes.length === 0) { + items = [new ConfigureCustomRemoteProviderCommandQuickPickItem()]; + placeholder = 'No auto-detected or configured remote providers found'; + } else { + if (autoPick === 'default' && remotes.length > 1) { + // If there is a default just execute it directly + const remote = remotes.find(r => r.default); + if (remote != null) { + remotes = [remote]; } - - items = remotes.map( - r => - new CopyOrOpenRemoteCommandQuickPickItem( - r, - resource, - clipboard, - setDefault ? [QuickCommandButtons.SetRemoteAsDefault] : undefined, - ), - ); } - if (autoPick && remotes.length === 1) return items[0]; + items = remotes.map( + r => + new CopyOrOpenRemoteCommandQuickPickItem( + r, + resources, + clipboard, + setDefault ? [SetRemoteAsDefaultQuickInputButton] : undefined, + ), + ); + } + + if (autoPick && remotes.length === 1) return items[0]; + + const quickpick = window.createQuickPick< + ConfigureCustomRemoteProviderCommandQuickPickItem | CopyOrOpenRemoteCommandQuickPickItem + >(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + const disposables: Disposable[] = []; + + try { + const pick = await new Promise< + ConfigureCustomRemoteProviderCommandQuickPickItem | CopyOrOpenRemoteCommandQuickPickItem | undefined + >(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpick.activeItems[0]); + } + }), + quickpick.onDidTriggerItemButton(async e => { + if ( + e.button === SetRemoteAsDefaultQuickInputButton && + e.item instanceof CopyOrOpenRemoteCommandQuickPickItem + ) { + await e.item.setAsDefault(); + resolve(e.item); + } + }), + ); - const quickpick = window.createQuickPick< - ConfigureCustomRemoteProviderCommandQuickPickItem | CopyOrOpenRemoteCommandQuickPickItem - >(); - quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.matchOnDetail = true; + quickpick.items = items; - const disposables: Disposable[] = []; + quickpick.show(); + }); + if (pick == null) return undefined; - try { - const pick = await new Promise< - ConfigureCustomRemoteProviderCommandQuickPickItem | CopyOrOpenRemoteCommandQuickPickItem | undefined - >(resolve => { - disposables.push( - quickpick.onDidHide(() => resolve(undefined)), - quickpick.onDidAccept(() => { - if (quickpick.activeItems.length !== 0) { - resolve(quickpick.activeItems[0]); - } - }), - quickpick.onDidTriggerItemButton(async e => { - if ( - e.button === QuickCommandButtons.SetRemoteAsDefault && - e.item instanceof CopyOrOpenRemoteCommandQuickPickItem - ) { - await e.item.setAsDefault(); - resolve(e.item); - } - }), - ); - - quickpick.title = title; - quickpick.placeholder = placeholder; - quickpick.matchOnDetail = true; - quickpick.items = items; - - quickpick.show(); - }); - if (pick == null) return undefined; - - return pick; - } finally { - quickpick.dispose(); - disposables.forEach(d => void d.dispose()); - } + return pick; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); } } diff --git a/src/quickpicks/repositoryPicker.ts b/src/quickpicks/repositoryPicker.ts index c9377207c1be2..2572ad1845b60 100644 --- a/src/quickpicks/repositoryPicker.ts +++ b/src/quickpicks/repositoryPicker.ts @@ -2,89 +2,195 @@ import type { Disposable, TextEditor, Uri } from 'vscode'; import { window } from 'vscode'; import { Container } from '../container'; import type { Repository } from '../git/models/repository'; +import { filterMapAsync } from '../system/array'; import { map } from '../system/iterable'; -import { getQuickPickIgnoreFocusOut } from '../system/utils'; +import { getQuickPickIgnoreFocusOut } from '../system/vscode/utils'; import { CommandQuickPickItem } from './items/common'; import type { RepositoryQuickPickItem } from './items/gitCommands'; import { createRepositoryQuickPickItem } from './items/gitCommands'; -export namespace RepositoryPicker { - export async function getBestRepositoryOrShow( - uri: Uri | undefined, - editor: TextEditor | undefined, - title: string, - ): Promise { - const repository = Container.instance.git.getBestRepository(uri, editor); - if (repository != null) return repository; - - const pick = await RepositoryPicker.show(title); - if (pick instanceof CommandQuickPickItem) { - await pick.execute(); - return undefined; +export async function getBestRepositoryOrShowPicker( + uri: Uri | undefined, + editor: TextEditor | undefined, + title: string, + placeholder?: string, + options?: { filter?: (r: Repository) => Promise }, +): Promise { + let repository = Container.instance.git.getBestRepository(uri, editor); + + if (repository != null && options?.filter != null) { + if (!(await options.filter(repository))) { + repository = undefined; } + } + if (repository != null) return repository; - return pick?.item; + const pick = await showRepositoryPicker(title, placeholder, undefined, options); + if (pick instanceof CommandQuickPickItem) { + await pick.execute(); + return undefined; } - export async function getRepositoryOrShow(title: string, uri?: Uri): Promise { - let repository; - if (uri == null) { - repository = Container.instance.git.highlander; - } else { - repository = await Container.instance.git.getOrOpenRepository(uri); - } - if (repository != null) return repository; + return pick; +} - const pick = await RepositoryPicker.show(title); - if (pick instanceof CommandQuickPickItem) { - void (await pick.execute()); - return undefined; +export async function getRepositoryOrShowPicker( + title: string, + placeholder?: string, + uri?: Uri, + options?: { filter?: (r: Repository) => Promise }, +): Promise { + let repository; + if (uri == null) { + repository = Container.instance.git.highlander; + } else { + repository = await Container.instance.git.getOrOpenRepository(uri); + } + + if (repository != null && options?.filter != null) { + if (!(await options.filter(repository))) { + repository = undefined; } + } + if (repository != null) return repository; + + const pick = await showRepositoryPicker(title, placeholder, undefined, options); + if (pick instanceof CommandQuickPickItem) { + void (await pick.execute()); + return undefined; + } + + return pick; +} + +export async function showRepositoryPicker( + title: string | undefined, + placeholder?: string, + repositories?: Repository[], + options?: { filter?: (r: Repository) => Promise; picked?: Repository }, +): Promise { + repositories ??= Container.instance.git.openRepositories; + + let items: RepositoryQuickPickItem[]; + if (options?.filter == null) { + items = await Promise.all>( + map(repositories ?? Container.instance.git.openRepositories, r => + createRepositoryQuickPickItem(r, r === options?.picked, { branch: true, status: true }), + ), + ); + } else { + const { filter } = options; + items = await filterMapAsync(Container.instance.git.openRepositories, async r => + (await filter(r)) + ? createRepositoryQuickPickItem(r, r === options?.picked, { branch: true, status: true }) + : undefined, + ); + } + + if (items.length === 0) return undefined; + + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + const disposables: Disposable[] = []; + + try { + const pick = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpick.activeItems[0]); + } + }), + ); + + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.items = items; + + quickpick.show(); + }); return pick?.item; + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); } +} - export async function show( - title: string | undefined, - placeholder: string = 'Choose a repository', - repositories?: Repository[], - ): Promise { - const items = await Promise.all>([ - ...map(repositories ?? Container.instance.git.openRepositories, r => +export async function showRepositoriesPicker( + title: string | undefined, + placeholder?: string, + repositories?: Repository[], +): Promise; +export async function showRepositoriesPicker( + title: string | undefined, + placeholder?: string, + options?: { filter?: (r: Repository) => Promise }, +): Promise; +export async function showRepositoriesPicker( + title: string | undefined, + placeholder: string = 'Choose a repository', + repositoriesOrOptions?: Repository[] | { filter?: (r: Repository) => Promise }, +): Promise { + if ( + repositoriesOrOptions != null && + !Array.isArray(repositoriesOrOptions) && + repositoriesOrOptions.filter == null + ) { + repositoriesOrOptions = undefined; + } + + let items: RepositoryQuickPickItem[]; + if (repositoriesOrOptions == null || Array.isArray(repositoriesOrOptions)) { + items = await Promise.all>( + map(repositoriesOrOptions ?? Container.instance.git.openRepositories, r => createRepositoryQuickPickItem(r, undefined, { branch: true, status: true }), ), - ]); - - const quickpick = window.createQuickPick(); - quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); - - const disposables: Disposable[] = []; - - try { - const pick = await new Promise(resolve => { - disposables.push( - quickpick.onDidHide(() => resolve(undefined)), - quickpick.onDidAccept(() => { - if (quickpick.activeItems.length !== 0) { - resolve(quickpick.activeItems[0]); - } - }), - ); - - quickpick.title = title; - quickpick.placeholder = placeholder; - quickpick.matchOnDescription = true; - quickpick.matchOnDetail = true; - quickpick.items = items; - - quickpick.show(); - }); - if (pick == null) return undefined; - - return pick; - } finally { - quickpick.dispose(); - disposables.forEach(d => void d.dispose()); - } + ); + } else { + const { filter } = repositoriesOrOptions; + items = await filterMapAsync(Container.instance.git.openRepositories, async r => + (await filter!(r)) + ? createRepositoryQuickPickItem(r, undefined, { branch: true, status: true }) + : undefined, + ); + } + + if (items.length === 0) return []; + + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + + const disposables: Disposable[] = []; + + try { + const picks = await new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => resolve(quickpick.selectedItems)), + ); + + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.matchOnDescription = true; + quickpick.matchOnDetail = true; + quickpick.items = items; + quickpick.canSelectMany = true; + + // Select all the repositories by default + quickpick.selectedItems = items; + + quickpick.show(); + }); + if (picks == null) return []; + + return picks.map(p => p.item); + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); } } diff --git a/src/quickpicks/revisionFilesPicker.ts b/src/quickpicks/revisionFilesPicker.ts new file mode 100644 index 0000000000000..c13e086332a18 --- /dev/null +++ b/src/quickpicks/revisionFilesPicker.ts @@ -0,0 +1,140 @@ +import type { Disposable, Uri } from 'vscode'; +import { window } from 'vscode'; +import type { Keys } from '../constants'; +import type { Container } from '../container'; +import type { GitRevisionReference } from '../git/models/reference'; +import type { GitTreeEntry } from '../git/models/tree'; +import { filterMap } from '../system/iterable'; +import type { KeyboardScope } from '../system/vscode/keyboard'; +import { splitPath } from '../system/vscode/path'; +import { getQuickPickIgnoreFocusOut } from '../system/vscode/utils'; +import type { QuickPickItemOfT } from './items/common'; + +export type RevisionQuickPickItem = QuickPickItemOfT; + +export async function showRevisionFilesPicker( + container: Container, + revision: GitRevisionReference, + options: { + ignoreFocusOut?: boolean; + initialPath?: string; + keyboard?: { + keys: Keys[]; + onDidPressKey(key: Keys, uri: Uri): void | Promise; + }; + placeholder?: string; + title: string; + }, +): Promise { + const disposables: Disposable[] = []; + + const repoPath = revision.repoPath; + const ref = revision.ref; + + function getRevisionUri(item: RevisionQuickPickItem) { + return container.git.getRevisionUri(ref, `${repoPath}/${item.item.path}`, repoPath); + } + + try { + const quickpick = window.createQuickPick(); + quickpick.ignoreFocusOut = options?.ignoreFocusOut ?? getQuickPickIgnoreFocusOut(); + + const value = options.initialPath ?? ''; + + let scope: KeyboardScope | undefined; + if (options?.keyboard != null) { + const { keyboard } = options; + scope = container.keyboard.createScope( + Object.fromEntries( + keyboard.keys.map(key => [ + key, + { + onDidPressKey: async key => { + if (quickpick.activeItems.length !== 0) { + const [item] = quickpick.activeItems; + if (item.item != null) { + const ignoreFocusOut = quickpick.ignoreFocusOut; + quickpick.ignoreFocusOut = true; + + await keyboard.onDidPressKey(key, getRevisionUri(item)); + + quickpick.ignoreFocusOut = ignoreFocusOut; + } + } + }, + }, + ]), + ), + ); + void scope.start(); + if (value != null) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } + disposables.push(scope); + } + + quickpick.title = options.title; + quickpick.placeholder = options?.placeholder ?? 'Search files by name'; + quickpick.matchOnDescription = true; + + quickpick.value = value; + quickpick.busy = true; + quickpick.show(); + + const tree = await container.git.getTreeForRevision(repoPath, ref); + const items: RevisionQuickPickItem[] = [ + ...filterMap(tree, file => { + // Exclude directories + if (file.type !== 'blob') return undefined; + + const [label, description] = splitPath(file.path, undefined, true); + return { + label: label, + description: description === '.' ? '' : description, + item: file, + } satisfies RevisionQuickPickItem; + }), + ]; + quickpick.items = items; + quickpick.busy = false; + + const pick = await new Promise(resolve => { + disposables.push( + quickpick, + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length === 0) return; + + resolve(quickpick.activeItems[0]); + }), + quickpick.onDidChangeValue(value => { + if (scope == null) return; + + // Pause the left/right keyboard commands if there is a value, otherwise the left/right arrows won't work in the input properly + if (value.length !== 0) { + void scope.pause(['left', 'ctrl+left', 'right', 'ctrl+right']); + } else { + void scope.resume(); + } + + for (const item of items) { + if ( + item.item.path.includes(value) && + !item.label.includes(value) && + !item.description!.includes(value) + ) { + item.alwaysShow = true; + } else { + item.alwaysShow = false; + } + } + quickpick.items = items; + }), + ); + }); + + return pick != null ? getRevisionUri(pick) : undefined; + } finally { + disposables.forEach(d => void d.dispose()); + } +} diff --git a/src/repositories.ts b/src/repositories.ts index 78cab92f526ad..c49c9a6bcf43d 100644 --- a/src/repositories.ts +++ b/src/repositories.ts @@ -1,11 +1,12 @@ -import type { Uri } from 'vscode'; import { isLinux } from '@env/platform'; +import type { Uri } from 'vscode'; import { Schemes } from './constants'; +import type { RevisionUriData } from './git/gitProvider'; +import { decodeGitLensRevisionUriAuthority } from './git/gitUri.authority'; import type { Repository } from './git/models/repository'; -import { addVslsPrefixIfNeeded, normalizePath } from './system/path'; +import { normalizePath } from './system/path'; import { UriTrie } from './system/trie'; -// TODO@eamodio don't import from string here since it will break the tests because of ESM dependencies -// import { CharCode } from './string'; +import { addVslsPrefixIfNeeded } from './system/vscode/path'; const slash = 47; //CharCode.Slash; @@ -24,7 +25,6 @@ export function normalizeRepoUri(uri: Uri): { path: string; ignoreCase: boolean return { path: path, ignoreCase: !isLinux }; case Schemes.Git: - case Schemes.GitLens: path = uri.path; if (path.charCodeAt(path.length - 1) === slash) { path = path.slice(1, -1); @@ -33,6 +33,21 @@ export function normalizeRepoUri(uri: Uri): { path: string; ignoreCase: boolean } return { path: path, ignoreCase: !isLinux }; + case Schemes.GitLens: { + path = uri.path; + + const metadata = decodeGitLensRevisionUriAuthority(uri.authority); + if (metadata.uncPath != null && !path.startsWith(metadata.uncPath)) { + path = `${metadata.uncPath}${uri.path}`; + } + + if (path.charCodeAt(path.length - 1) === slash) { + path = path.slice(1, -1); + } else { + path = path.startsWith('//') ? path : path.slice(1); + } + return { path: path, ignoreCase: !isLinux }; + } case Schemes.Virtual: case Schemes.GitHub: { path = uri.path; diff --git a/src/statusbar/statusBarController.ts b/src/statusbar/statusBarController.ts index c1c96248146e7..63b7bd6389eab 100644 --- a/src/statusbar/statusBarController.ts +++ b/src/statusbar/statusBarController.ts @@ -1,29 +1,29 @@ -import type { CancellationToken, ConfigurationChangeEvent, StatusBarItem, TextEditor, Uri } from 'vscode'; +import type { ConfigurationChangeEvent, StatusBarItem, TextEditor, Uri } from 'vscode'; import { CancellationTokenSource, Disposable, MarkdownString, StatusBarAlignment, window } from 'vscode'; +import { Command } from '../commands/base'; import type { ToggleFileChangesAnnotationCommandArgs } from '../commands/toggleFileAnnotations'; -import { configuration, FileAnnotationType, StatusBarCommand } from '../configuration'; -import { Commands, GlyphChars } from '../constants'; +import { StatusBarCommand } from '../config'; +import { GlyphChars } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { CommitFormatter } from '../git/formatters/commitFormatter'; -import type { GitCommit } from '../git/models/commit'; import type { PullRequest } from '../git/models/pullRequest'; import { detailsMessage } from '../hovers/hovers'; -import { Logger } from '../logger'; -import type { LogScope } from '../logScope'; -import { getLogScope } from '../logScope'; -import { asCommand } from '../system/command'; import { debug } from '../system/decorators/log'; import { once } from '../system/event'; -import { PromiseCancelledError } from '../system/promise'; -import { isTextEditor } from '../system/utils'; -import type { LinesChangeEvent } from '../trackers/gitLineTracker'; +import { Logger } from '../system/logger'; +import { getLogScope, setLogScopeExit } from '../system/logger.scope'; +import type { MaybePausedResult } from '../system/promise'; +import { getSettledValue, pauseOnCancelOrTimeout } from '../system/promise'; +import { asCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { isTrackableTextEditor } from '../system/vscode/utils'; +import type { LinesChangeEvent, LineState } from '../trackers/lineTracker'; export class StatusBarController implements Disposable { - private _pullRequestCancellation: CancellationTokenSource | undefined; - private _tooltipCancellation: CancellationTokenSource | undefined; - private _tooltipDelayTimer: ReturnType | undefined; - + private _cancellation: CancellationTokenSource | undefined; private readonly _disposable: Disposable; + private _selectedSha: string | undefined; private _statusBarBlame: StatusBarItem | undefined; private _statusBarMode: StatusBarItem | undefined; @@ -145,7 +145,7 @@ export class StatusBarController implements Disposable { if (!e.pending && e.selections != null) { const state = this.container.lineTracker.getState(e.selections[0].active); if (state?.commit != null) { - void this.updateBlame(e.editor!, state.commit); + void this.updateBlame(e.editor!, state); return; } @@ -155,121 +155,147 @@ export class StatusBarController implements Disposable { if (clear) { this.clearBlame(); - } else if (this._statusBarBlame != null) { - this._statusBarBlame.text = this._statusBarBlame.text.replace('$(git-commit)', '$(watch)'); + + if (e.suspended && e.editor?.document.isDirty && this._statusBarBlame != null) { + const statusBarItem = this._statusBarBlame; + const trackedDocumentPromise = this.container.documentTracker.get(e.editor.document); + queueMicrotask(async () => { + const doc = await trackedDocumentPromise; + if (doc == null) return; + + const status = await doc?.getStatus(); + if (!status?.blameable) return; + + statusBarItem.tooltip = new MarkdownString(); + statusBarItem.tooltip.isTrusted = { enabledCommands: [Commands.ShowSettingsPage] }; + + if (doc.canDirtyIdle) { + statusBarItem.text = '$(watch) Blame Paused'; + statusBarItem.tooltip.appendMarkdown( + `Blame will resume after a [${configuration.get( + 'advanced.blame.delayAfterEdit', + )} ms delay](${Command.getMarkdownCommandArgsCore<[undefined, string]>( + Commands.ShowSettingsPage, + [undefined, 'advanced.blame.delayAfterEdit'], + )} 'Change the after edit delay') to limit the performance impact because there are unsaved changes`, + ); + } else { + statusBarItem.text = '$(debug-pause) Blame Paused'; + statusBarItem.tooltip.appendMarkdown( + `Blame will resume after saving because there are unsaved changes and the file is over the [${configuration.get( + 'advanced.blame.sizeThresholdAfterEdit', + )} line threshold](${Command.getMarkdownCommandArgsCore<[undefined, string]>( + Commands.ShowSettingsPage, + [undefined, 'advanced.blame.sizeThresholdAfterEdit'], + )} 'Change the after edit line threshold') to limit the performance impact`, + ); + } + + statusBarItem.show(); + }); + } + } else if (this._statusBarBlame?.text.startsWith('$(git-commit)')) { + this._statusBarBlame.text = `$(watch)${this._statusBarBlame.text.substring(13)}`; } } clearBlame() { - this._pullRequestCancellation?.cancel(); - this._tooltipCancellation?.cancel(); + this._selectedSha = undefined; + this._cancellation?.cancel(); this._statusBarBlame?.hide(); } - @debug({ args: false }) - private async updateBlame(editor: TextEditor, commit: GitCommit, options?: { pr?: PullRequest | null }) { - const cfg = configuration.get('statusBar'); - if (!cfg.enabled || this._statusBarBlame == null || !isTextEditor(editor)) return; - + @debug({ args: { 1: s => s.commit?.sha } }) + private async updateBlame(editor: TextEditor, state: LineState) { const scope = getLogScope(); - const showPullRequests = - cfg.pullRequests.enabled && - (CommitFormatter.has( - cfg.format, - 'pullRequest', - 'pullRequestAgo', - 'pullRequestAgoOrDate', - 'pullRequestDate', - 'pullRequestState', - ) || - CommitFormatter.has( - cfg.tooltipFormat, - 'pullRequest', - 'pullRequestAgo', - 'pullRequestAgoOrDate', - 'pullRequestDate', - 'pullRequestState', - )); + const cfg = configuration.get('statusBar'); + if (!cfg.enabled || this._statusBarBlame == null || !isTrackableTextEditor(editor)) { + this._cancellation?.cancel(); + this._selectedSha = undefined; - // TODO: Make this configurable? - const timeout = 100; - const [getBranchAndTagTips, pr] = await Promise.all([ - CommitFormatter.has(cfg.format, 'tips') || CommitFormatter.has(cfg.tooltipFormat, 'tips') - ? this.container.git.getBranchesAndTagsTipsFn(commit.repoPath) - : undefined, - showPullRequests && options?.pr === undefined - ? this.getPullRequest(commit, { timeout: timeout }) - : options?.pr ?? undefined, - ]); - - if (pr != null) { - this._pullRequestCancellation?.cancel(); - this._pullRequestCancellation = new CancellationTokenSource(); - void this.waitForPendingPullRequest( - editor, - commit, - pr, - this._pullRequestCancellation.token, - timeout, + setLogScopeExit( scope, + ` \u2022 skipped; ${ + !cfg.enabled || this._statusBarBlame == null ? 'disabled' : 'not a trackable editor' + }`, ); + + return; + } + + const { commit } = state; + if (commit == null) { + this._cancellation?.cancel(); + + setLogScopeExit(scope, ' \u2022 skipped; no commit found'); + + return; } - this._statusBarBlame.text = `$(git-commit) ${CommitFormatter.fromTemplate(cfg.format, commit, { - dateFormat: cfg.dateFormat === null ? configuration.get('defaultDateFormat') : cfg.dateFormat, - getBranchAndTagTips: getBranchAndTagTips, - messageTruncateAtNewLine: true, - pullRequestOrRemote: pr, - pullRequestPendingMessage: 'PR $(watch)', - })}`; + // We can avoid refreshing if the commit is the same, except when the commit is uncommitted, since we need to incorporate the line number in the hover + if (this._selectedSha === commit.sha && !commit.isUncommitted) { + if (this._statusBarBlame?.text.startsWith('$(watch)')) { + this._statusBarBlame.text = `$(git-commit)${this._statusBarBlame.text.substring(8)}`; + } - let tooltip: string; + setLogScopeExit(scope, ' \u2022 skipped; same commit'); + + return; + } + + this._selectedSha = commit.sha; + + this._cancellation?.cancel(); + this._cancellation = new CancellationTokenSource(); + const cancellation = this._cancellation.token; + + let actionTooltip: string; switch (cfg.command) { case StatusBarCommand.CopyRemoteCommitUrl: - tooltip = 'Click to Copy Remote Commit URL'; + actionTooltip = 'Click to Copy Remote Commit URL'; break; case StatusBarCommand.CopyRemoteFileUrl: this._statusBarBlame.command = Commands.CopyRemoteFileUrl; - tooltip = 'Click to Copy Remote File Revision URL'; + actionTooltip = 'Click to Copy Remote File Revision URL'; break; case StatusBarCommand.DiffWithPrevious: this._statusBarBlame.command = Commands.DiffLineWithPrevious; - tooltip = 'Click to Open Line Changes with Previous Revision'; + actionTooltip = 'Click to Open Line Changes with Previous Revision'; break; case StatusBarCommand.DiffWithWorking: this._statusBarBlame.command = Commands.DiffLineWithWorking; - tooltip = 'Click to Open Line Changes with Working File'; + actionTooltip = 'Click to Open Line Changes with Working File'; break; case StatusBarCommand.OpenCommitOnRemote: - tooltip = 'Click to Open Commit on Remote'; + actionTooltip = 'Click to Open Commit on Remote'; break; case StatusBarCommand.OpenFileOnRemote: - tooltip = 'Click to Open Revision on Remote'; + actionTooltip = 'Click to Open Revision on Remote'; break; case StatusBarCommand.RevealCommitInView: - tooltip = 'Click to Reveal Commit in the Side Bar'; + actionTooltip = 'Click to Reveal Commit in the Side Bar'; break; case StatusBarCommand.ShowCommitsInView: - tooltip = 'Click to Search for Commit'; + actionTooltip = 'Click to Search for Commit'; break; case StatusBarCommand.ShowQuickCommitDetails: - tooltip = 'Click to Show Commit'; + actionTooltip = 'Click to Show Commit'; break; case StatusBarCommand.ShowQuickCommitFileDetails: - tooltip = 'Click to Show Commit (file)'; + actionTooltip = 'Click to Show Commit (file)'; break; case StatusBarCommand.ShowQuickCurrentBranchHistory: - tooltip = 'Click to Show Branch History'; + actionTooltip = 'Click to Show Branch History'; break; case StatusBarCommand.ShowQuickFileHistory: - tooltip = 'Click to Show File History'; + actionTooltip = 'Click to Show File History'; break; case StatusBarCommand.ToggleCodeLens: - tooltip = 'Click to Toggle Git CodeLens'; + actionTooltip = 'Click to Toggle Git CodeLens'; break; case StatusBarCommand.ToggleFileBlame: - tooltip = 'Click to Toggle File Blame'; + actionTooltip = 'Click to Toggle File Blame'; break; case StatusBarCommand.ToggleFileChanges: { if (commit.file != null) { @@ -279,13 +305,13 @@ export class StatusBarController implements Disposable { arguments: [ commit.file.uri, { - type: FileAnnotationType.Changes, + type: 'changes', context: { sha: commit.sha, only: false, selection: false }, }, ], }); } - tooltip = 'Click to Toggle File Changes'; + actionTooltip = 'Click to Toggle File Changes'; break; } case StatusBarCommand.ToggleFileChangesOnly: { @@ -296,125 +322,153 @@ export class StatusBarController implements Disposable { arguments: [ commit.file.uri, { - type: FileAnnotationType.Changes, + type: 'changes', context: { sha: commit.sha, only: true, selection: false }, }, ], }); } - tooltip = 'Click to Toggle File Changes'; + actionTooltip = 'Click to Toggle File Changes'; break; } case StatusBarCommand.ToggleFileHeatmap: - tooltip = 'Click to Toggle File Heatmap'; + actionTooltip = 'Click to Toggle File Heatmap'; break; } - this._statusBarBlame.tooltip = tooltip; + this._statusBarBlame.tooltip = new MarkdownString(`Loading... \n\n---\n\n${actionTooltip}`); this._statusBarBlame.accessibilityInformation = { - label: `${this._statusBarBlame.text}\n${tooltip}`, + label: `${this._statusBarBlame.text}\n${actionTooltip}`, }; - if (this._tooltipDelayTimer != null) { - clearTimeout(this._tooltipDelayTimer); - } - this._tooltipCancellation?.cancel(); - - this._tooltipDelayTimer = setTimeout(() => { - this._tooltipDelayTimer = undefined; - this._tooltipCancellation = new CancellationTokenSource(); - - void this.updateCommitTooltip( - this._statusBarBlame!, - commit, - tooltip, - getBranchAndTagTips, - { - enabled: showPullRequests || pr != null, - pr: pr, - }, - this._tooltipCancellation.token, - ); - }, 500); + const remotes = await this.container.git.getBestRemotesWithProviders(commit.repoPath); + const [remote] = remotes; - this._statusBarBlame.show(); - } + const defaultDateFormat = configuration.get('defaultDateFormat'); + const getBranchAndTagTipsPromise = + CommitFormatter.has(cfg.format, 'tips') || CommitFormatter.has(cfg.tooltipFormat, 'tips') + ? this.container.git.getBranchesAndTagsTipsFn(commit.repoPath) + : undefined; - private async getPullRequest( - commit: GitCommit, - { timeout }: { timeout?: number } = {}, - ): Promise> | undefined> { - const remote = await this.container.git.getBestRemoteWithRichProvider(commit.repoPath); - if (remote?.provider == null) return undefined; - - const { provider } = remote; - try { - return await this.container.git.getPullRequestForCommit(commit.ref, provider, { timeout: timeout }); - } catch (ex) { - return ex instanceof PromiseCancelledError ? ex : undefined; + const showPullRequests = + !commit.isUncommitted && + remote?.hasIntegration() && + cfg.pullRequests.enabled && + (CommitFormatter.has( + cfg.format, + 'pullRequest', + 'pullRequestAgo', + 'pullRequestAgoOrDate', + 'pullRequestDate', + 'pullRequestState', + ) || + CommitFormatter.has( + cfg.tooltipFormat, + 'pullRequest', + 'pullRequestAgo', + 'pullRequestAgoOrDate', + 'pullRequestDate', + 'pullRequestState', + )); + + function setBlameText( + statusBarItem: StatusBarItem, + getBranchAndTagTips: Awaited | undefined, + pr: Promise | PullRequest | undefined, + ) { + statusBarItem.text = `$(git-commit) ${CommitFormatter.fromTemplate(cfg.format, commit, { + dateFormat: cfg.dateFormat === null ? defaultDateFormat : cfg.dateFormat, + getBranchAndTagTips: getBranchAndTagTips, + messageTruncateAtNewLine: true, + pullRequest: pr, + pullRequestPendingMessage: 'PR $(watch)', + remotes: remotes, + })}`; + statusBarItem.accessibilityInformation = { + label: `${statusBarItem.text}\n${actionTooltip}`, + }; } - } - private async updateCommitTooltip( - statusBarItem: StatusBarItem, - commit: GitCommit, - actionTooltip: string, - getBranchAndTagTips: - | (( - sha: string, - options?: { compact?: boolean | undefined; icons?: boolean | undefined } | undefined, - ) => string | undefined) - | undefined, - pullRequests: { - enabled: boolean; - pr: PullRequest | PromiseCancelledError> | undefined | undefined; - }, - cancellationToken: CancellationToken, - ) { - if (cancellationToken.isCancellationRequested) return; - - const tooltip = await detailsMessage( - commit, - commit.getGitUri(), - commit.lines[0].line, - configuration.get('statusBar.tooltipFormat'), - configuration.get('defaultDateFormat'), - { + async function getBlameTooltip( + container: Container, + getBranchAndTagTips: Awaited | undefined, + pr: Promise | PullRequest | undefined, + timeout?: number, + ) { + return detailsMessage(container, commit, commit.getGitUri(), commit.lines[0].line - 1, { autolinks: true, - cancellationToken: cancellationToken, + cancellation: cancellation, + dateFormat: defaultDateFormat, + format: cfg.tooltipFormat, getBranchAndTagTips: getBranchAndTagTips, - pullRequests: pullRequests, - }, - ); + pullRequest: pr, + pullRequests: showPullRequests && pr != null, + remotes: remotes, + timeout: timeout, + }); + } - if (cancellationToken.isCancellationRequested) return; + let prResult: MaybePausedResult | undefined; + if (showPullRequests) { + // TODO: Make this configurable? + const timeout = 100; - tooltip.appendMarkdown(`\n\n---\n\n${actionTooltip}`); - statusBarItem.tooltip = tooltip; - statusBarItem.accessibilityInformation = { - label: `${statusBarItem.text}\n${actionTooltip}`, - }; - } + prResult = await pauseOnCancelOrTimeout( + commit.getAssociatedPullRequest(remote), + cancellation, + timeout, + async result => { + if (result.reason !== 'timedout' || this._statusBarBlame == null) return; - private async waitForPendingPullRequest( - editor: TextEditor, - commit: GitCommit, - pr: PullRequest | PromiseCancelledError> | undefined, - cancellationToken: CancellationToken, - timeout: number, - scope: LogScope | undefined, - ) { - if (cancellationToken.isCancellationRequested || !(pr instanceof PromiseCancelledError)) return; + // If the PR is taking too long, refresh the status bar once it completes - // If the PR timed out, refresh the status bar once it completes - Logger.debug(scope, `${GlyphChars.Dot} pull request query took too long (over ${timeout} ms)`); + Logger.debug(scope, `${GlyphChars.Dot} pull request query took too long (over ${timeout} ms)`); - pr = await pr.promise; + const [getBranchAndTagTipsResult, prResult] = await Promise.allSettled([ + getBranchAndTagTipsPromise, + result.value, + ]); - if (cancellationToken.isCancellationRequested) return; + if (cancellation.isCancellationRequested || this._statusBarBlame == null) return; - Logger.debug(scope, `${GlyphChars.Dot} pull request query completed; refreshing...`); + const pr = getSettledValue(prResult); + const getBranchAndTagTips = getSettledValue(getBranchAndTagTipsResult); - void this.updateBlame(editor, commit, { pr: pr ?? null }); + Logger.debug(scope, `${GlyphChars.Dot} pull request query completed; updating...`); + + setBlameText(this._statusBarBlame, getBranchAndTagTips, pr); + + const tooltip = await getBlameTooltip(this.container, getBranchAndTagTips, pr); + if (tooltip != null) { + this._statusBarBlame.tooltip = tooltip.appendMarkdown(`\n\n---\n\n${actionTooltip}`); + } + }, + ); + } + + const getBranchAndTagTips = getBranchAndTagTipsPromise != null ? await getBranchAndTagTipsPromise : undefined; + + if (cancellation.isCancellationRequested) return; + + setBlameText(this._statusBarBlame, getBranchAndTagTips, prResult?.value); + this._statusBarBlame.show(); + + const tooltipResult = await pauseOnCancelOrTimeout( + getBlameTooltip(this.container, getBranchAndTagTips, prResult?.value, 20), + cancellation, + 100, + async result => { + if (result.reason !== 'timedout' || this._statusBarBlame == null) return; + + const tooltip = await result.value; + if (tooltip != null) { + this._statusBarBlame.tooltip = tooltip.appendMarkdown(`\n\n---\n\n${actionTooltip}`); + } + }, + ); + + if (!cancellation.isCancellationRequested && !tooltipResult.paused && tooltipResult.value != null) { + this._statusBarBlame.tooltip = tooltipResult.value.appendMarkdown(`\n\n---\n\n${actionTooltip}`); + } } } diff --git a/src/storage.ts b/src/storage.ts deleted file mode 100644 index 1edc4163fe53b..0000000000000 --- a/src/storage.ts +++ /dev/null @@ -1,264 +0,0 @@ -import type { Disposable, Event, ExtensionContext, SecretStorageChangeEvent } from 'vscode'; -import { EventEmitter } from 'vscode'; -import type { ViewShowBranchComparison } from './config'; -import type { StoredSearchQuery } from './git/search'; -import type { Subscription } from './subscription'; -import { debug } from './system/decorators/log'; -import type { TrackedUsage, TrackedUsageKeys } from './usageTracker'; -import type { CompletedActions } from './webviews/home/protocol'; - -export type StorageChangeEvent = - | { - /** - * The key of the stored value that has changed. - */ - readonly key: keyof (GlobalStorage & DeprecatedGlobalStorage); - readonly workspace: false; - } - | { - /** - * The key of the stored value that has changed. - */ - readonly key: keyof (WorkspaceStorage & DeprecatedWorkspaceStorage); - readonly workspace: true; - }; - -export class Storage implements Disposable { - private _onDidChange = new EventEmitter(); - get onDidChange(): Event { - return this._onDidChange.event; - } - - private _onDidChangeSecrets = new EventEmitter(); - get onDidChangeSecrets(): Event { - return this._onDidChangeSecrets.event; - } - - private readonly _disposable: Disposable; - constructor(private readonly context: ExtensionContext) { - this._disposable = this.context.secrets.onDidChange(e => this._onDidChangeSecrets.fire(e)); - } - - dispose(): void { - this._disposable.dispose(); - } - - get(key: T): GlobalStorage[T] | undefined; - /** @deprecated */ - get(key: T): DeprecatedGlobalStorage[T] | undefined; - get(key: T, defaultValue: GlobalStorage[T]): GlobalStorage[T]; - @debug({ logThreshold: 50 }) - get(key: keyof (GlobalStorage & DeprecatedGlobalStorage), defaultValue?: unknown): unknown | undefined { - return this.context.globalState.get(`gitlens:${key}`, defaultValue); - } - - @debug({ logThreshold: 250 }) - async delete(key: keyof (GlobalStorage & DeprecatedGlobalStorage)): Promise { - await this.context.globalState.update(`gitlens:${key}`, undefined); - this._onDidChange.fire({ key: key, workspace: false }); - } - - @debug({ args: { 1: false }, logThreshold: 250 }) - async store(key: T, value: GlobalStorage[T] | undefined): Promise { - await this.context.globalState.update(`gitlens:${key}`, value); - this._onDidChange.fire({ key: key, workspace: false }); - } - - @debug({ args: false, logThreshold: 250 }) - async getSecret(key: SecretKeys): Promise { - return this.context.secrets.get(key); - } - - @debug({ args: false, logThreshold: 250 }) - async deleteSecret(key: SecretKeys): Promise { - return this.context.secrets.delete(key); - } - - @debug({ args: false, logThreshold: 250 }) - async storeSecret(key: SecretKeys, value: string): Promise { - return this.context.secrets.store(key, value); - } - - getWorkspace(key: T): WorkspaceStorage[T] | undefined; - /** @deprecated */ - getWorkspace(key: T): DeprecatedWorkspaceStorage[T] | undefined; - getWorkspace(key: T, defaultValue: WorkspaceStorage[T]): WorkspaceStorage[T]; - @debug({ logThreshold: 25 }) - getWorkspace( - key: keyof (WorkspaceStorage & DeprecatedWorkspaceStorage), - defaultValue?: unknown, - ): unknown | undefined { - return this.context.workspaceState.get(`gitlens:${key}`, defaultValue); - } - - @debug({ logThreshold: 250 }) - async deleteWorkspace(key: keyof (WorkspaceStorage & DeprecatedWorkspaceStorage)): Promise { - await this.context.workspaceState.update(`gitlens:${key}`, undefined); - this._onDidChange.fire({ key: key, workspace: true }); - } - - @debug({ args: { 1: false }, logThreshold: 250 }) - async storeWorkspace(key: keyof WorkspaceStorage, value: unknown | undefined): Promise { - await this.context.workspaceState.update(`gitlens:${key}`, value); - this._onDidChange.fire({ key: key, workspace: true }); - } -} - -export type SecretKeys = string; - -export const enum SyncedStorageKeys { - Version = 'gitlens:synced:version', - PreReleaseVersion = 'gitlens:synced:preVersion', - HomeViewWelcomeVisible = 'gitlens:views:welcome:visible', -} - -export type DeprecatedGlobalStorage = { - /** @deprecated */ - [key in `disallow:connection:${string}`]: any; -}; - -export type GlobalStorage = { - avatars: [string, StoredAvatar][]; - 'deepLinks:pending': StoredDeepLinkContext; - 'home:actions:completed': CompletedActions[]; - 'home:steps:completed': string[]; - 'home:sections:dismissed': string[]; - 'home:status:pinned': boolean; - 'home:banners:dismissed': string[]; - pendingWelcomeOnFocus: boolean; - pendingWhatsNewOnFocus: boolean; - 'plus:migratedAuthentication': boolean; - 'plus:discountNotificationShown': boolean; - 'plus:renewalDiscountNotificationShown': boolean; - // Don't change this key name ('premium`) as its the stored subscription - 'premium:subscription': Stored; - 'synced:version': string; - // Keep the pre-release version separate from the released version - 'synced:preVersion': string; - usages: Record; - version: string; - // Keep the pre-release version separate from the released version - preVersion: string; - 'views:layout': StoredViewsLayout; - 'views:welcome:visible': boolean; - 'views:commitDetails:dismissed': string[]; -} & { [key in `provider:authentication:skip:${string}`]: boolean }; - -export type DeprecatedWorkspaceStorage = { - /** @deprecated use `graph:filtersByRepo.excludeRefs` */ - 'graph:hiddenRefs': Record; - /** @deprecated use `views:searchAndCompare:pinned` */ - 'pinned:comparisons': Record; -}; - -export type WorkspaceStorage = { - assumeRepositoriesOnStartup?: boolean; - 'branch:comparisons': StoredBranchComparisons; - 'gitComandPalette:usage': RecentUsage; - gitPath: string; - 'graph:banners:dismissed': Record; - 'graph:columns': Record; - 'graph:filtersByRepo': Record; - 'remote:default': string; - 'starred:branches': StoredStarred; - 'starred:repositories': StoredStarred; - 'views:repositories:autoRefresh': boolean; - 'views:searchAndCompare:keepResults': boolean; - 'views:searchAndCompare:pinned': StoredPinnedItems; - 'views:commitDetails:autolinksExpanded': boolean; -} & { [key in `connected:${string}`]: boolean }; - -export type StoredViewsLayout = 'gitlens' | 'scm'; -export interface Stored { - v: SchemaVersion; - data: T; -} - -export interface StoredAvatar { - uri: string; - timestamp: number; -} - -export interface StoredBranchComparison { - ref: string; - notation: '..' | '...' | undefined; - type: Exclude | undefined; -} - -export interface StoredBranchComparisons { - [id: string]: string | StoredBranchComparison; -} - -export interface StoredDeepLinkContext { - url?: string | undefined; - repoPath?: string | undefined; -} - -export interface StoredGraphColumn { - isHidden?: boolean; - width?: number; -} - -export interface StoredGraphFilters { - includeOnlyRefs?: Record; - excludeRefs?: Record; - excludeTypes?: Record; -} - -export type StoredGraphRefType = 'head' | 'remote' | 'tag'; - -export interface StoredGraphExcludedRef { - id: string; - type: StoredGraphRefType; - name: string; - owner?: string; -} - -export interface StoredGraphIncludeOnlyRef { - id: string; - type: StoredGraphRefType; - name: string; - owner?: string; -} - -export interface StoredNamedRef { - label?: string; - ref: string; -} - -export interface StoredPinnedComparison { - type: 'comparison'; - timestamp: number; - path: string; - ref1: StoredNamedRef; - ref2: StoredNamedRef; - notation?: '..' | '...'; -} - -export interface StoredPinnedSearch { - type: 'search'; - timestamp: number; - path: string; - labels: { - label: string; - queryLabel: - | string - | { - label: string; - resultsType?: { singular: string; plural: string }; - }; - }; - search: StoredSearchQuery; -} - -export type StoredPinnedItem = StoredPinnedComparison | StoredPinnedSearch; -export type StoredPinnedItems = Record; -export type StoredStarred = Record; -export type RecentUsage = Record; - -interface DeprecatedPinnedComparison { - path: string; - ref1: StoredNamedRef; - ref2: StoredNamedRef; - notation?: '..' | '...'; -} diff --git a/src/test/suite/system/paths.json b/src/system/__tests__/__mock__/paths.json similarity index 100% rename from src/test/suite/system/paths.json rename to src/system/__tests__/__mock__/paths.json diff --git a/src/system/__tests__/color.test.ts b/src/system/__tests__/color.test.ts new file mode 100644 index 0000000000000..4f6b1e335519f --- /dev/null +++ b/src/system/__tests__/color.test.ts @@ -0,0 +1,23 @@ +import * as assert from 'assert'; +import { suite, test } from 'mocha'; +import { Color, formatRGB, formatRGBA, mix, opacity } from '../color'; + +suite('Color Test Suite', () => { + test('hex to rgb', () => { + assert.strictEqual(formatRGB(Color.from('#FFFFFF')), 'rgb(255, 255, 255)'); + assert.strictEqual(formatRGBA(Color.from('#FFFFFF')), 'rgba(255, 255, 255, 1)'); + assert.strictEqual(formatRGBA(Color.from('#FFFFFF20')), 'rgba(255, 255, 255, 0.13)'); + assert.strictEqual(formatRGBA(Color.from('#2f989b21')), 'rgba(47, 152, 155, 0.13)'); + assert.strictEqual(formatRGB(Color.from('#2f989b21')), 'rgba(47, 152, 155, 0.13)'); + }); + test('hsl to rgb', () => { + // assert.strictEqual(formatRGB(Color.from('hsl(0deg 0% 100%)')), 'rgb(255, 255, 255)'); + assert.strictEqual(formatRGB(Color.from('hsl(0 0% 100%)')), 'rgb(255, 255, 255)'); + }); + test.skip('mix', () => { + assert.strictEqual(mix('#FFFFFF', '#000000', 50), '#808080'); + }); + test('opacity', () => { + assert.strictEqual(opacity('#FFFFFF', 50), 'rgba(255, 255, 255, 0.5)'); + }); +}); diff --git a/src/system/__tests__/iterable.test.ts b/src/system/__tests__/iterable.test.ts new file mode 100644 index 0000000000000..c50e0bc97b6fb --- /dev/null +++ b/src/system/__tests__/iterable.test.ts @@ -0,0 +1,14 @@ +import * as assert from 'assert'; +import { suite, test } from 'mocha'; +import { join } from '../iterable'; + +suite('Iterable Test Suite', () => { + test('join', () => { + assert.strictEqual(join([1, 2, 3], ','), '1,2,3'); + assert.strictEqual(join([1, 2, 3], ''), '123'); + assert.strictEqual(join([], ''), ''); + assert.strictEqual(join([1], ''), '1'); + assert.strictEqual(join([1], ','), '1'); + assert.strictEqual(join([], ','), ''); + }); +}); diff --git a/src/test/suite/system/path.test.ts b/src/system/__tests__/path.test.ts similarity index 60% rename from src/test/suite/system/path.test.ts rename to src/system/__tests__/path.test.ts index 03b8a4737056c..a0022aa452a85 100644 --- a/src/test/suite/system/path.test.ts +++ b/src/system/__tests__/path.test.ts @@ -1,63 +1,73 @@ import * as assert from 'assert'; -import { splitPath } from '../../../system/path'; +import { suite, test } from 'mocha'; +import { splitPath } from '../vscode/path'; + +const smallDiskNameRegex = /^[a-z]:\//gm; +function capitalizeDiskName(path: string) { + const match = smallDiskNameRegex.exec(path); + if (!match) { + return path; + } + return match[0].toUpperCase() + path.slice(match[0].length); +} -describe('Path Test Suite', () => { +suite('Path Test Suite', () => { function assertSplitPath(actual: [string, string], expected: [string, string]) { - assert.strictEqual(actual[0], expected[0]); - assert.strictEqual(actual[1], expected[1]); + assert.strictEqual(capitalizeDiskName(actual[0]), capitalizeDiskName(expected[0])); + assert.strictEqual(capitalizeDiskName(actual[1]), capitalizeDiskName(expected[1])); } - it('splitPath: no repoPath', () => { + test('splitPath: no repoPath', () => { assertSplitPath(splitPath('C:\\User\\Name\\code\\gitkraken\\vscode-gitlens', ''), [ - 'c:/User/Name/code/gitkraken/vscode-gitlens', + 'C:/User/Name/code/gitkraken/vscode-gitlens', '', ]); assertSplitPath(splitPath('C:\\User\\Name\\code\\gitkraken\\vscode-gitlens\\', ''), [ - 'c:/User/Name/code/gitkraken/vscode-gitlens', + 'C:/User/Name/code/gitkraken/vscode-gitlens', '', ]); assertSplitPath(splitPath('C:/User/Name/code/gitkraken/vscode-gitlens', ''), [ - 'c:/User/Name/code/gitkraken/vscode-gitlens', + 'C:/User/Name/code/gitkraken/vscode-gitlens', '', ]); assertSplitPath(splitPath('C:/User/Name/code/gitkraken/vscode-gitlens/', ''), [ - 'c:/User/Name/code/gitkraken/vscode-gitlens', + 'C:/User/Name/code/gitkraken/vscode-gitlens', '', ]); }); - it('splitPath: no repoPath (split base)', () => { + test('splitPath: no repoPath (split base)', () => { assertSplitPath(splitPath('C:\\User\\Name\\code\\gitkraken\\vscode-gitlens', '', true), [ 'vscode-gitlens', - 'c:/User/Name/code/gitkraken', + 'C:/User/Name/code/gitkraken', ]); assertSplitPath(splitPath('C:\\User\\Name\\code\\gitkraken\\vscode-gitlens\\', '', true), [ 'vscode-gitlens', - 'c:/User/Name/code/gitkraken', + 'C:/User/Name/code/gitkraken', ]); assertSplitPath(splitPath('C:/User/Name/code/gitkraken/vscode-gitlens', '', true), [ 'vscode-gitlens', - 'c:/User/Name/code/gitkraken', + 'C:/User/Name/code/gitkraken', ]); assertSplitPath(splitPath('C:/User/Name/code/gitkraken/vscode-gitlens/', '', true), [ 'vscode-gitlens', - 'c:/User/Name/code/gitkraken', + 'C:/User/Name/code/gitkraken', ]); }); - it('splitPath: match', () => { + test('splitPath: match', () => { assertSplitPath( splitPath( 'C:\\User\\Name\\code\\gitkraken\\vscode-gitlens\\foo\\bar\\baz.ts', 'C:\\User\\Name\\code\\gitkraken\\vscode-gitlens', ), - ['foo/bar/baz.ts', 'c:/User/Name/code/gitkraken/vscode-gitlens'], + ['foo/bar/baz.ts', 'C:/User/Name/code/gitkraken/vscode-gitlens'], ); assertSplitPath( @@ -65,61 +75,61 @@ describe('Path Test Suite', () => { 'C:\\User\\Name\\code\\gitkraken\\vscode-gitlens\\foo\\bar\\baz.ts', 'C:\\User\\Name\\code\\gitkraken\\vscode-gitlens\\', ), - ['foo/bar/baz.ts', 'c:/User/Name/code/gitkraken/vscode-gitlens'], + ['foo/bar/baz.ts', 'C:/User/Name/code/gitkraken/vscode-gitlens'], ); assertSplitPath( splitPath( 'C:\\User\\Name\\code\\gitkraken\\vscode-gitlens\\foo\\bar\\baz.ts', - 'c:/User/Name/code/gitkraken/vscode-gitlens', + 'C:/User/Name/code/gitkraken/vscode-gitlens', ), - ['foo/bar/baz.ts', 'c:/User/Name/code/gitkraken/vscode-gitlens'], + ['foo/bar/baz.ts', 'C:/User/Name/code/gitkraken/vscode-gitlens'], ); assertSplitPath( splitPath( 'C:\\User\\Name\\code\\gitkraken\\vscode-gitlens\\foo\\bar\\baz.ts', - 'c:/User/Name/code/gitkraken/vscode-gitlens/', + 'C:/User/Name/code/gitkraken/vscode-gitlens/', ), - ['foo/bar/baz.ts', 'c:/User/Name/code/gitkraken/vscode-gitlens'], + ['foo/bar/baz.ts', 'C:/User/Name/code/gitkraken/vscode-gitlens'], ); assertSplitPath( splitPath( 'C:/User/Name/code/gitkraken/vscode-gitlens/foo/bar/baz.ts', - 'c:/User/Name/code/gitkraken/vscode-gitlens', + 'C:/User/Name/code/gitkraken/vscode-gitlens', ), - ['foo/bar/baz.ts', 'c:/User/Name/code/gitkraken/vscode-gitlens'], + ['foo/bar/baz.ts', 'C:/User/Name/code/gitkraken/vscode-gitlens'], ); assertSplitPath( splitPath( 'C:/User/Name/code/gitkraken/vscode-gitlens/foo/bar/baz.ts', - 'c:/User/Name/code/gitkraken/vscode-gitlens/', + 'C:/User/Name/code/gitkraken/vscode-gitlens/', ), - ['foo/bar/baz.ts', 'c:/User/Name/code/gitkraken/vscode-gitlens'], + ['foo/bar/baz.ts', 'C:/User/Name/code/gitkraken/vscode-gitlens'], ); }); - it('splitPath: match (casing)', () => { + test.skip('splitPath: match (casing)', () => { assertSplitPath( splitPath( 'C:/USER/NAME/CODE/GITKRAKEN/VSCODE-GITLENS/FOO/BAR/BAZ.TS', - 'c:/User/Name/code/gitkraken/vscode-gitlens/', + 'C:/User/Name/code/gitkraken/vscode-gitlens/', undefined, true, ), - ['FOO/BAR/BAZ.TS', 'c:/USER/NAME/CODE/GITKRAKEN/VSCODE-GITLENS'], + ['FOO/BAR/BAZ.TS', 'C:/USER/NAME/CODE/GITKRAKEN/VSCODE-GITLENS'], ); assertSplitPath( splitPath( 'C:/USER/NAME/CODE/GITKRAKEN/VSCODE-GITLENS/FOO/BAR/BAZ.TS', - 'c:/User/Name/code/gitkraken/vscode-gitlens/', + 'C:/User/Name/code/gitkraken/vscode-gitlens/', undefined, false, ), - ['USER/NAME/CODE/GITKRAKEN/VSCODE-GITLENS/FOO/BAR/BAZ.TS', 'c:'], + ['USER/NAME/CODE/GITKRAKEN/VSCODE-GITLENS/FOO/BAR/BAZ.TS', 'C:'], ); assertSplitPath( @@ -143,7 +153,7 @@ describe('Path Test Suite', () => { ); }); - it('splitPath: no match', () => { + test.skip('splitPath: no match', () => { assertSplitPath( splitPath( '/foo/User/Name/code/gitkraken/vscode-gitlens/foo/bar/baz.ts', diff --git a/src/test/suite/system/trie.test.ts b/src/system/__tests__/trie.test.ts similarity index 89% rename from src/test/suite/system/trie.test.ts rename to src/system/__tests__/trie.test.ts index 70867e0f662da..e8843560c712d 100644 --- a/src/test/suite/system/trie.test.ts +++ b/src/system/__tests__/trie.test.ts @@ -1,35 +1,35 @@ import * as assert from 'assert'; import { basename } from 'path'; +import { before, suite, test } from 'mocha'; import { Uri } from 'vscode'; -import { isLinux } from '../../../env/node/platform'; -import { normalizeRepoUri } from '../../../repositories'; -import type { UriEntry } from '../../../system/trie'; -import { PathEntryTrie, UriEntryTrie, UriTrie } from '../../../system/trie'; -// eslint-disable-next-line import/extensions -import paths from './paths.json'; - -describe('PathEntryTrie Test Suite', () => { +import { isLinux } from '../../env/node/platform'; +import { normalizeRepoUri } from '../../repositories'; +import type { UriEntry } from '../trie'; +import { PathEntryTrie, UriEntryTrie, UriTrie } from '../trie'; +import paths from './__mock__/paths.json'; + +suite.skip('PathEntryTrie Test Suite', () => { type Repo = { type: 'repo'; name: string; path: string; fsPath: string }; type File = { type: 'file'; name: string; path: string }; - const repoGL: Repo = { + const repoGL = { type: 'repo', name: 'vscode-gitlens', - path: 'c:/Users/Name/code/gitkraken/vscode-gitlens', + path: 'C:/Users/Name/code/gitkraken/vscode-gitlens', fsPath: 'C:\\Users\\Name\\code\\gitkraken\\vscode-gitlens', - }; - const repoNested: Repo = { + } as const satisfies Repo; + const repoNested = { type: 'repo', name: 'repo', - path: 'c:/Users/Name/code/gitkraken/vscode-gitlens/nested/repo', + path: 'C:/Users/Name/code/gitkraken/vscode-gitlens/nested/repo', fsPath: 'C:\\Users\\Name\\code\\gitkraken\\vscode-gitlens\\nested\\repo', - }; - const repoVSC: Repo = { + } as const satisfies Repo; + const repoVSC = { type: 'repo', name: 'vscode', - path: 'c:/Users/Name/code/microsoft/vscode', + path: 'C:/Users/Name/code/microsoft/vscode', fsPath: 'C:\\Users\\Name\\code\\microsoft\\vscode', - }; + } as const satisfies Repo; const trie = new PathEntryTrie(); @@ -50,7 +50,7 @@ describe('PathEntryTrie Test Suite', () => { } }); - it('has: repo', () => { + test('has: repo', () => { assert.strictEqual(trie.has(repoGL.fsPath), true); assert.strictEqual(trie.has(repoNested.fsPath), true); assert.strictEqual(trie.has(repoVSC.fsPath), true); @@ -59,13 +59,13 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(trie.has('D:\\Users\\Name\\code\\gitkraken\\vscode-gitlens'), false); }); - it('has: repo (ignore case)', () => { + test('has: repo (ignore case)', () => { assert.strictEqual(trie.has(repoGL.fsPath.toUpperCase()), true); assert.strictEqual(trie.has(repoNested.fsPath.toUpperCase()), true); assert.strictEqual(trie.has(repoVSC.fsPath.toUpperCase()), true); }); - it('has: file', () => { + test('has: file', () => { assert.strictEqual(trie.has(`${repoGL.fsPath}\\src\\extension.ts`), true); assert.strictEqual(trie.has(`${repoGL.fsPath}\\foo\\bar\\baz.ts`), false); @@ -73,7 +73,7 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(trie.has(`${repoVSC.fsPath}\\src\\main.ts`), true); }); - it('has: file (ignore case)', () => { + test('has: file (ignore case)', () => { assert.strictEqual(trie.has(`${repoGL.fsPath}\\src\\extension.ts`.toUpperCase()), true); assert.strictEqual(trie.has(`${repoGL.fsPath}\\foo\\bar\\baz.ts`.toUpperCase()), false); @@ -81,13 +81,13 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(trie.has(`${repoVSC.fsPath}\\src\\main.ts`.toUpperCase()), true); }); - it('has: folder (failure case)', () => { + test('has: folder (failure case)', () => { assert.strictEqual(trie.has(`${repoGL.fsPath}\\src`), false); assert.strictEqual(trie.has(`${repoNested.fsPath}\\src`), false); assert.strictEqual(trie.has(`${repoVSC.fsPath}\\src`), false); }); - it('get: repo', () => { + test('get: repo', () => { let entry = trie.get(repoGL.fsPath); assert.strictEqual(entry?.path, basename(repoGL.path)); assert.strictEqual(entry?.fullPath, repoGL.path); @@ -110,7 +110,7 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(trie.get('D:\\Users\\Name\\code\\gitkraken\\vscode-gitlens'), undefined); }); - it('get: repo (ignore case)', () => { + test('get: repo (ignore case)', () => { let entry = trie.get(repoGL.fsPath.toUpperCase()); assert.strictEqual(entry?.path, basename(repoGL.path)); assert.strictEqual(entry?.fullPath, repoGL.path); @@ -130,7 +130,7 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(entry?.value?.path, repoVSC.path); }); - it('get: file', () => { + test('get: file', () => { let entry = trie.get(`${repoGL.fsPath}\\src\\extension.ts`); assert.strictEqual(entry?.path, 'extension.ts'); assert.strictEqual(entry?.fullPath, `${repoGL.path}/src/extension.ts`); @@ -149,7 +149,7 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(entry?.value?.path, `${repoVSC.fsPath}\\src\\main.ts`); }); - it('get: file (ignore case)', () => { + test('get: file (ignore case)', () => { let entry = trie.get(`${repoGL.fsPath}\\src\\extension.ts`.toLocaleUpperCase()); assert.strictEqual(entry?.path, 'extension.ts'); assert.strictEqual(entry?.fullPath, `${repoGL.path}/src/extension.ts`); @@ -166,13 +166,13 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(entry?.value?.path, `${repoVSC.fsPath}\\src\\main.ts`); }); - it('get: folder (failure case)', () => { + test('get: folder (failure case)', () => { assert.strictEqual(trie.get(`${repoGL.fsPath}\\src`), undefined); assert.strictEqual(trie.get(`${repoNested.fsPath}\\src`), undefined); assert.strictEqual(trie.get(`${repoVSC.fsPath}\\src`), undefined); }); - it('getClosest: repo file', () => { + test('getClosest: repo file', () => { let entry = trie.getClosest(`${repoGL.fsPath}\\src\\extension.ts`, true); assert.strictEqual(entry?.path, repoGL.name); assert.strictEqual(entry?.fullPath, repoGL.path); @@ -189,7 +189,7 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(entry?.value?.path, repoVSC.path); }); - it('getClosest: repo file (ignore case)', () => { + test('getClosest: repo file (ignore case)', () => { let entry = trie.getClosest(`${repoGL.fsPath}\\src\\extension.ts`.toUpperCase(), true); assert.strictEqual(entry?.path, repoGL.name); assert.strictEqual(entry?.fullPath, repoGL.path); @@ -206,7 +206,7 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(entry?.value?.path, repoVSC.path); }); - it('getClosest: missing path but inside repo', () => { + test('getClosest: missing path but inside repo', () => { let entry = trie.getClosest(`${repoGL.fsPath}\\src\\foo\\bar\\baz.ts`.toUpperCase()); assert.strictEqual(entry?.path, repoGL.name); assert.strictEqual(entry?.fullPath, repoGL.path); @@ -223,12 +223,12 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(entry?.value?.path, repoVSC.path); }); - it('getClosest: missing path', () => { + test('getClosest: missing path', () => { const entry = trie.getClosest('C:\\Users\\Name\\code\\company\\repo\\foo\\bar\\baz.ts'); assert.strictEqual(entry, undefined); }); - it('getClosest: repo', () => { + test('getClosest: repo', () => { let entry = trie.getClosest(repoGL.fsPath); assert.strictEqual(entry?.path, repoGL.name); assert.strictEqual(entry?.fullPath, repoGL.path); @@ -245,14 +245,14 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(entry?.value?.path, repoVSC.path); }); - it('delete file', () => { + test('delete file', () => { const file = `${repoVSC.fsPath}\\src\\main.ts`; assert.strictEqual(trie.has(file), true); assert.strictEqual(trie.delete(file), true); assert.strictEqual(trie.has(file), false); }); - it('delete repo', () => { + test('delete repo', () => { const repo = repoGL.fsPath; assert.strictEqual(trie.has(repo), true); assert.strictEqual(trie.delete(repo), true); @@ -261,14 +261,14 @@ describe('PathEntryTrie Test Suite', () => { assert.strictEqual(trie.has(repoNested.fsPath), true); }); - it('delete missing', () => { + test('delete missing', () => { const file = `${repoGL.fsPath}\\src\\foo\\bar\\baz.ts`; assert.strictEqual(trie.has(file), false); assert.strictEqual(trie.delete(file), false); assert.strictEqual(trie.has(file), false); }); - it('clear', () => { + test('clear', () => { assert.strictEqual(trie.has(repoVSC.fsPath), true); trie.clear(); @@ -286,36 +286,36 @@ describe('PathEntryTrie Test Suite', () => { }); }); -describe('UriEntryTrie Test Suite', () => { +suite.skip('UriEntryTrie Test Suite', () => { type Repo = { type: 'repo'; name: string; uri: Uri; fsPath: string }; type File = { type: 'file'; name: string; uri: Uri }; - const repoGL: Repo = { + const repoGL = { type: 'repo', name: 'vscode-gitlens', uri: Uri.file('c:/Users/Name/code/gitkraken/vscode-gitlens'), fsPath: 'c:/Users/Name/code/gitkraken/vscode-gitlens', - }; - const repoNested: Repo = { + } as const satisfies Repo; + const repoNested = { type: 'repo', name: 'repo', uri: Uri.file('c:/Users/Name/code/gitkraken/vscode-gitlens/nested/repo'), fsPath: 'c:/Users/Name/code/gitkraken/vscode-gitlens/nested/repo', - }; - const repoGLvfs: Repo = { + } as const satisfies Repo; + const repoGLvfs = { type: 'repo', name: 'vscode-gitlens', uri: Uri.parse('vscode-vfs://github/gitkraken/vscode-gitlens'), fsPath: 'github/gitkraken/vscode-gitlens', - }; - const repoVSCvfs: Repo = { + } as const satisfies Repo; + const repoVSCvfs = { type: 'repo', name: 'vscode', uri: Uri.parse('vscode-vfs://github/microsoft/vscode'), fsPath: 'github/microsoft/vscode', - }; + } as const satisfies Repo; - const trie = new UriEntryTrie(); + const trie = new UriEntryTrie(_ => ({ ignoreCase: false, path: '' })); function assertRepoEntry(actual: UriEntry | undefined, expected: Repo): void { assert.strictEqual(actual?.path, expected.name); @@ -366,7 +366,7 @@ describe('UriEntryTrie Test Suite', () => { } }); - it('has(file://): repo', () => { + test('has(file://): repo', () => { assert.strictEqual(trie.has(repoGL.uri), true); assert.strictEqual(trie.has(repoGL.uri.with({ path: repoGL.uri.path.toUpperCase() })), !isLinux); assert.strictEqual(trie.has(repoNested.uri), true); @@ -376,14 +376,14 @@ describe('UriEntryTrie Test Suite', () => { assert.strictEqual(trie.has(Uri.file('D:\\Users\\Name\\code\\gitkraken\\vscode-gitlens')), false); }); - it('has(file://): file', () => { + test('has(file://): file', () => { assert.strictEqual(trie.has(Uri.file(`${repoGL.fsPath}/src/extension.ts`)), true); assert.strictEqual(trie.has(Uri.file(`${repoGL.fsPath}/foo/bar/baz.ts`)), false); assert.strictEqual(trie.has(Uri.file(`${repoNested.fsPath}/src/index.ts`)), true); }); - it('has(vscode-vfs://): repo', () => { + test('has(vscode-vfs://): repo', () => { assert.strictEqual(trie.has(repoGLvfs.uri), true); assert.strictEqual(trie.has(repoGLvfs.uri.with({ path: repoGLvfs.uri.path.toUpperCase() })), false); assert.strictEqual(trie.has(repoVSCvfs.uri), true); @@ -393,7 +393,7 @@ describe('UriEntryTrie Test Suite', () => { assert.strictEqual(trie.has(repoGLvfs.uri.with({ authority: 'azdo' })), false); }); - it('has(vscode-vfs://): file', () => { + test('has(vscode-vfs://): file', () => { assert.strictEqual(trie.has(Uri.joinPath(repoGLvfs.uri, 'src/extension.ts')), true); assert.strictEqual(trie.has(Uri.joinPath(repoGLvfs.uri, 'foo/bar/baz.ts')), false); assert.strictEqual(trie.has(Uri.joinPath(repoVSCvfs.uri, 'src/main.ts')), true); @@ -407,7 +407,7 @@ describe('UriEntryTrie Test Suite', () => { ); }); - it('has(github://): repo', () => { + test('has(github://): repo', () => { assert.strictEqual(trie.has(repoGLvfs.uri.with({ scheme: 'github' })), true); assert.strictEqual( trie.has(repoGLvfs.uri.with({ scheme: 'github', path: repoGLvfs.uri.path.toUpperCase() })), @@ -423,7 +423,7 @@ describe('UriEntryTrie Test Suite', () => { assert.strictEqual(trie.has(repoGLvfs.uri.with({ scheme: 'github', authority: 'azdo' })), false); }); - it('has(github://): file', () => { + test('has(github://): file', () => { assert.strictEqual(trie.has(Uri.joinPath(repoGLvfs.uri, 'src/extension.ts').with({ scheme: 'github' })), true); assert.strictEqual(trie.has(Uri.joinPath(repoGLvfs.uri, 'foo/bar/baz.ts').with({ scheme: 'github' })), false); assert.strictEqual(trie.has(Uri.joinPath(repoVSCvfs.uri, 'src/main.ts').with({ scheme: 'github' })), true); @@ -444,7 +444,7 @@ describe('UriEntryTrie Test Suite', () => { ); }); - // it('has(gitlens://): repo', () => { + // test('has(gitlens://): repo', () => { // assert.strictEqual( // trie.has( // repoGL.uri.with({ @@ -508,7 +508,7 @@ describe('UriEntryTrie Test Suite', () => { // ); // }); - // it('has(gitlens://): file', () => { + // test('has(gitlens://): file', () => { // assert.strictEqual( // trie.has( // Uri.joinPath(repoGL.uri, 'src/extension.ts').with({ @@ -541,7 +541,7 @@ describe('UriEntryTrie Test Suite', () => { // ); // }); - it('get(file://): repo', () => { + test('get(file://): repo', () => { assertRepoEntry(trie.get(repoGL.uri), repoGL); assertRepoEntry(trie.get(repoNested.uri), repoNested); @@ -549,7 +549,7 @@ describe('UriEntryTrie Test Suite', () => { assert.strictEqual(trie.get(Uri.file('D:\\Users\\Name\\code\\gitkraken\\vscode-gitlens')), undefined); }); - it('get(vscode-vfs://): repo', () => { + test('get(vscode-vfs://): repo', () => { assertRepoEntry(trie.get(repoGLvfs.uri), repoGLvfs); assertRepoEntry(trie.get(repoVSCvfs.uri), repoVSCvfs); @@ -557,12 +557,12 @@ describe('UriEntryTrie Test Suite', () => { assert.strictEqual(trie.get(Uri.file('D:\\Users\\Name\\code\\gitkraken\\vscode-gitlens')), undefined); }); - it('get(github://): repo', () => { + test('get(github://): repo', () => { assertRepoEntry(trie.get(repoGLvfs.uri.with({ scheme: 'github' })), repoGLvfs); assertRepoEntry(trie.get(repoVSCvfs.uri.with({ scheme: 'github' })), repoVSCvfs); }); - // it('get(gitlens://): repo', () => { + // test('get(gitlens://): repo', () => { // assertRepoEntry( // trie.get( // repoGL.uri.with({ @@ -586,7 +586,7 @@ describe('UriEntryTrie Test Suite', () => { // ); // }); - it('get(file://): repo (ignore case)', () => { + test('get(file://): repo (ignore case)', () => { assertRepoEntryIgnoreCase(trie.get(repoGL.uri.with({ path: repoGL.uri.path.toUpperCase() })), repoGL); assertRepoEntryIgnoreCase( trie.get(repoNested.uri.with({ path: repoNested.uri.path.toUpperCase() })), @@ -594,7 +594,7 @@ describe('UriEntryTrie Test Suite', () => { ); }); - it('get(vscode://): repo (ignore case)', () => { + test('get(vscode://): repo (ignore case)', () => { assertRepoEntry(trie.get(repoGLvfs.uri.with({ scheme: 'VSCODE-VFS' })), repoGLvfs); assert.strictEqual( @@ -604,7 +604,7 @@ describe('UriEntryTrie Test Suite', () => { assert.strictEqual(trie.get(repoGLvfs.uri.with({ path: repoGLvfs.uri.path.toUpperCase() })), undefined); }); - it('get(github://): repo (ignore case)', () => { + test('get(github://): repo (ignore case)', () => { assertRepoEntry(trie.get(repoGLvfs.uri.with({ scheme: 'GITHUB' })), repoGLvfs); assert.strictEqual( @@ -617,7 +617,7 @@ describe('UriEntryTrie Test Suite', () => { ); }); - // it('get(gitlens://): repo (ignore case)', () => { + // test('get(gitlens://): repo (ignore case)', () => { // assertRepoEntry( // trie.get( // repoGL.uri.with({ @@ -646,7 +646,7 @@ describe('UriEntryTrie Test Suite', () => { // ); // }); - it('get(file://): file', () => { + test('get(file://): file', () => { let uri = Uri.joinPath(repoGL.uri, 'src/extension.ts'); assertFileEntry(trie.get(uri), uri); @@ -656,17 +656,17 @@ describe('UriEntryTrie Test Suite', () => { assertFileEntry(trie.get(uri), uri); }); - it('get(vscode-vfs://): file', () => { + test('get(vscode-vfs://): file', () => { const uri = Uri.joinPath(repoGLvfs.uri, 'src/extension.ts'); assertFileEntry(trie.get(uri), uri); }); - it('get(github://): file', () => { + test('get(github://): file', () => { const uri = Uri.joinPath(repoGLvfs.uri, 'src/extension.ts'); assertFileEntry(trie.get(uri.with({ scheme: 'github' })), uri); }); - // it('get(gitlens://): file', () => { + // test('get(gitlens://): file', () => { // const uri = Uri.joinPath(repoGL.uri, 'src/extension.ts'); // assertFileEntry( // trie.get( @@ -680,36 +680,36 @@ describe('UriEntryTrie Test Suite', () => { // ); // }); - it('get: missing file', () => { + test('get: missing file', () => { assert.strictEqual(trie.get(Uri.joinPath(repoGL.uri, 'foo/bar/baz.ts')), undefined); }); - it('getClosest(file://): repo', () => { + test('getClosest(file://): repo', () => { assertRepoEntry(trie.getClosest(repoGL.uri), repoGL); assertRepoEntry(trie.getClosest(repoNested.uri), repoNested); }); - it('getClosest(vscode-vfs://): repo', () => { + test('getClosest(vscode-vfs://): repo', () => { assertRepoEntry(trie.getClosest(repoGLvfs.uri), repoGLvfs); assertRepoEntry(trie.getClosest(repoVSCvfs.uri), repoVSCvfs); }); - it('getClosest(file://): file', () => { + test('getClosest(file://): file', () => { assertRepoEntry(trie.getClosest(Uri.joinPath(repoGL.uri, 'src/extension.ts'), true), repoGL); }); - it('getClosest(vscode-vfs://): file', () => { + test('getClosest(vscode-vfs://): file', () => { assertRepoEntry(trie.getClosest(Uri.joinPath(repoGLvfs.uri, 'src/extension.ts'), true), repoGLvfs); }); - it('getClosest(github://): file', () => { + test('getClosest(github://): file', () => { assertRepoEntry( trie.getClosest(Uri.joinPath(repoGLvfs.uri, 'src/extension.ts').with({ scheme: 'github' }), true), repoGLvfs, ); }); - // it('getClosest(gitlens://): file', () => { + // test('getClosest(gitlens://): file', () => { // assertRepoEntry( // trie.getClosest( // Uri.joinPath(repoGL.uri, 'src/extension.ts').with({ @@ -723,22 +723,22 @@ describe('UriEntryTrie Test Suite', () => { // ); // }); - it('getClosest(file://): missing repo file', () => { + test('getClosest(file://): missing repo file', () => { assertRepoEntry(trie.getClosest(Uri.joinPath(repoGL.uri, 'foo/bar/baz.ts'), true), repoGL); }); - it('getClosest(vscode-vfs://): missing repo file', () => { + test('getClosest(vscode-vfs://): missing repo file', () => { assertRepoEntry(trie.getClosest(Uri.joinPath(repoGLvfs.uri, 'foo/bar/baz.ts'), true), repoGLvfs); }); - it('getClosest(github://): missing repo file', () => { + test('getClosest(github://): missing repo file', () => { assertRepoEntry( trie.getClosest(Uri.joinPath(repoGLvfs.uri, 'foo/bar/baz.ts').with({ scheme: 'github' }), true), repoGLvfs, ); }); - // it('getClosest(gitlens://): missing repo file', () => { + // test('getClosest(gitlens://): missing repo file', () => { // assertRepoEntry( // trie.getClosest( // Uri.joinPath(repoGL.uri, 'src/extension.ts').with({ @@ -774,7 +774,7 @@ describe('UriEntryTrie Test Suite', () => { // ); // }); - it("getClosest: path doesn't exists anywhere", () => { + test("getClosest: path doesn't exists anywhere", () => { assert.strictEqual( trie.getClosest(Uri.file('C:\\Users\\Name\\code\\company\\repo\\foo\\bar\\baz.ts')), undefined, @@ -782,33 +782,33 @@ describe('UriEntryTrie Test Suite', () => { }); }); -describe('UriTrie(Repositories) Test Suite', () => { +suite.skip('UriTrie(Repositories) Test Suite', () => { type Repo = { type: 'repo'; name: string; uri: Uri; fsPath: string }; - const repoGL: Repo = { + const repoGL = { type: 'repo', name: 'vscode-gitlens', uri: Uri.file('c:/Users/Name/code/gitkraken/vscode-gitlens'), fsPath: 'c:/Users/Name/code/gitkraken/vscode-gitlens', - }; - const repoNested: Repo = { + } as const satisfies Repo; + const repoNested = { type: 'repo', name: 'repo', uri: Uri.file('c:/Users/Name/code/gitkraken/vscode-gitlens/nested/repo'), fsPath: 'c:/Users/Name/code/gitkraken/vscode-gitlens/nested/repo', - }; - const repoGLvfs: Repo = { + } as const satisfies Repo; + const repoGLvfs = { type: 'repo', name: 'vscode-gitlens', uri: Uri.parse('vscode-vfs://github/gitkraken/vscode-gitlens'), fsPath: 'github/gitkraken/vscode-gitlens', - }; - const repoVSCvfs: Repo = { + } as const satisfies Repo; + const repoVSCvfs = { type: 'repo', name: 'vscode', uri: Uri.parse('vscode-vfs://github/microsoft/vscode'), fsPath: 'github/microsoft/vscode', - }; + } as const satisfies Repo; const trie = new UriTrie(normalizeRepoUri); @@ -837,7 +837,7 @@ describe('UriTrie(Repositories) Test Suite', () => { trie.set(repoVSCvfs.uri, repoVSCvfs); }); - it('has(file://)', () => { + test('has(file://)', () => { assert.strictEqual(trie.has(repoGL.uri), true); assert.strictEqual(trie.has(repoGL.uri.with({ path: repoGL.uri.path.toUpperCase() })), !isLinux); assert.strictEqual(trie.has(repoNested.uri), true); @@ -847,7 +847,7 @@ describe('UriTrie(Repositories) Test Suite', () => { assert.strictEqual(trie.has(Uri.file('D:\\Users\\Name\\code\\gitkraken\\vscode-gitlens')), false); }); - it('has(vscode-vfs://)', () => { + test('has(vscode-vfs://)', () => { assert.strictEqual(trie.has(repoGLvfs.uri), true); assert.strictEqual(trie.has(repoGLvfs.uri.with({ path: repoGLvfs.uri.path.toUpperCase() })), false); assert.strictEqual(trie.has(repoVSCvfs.uri), true); @@ -857,7 +857,7 @@ describe('UriTrie(Repositories) Test Suite', () => { assert.strictEqual(trie.has(repoGLvfs.uri.with({ authority: 'azdo' })), false); }); - it('has(github://)', () => { + test('has(github://)', () => { assert.strictEqual(trie.has(repoGLvfs.uri.with({ scheme: 'github' })), true); assert.strictEqual( trie.has(repoGLvfs.uri.with({ scheme: 'github', path: repoGLvfs.uri.path.toUpperCase() })), @@ -873,7 +873,7 @@ describe('UriTrie(Repositories) Test Suite', () => { assert.strictEqual(trie.has(repoGLvfs.uri.with({ scheme: 'github', authority: 'azdo' })), false); }); - it('has(gitlens://)', () => { + test('has(gitlens://)', () => { assert.strictEqual( trie.has( repoGL.uri.with({ @@ -937,7 +937,7 @@ describe('UriTrie(Repositories) Test Suite', () => { ); }); - it('get(file://)', () => { + test('get(file://)', () => { assertRepoEntry(trie.get(repoGL.uri), repoGL); assertRepoEntry(trie.get(repoNested.uri), repoNested); @@ -945,7 +945,7 @@ describe('UriTrie(Repositories) Test Suite', () => { assert.strictEqual(trie.get(Uri.file('D:\\Users\\Name\\code\\gitkraken\\vscode-gitlens')), undefined); }); - it('get(vscode-vfs://)', () => { + test('get(vscode-vfs://)', () => { assertRepoEntry(trie.get(repoGLvfs.uri), repoGLvfs); assertRepoEntry(trie.get(repoVSCvfs.uri), repoVSCvfs); @@ -953,12 +953,12 @@ describe('UriTrie(Repositories) Test Suite', () => { assert.strictEqual(trie.get(Uri.file('D:\\Users\\Name\\code\\gitkraken\\vscode-gitlens')), undefined); }); - it('get(github://)', () => { + test('get(github://)', () => { assertRepoEntry(trie.get(repoGLvfs.uri.with({ scheme: 'github' })), repoGLvfs); assertRepoEntry(trie.get(repoVSCvfs.uri.with({ scheme: 'github' })), repoVSCvfs); }); - it('get(gitlens://)', () => { + test('get(gitlens://)', () => { assertRepoEntry( trie.get( repoGL.uri.with({ @@ -982,7 +982,7 @@ describe('UriTrie(Repositories) Test Suite', () => { ); }); - it('get(file://) (ignore case)', () => { + test('get(file://) (ignore case)', () => { assertRepoEntryIgnoreCase(trie.get(repoGL.uri.with({ path: repoGL.uri.path.toUpperCase() })), repoGL); assertRepoEntryIgnoreCase( trie.get(repoNested.uri.with({ path: repoNested.uri.path.toUpperCase() })), @@ -990,7 +990,7 @@ describe('UriTrie(Repositories) Test Suite', () => { ); }); - it('get(vscode://) (ignore case)', () => { + test('get(vscode://) (ignore case)', () => { assertRepoEntry(trie.get(repoGLvfs.uri.with({ scheme: 'VSCODE-VFS' })), repoGLvfs); assert.strictEqual( @@ -1000,7 +1000,7 @@ describe('UriTrie(Repositories) Test Suite', () => { assert.strictEqual(trie.get(repoGLvfs.uri.with({ path: repoGLvfs.uri.path.toUpperCase() })), undefined); }); - it('get(github://) (ignore case)', () => { + test('get(github://) (ignore case)', () => { assertRepoEntry(trie.get(repoGLvfs.uri.with({ scheme: 'GITHUB' })), repoGLvfs); assert.strictEqual( @@ -1013,7 +1013,7 @@ describe('UriTrie(Repositories) Test Suite', () => { ); }); - it('get(gitlens://) (ignore case)', () => { + test('get(gitlens://) (ignore case)', () => { assertRepoEntry( trie.get( repoGL.uri.with({ @@ -1042,7 +1042,7 @@ describe('UriTrie(Repositories) Test Suite', () => { ); }); - it('getClosest(file://)', () => { + test('getClosest(file://)', () => { assertRepoEntry(trie.getClosest(repoGL.uri), repoGL); assert.strictEqual(trie.getClosest(repoGL.uri, true), undefined); assertRepoEntry(trie.getClosest(repoNested.uri), repoNested); @@ -1052,7 +1052,7 @@ describe('UriTrie(Repositories) Test Suite', () => { assertRepoEntry(trie.getClosest(Uri.joinPath(repoNested.uri, 'src/index.ts')), repoNested); }); - it('getClosest(vscode-vfs://)', () => { + test('getClosest(vscode-vfs://)', () => { assertRepoEntry(trie.getClosest(repoGLvfs.uri), repoGLvfs); assert.strictEqual(trie.getClosest(repoGLvfs.uri, true), undefined); assertRepoEntry(trie.getClosest(repoVSCvfs.uri), repoVSCvfs); @@ -1062,7 +1062,7 @@ describe('UriTrie(Repositories) Test Suite', () => { assertRepoEntry(trie.getClosest(Uri.joinPath(repoVSCvfs.uri, 'src/main.ts'), true), repoVSCvfs); }); - it('getClosest(github://)', () => { + test('getClosest(github://)', () => { const repoGLvfsUri = repoGLvfs.uri.with({ scheme: 'github' }); const repoVSCvfsUri = repoVSCvfs.uri.with({ scheme: 'github' }); @@ -1075,7 +1075,7 @@ describe('UriTrie(Repositories) Test Suite', () => { assertRepoEntry(trie.getClosest(Uri.joinPath(repoVSCvfsUri, 'src/main.ts'), true), repoVSCvfs); }); - it('getClosest(gitlens://)', () => { + test('getClosest(gitlens://)', () => { const repoGLUri = Uri.joinPath(repoGL.uri, 'src/extension.ts').with({ scheme: 'gitlens', authority: 'abcd', @@ -1091,14 +1091,14 @@ describe('UriTrie(Repositories) Test Suite', () => { assertRepoEntry(trie.getClosest(repoNestedUri), repoNested); }); - it('getClosest: missing', () => { + test('getClosest: missing', () => { assert.strictEqual( trie.getClosest(Uri.file('C:\\Users\\Name\\code\\company\\repo\\foo\\bar\\baz.ts')), undefined, ); }); - it('getDescendants', () => { + test('getDescendants', () => { const descendants = [...trie.getDescendants()]; assert.strictEqual(descendants.length, 4); }); diff --git a/src/system/array.ts b/src/system/array.ts index 6292f832ef1e2..2b474c2e0fabe 100644 --- a/src/system/array.ts +++ b/src/system/array.ts @@ -28,10 +28,24 @@ export function countUniques(source: T[], accessor: (item: T) => string): Rec return uniqueCounts; } -export function ensure(source: T | T[] | undefined): T[] | undefined { +export function ensureArray(source: T | T[]): T[]; +export function ensureArray(source: T | T[] | undefined): T[] | undefined; +export function ensureArray(source: T | T[] | undefined): T[] | undefined { return source == null ? undefined : Array.isArray(source) ? source : [source]; } +export async function filterAsync(source: T[], predicate: (item: T) => Promise): Promise { + const predicates = source.map>(i => predicate(i).then(r => [r, i])); + + const filtered = []; + for await (const [include, item] of predicates) { + if (!include) continue; + + filtered.push(item); + } + return filtered; +} + export function filterMap( source: T[], predicateMapper: (item: T, index: number) => TMapped | null | undefined, @@ -46,20 +60,19 @@ export function filterMap( }, []); } -export function filterMapAsync( +export async function filterMapAsync( source: T[], - predicateMapper: (item: T, index: number) => Promise, + predicateMapper: (item: T) => Promise, ): Promise { - let index = 0; - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return source.reduce(async (accumulator, current: T) => { - const mapped = await predicateMapper(current, index++); - if (mapped != null) { - accumulator.push(mapped); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return accumulator; - }, []); + const items = source.map(predicateMapper); + + const filteredAndMapped = []; + for await (const item of items) { + if (item == null) continue; + + filteredAndMapped.push(item); + } + return filteredAndMapped; } export function findLastIndex(source: T[], predicate: (value: T, index: number, obj: T[]) => boolean): number { @@ -70,55 +83,6 @@ export function findLastIndex(source: T[], predicate: (value: T, index: numbe return -1; } -export function groupBy(source: readonly T[], groupingKey: (item: T) => string): Record { - return source.reduce>((groupings, current) => { - const value = groupingKey(current); - const group = groupings[value]; - if (group === undefined) { - groupings[value] = [current]; - } else { - group.push(current); - } - return groupings; - }, Object.create(null)); -} - -export function groupByMap( - source: readonly TValue[], - groupingKey: (item: TValue) => TKey, -): Map { - return source.reduce((groupings, current) => { - const value = groupingKey(current); - const group = groupings.get(value); - if (group === undefined) { - groupings.set(value, [current]); - } else { - group.push(current); - } - return groupings; - }, new Map()); -} - -export function groupByFilterMap( - source: readonly TValue[], - groupingKey: (item: TValue) => TKey, - predicateMapper: (item: TValue) => TMapped | null | undefined, -): Map { - return source.reduce((groupings, current) => { - const mapped = predicateMapper(current); - if (mapped != null) { - const value = groupingKey(current); - const group = groupings.get(value); - if (group === undefined) { - groupings.set(value, [mapped]); - } else { - group.push(mapped); - } - } - return groupings; - }, new Map()); -} - export function intersection(sources: T[][], comparator: (a: T, b: T) => boolean): T[] { const results: T[] = []; @@ -242,23 +206,22 @@ export function joinUnique(source: readonly T[], separator: string): string { return join(new Set(source), separator); } -export function uniqueBy( - source: readonly TValue[], - uniqueKey: (item: TValue) => TKey, - onDuplicate: (original: TValue, current: TValue) => TValue | void, -): TValue[] { - const map = source.reduce((uniques, current) => { - const value = uniqueKey(current); - const original = uniques.get(value); - if (original === undefined) { - uniques.set(value, current); - } else { - const updated = onDuplicate(original, current); - if (updated !== undefined) { - uniques.set(value, updated); - } - } - return uniques; - }, new Map()); - return [...map.values()]; +export async function mapAsync( + source: T[], + mapper: (item: T) => TMapped | Promise, + predicate?: (item: TMapped) => boolean, +): Promise[]> { + const items = source.map(mapper); + + const mapped = []; + for await (const item of items) { + if (item == null || (predicate != null && !predicate(item))) continue; + + mapped.push(item); + } + return mapped; +} + +export function splitAt(source: T[], index: number): [T[], T[]] { + return index < 0 ? [source, []] : [source.slice(0, index), source.slice(index)]; } diff --git a/src/system/brand.ts b/src/system/brand.ts new file mode 100644 index 0000000000000..0c6f0c8f4d7cd --- /dev/null +++ b/src/system/brand.ts @@ -0,0 +1,9 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention +declare const __brand: unique symbol; +// eslint-disable-next-line @typescript-eslint/naming-convention +declare const __base: unique symbol; + +type _Brand = { [__brand]: B; [__base]: Base }; +export type Branded = Base & _Brand; +export type Brand> = B extends Branded ? B : never; +export type Unbrand = T extends _Brand ? Base : never; diff --git a/src/system/color.ts b/src/system/color.ts index 1be3d93e53c0b..f7612d012bfdc 100644 --- a/src/system/color.ts +++ b/src/system/color.ts @@ -1,27 +1,5 @@ import { CharCode } from '../constants'; -function adjustLight(color: number, amount: number) { - const cc = color + amount; - const c = amount < 0 ? (cc < 0 ? 0 : cc) : cc > 255 ? 255 : cc; - - return Math.round(c); -} - -// TODO@d13 leaving as is for now, updating to the color library breaks our existing darkened colors -export function darken(color: string, percentage: number) { - return lighten(color, -percentage); -} - -// TODO@d13 leaving as is for now, updating to the color library breaks our existing lightened colors -export function lighten(color: string, percentage: number) { - const rgba = toRgba(color); - if (rgba == null) return color; - - const [r, g, b, a] = rgba; - const amount = (255 * percentage) / 100; - return `rgba(${adjustLight(r, amount)}, ${adjustLight(g, amount)}, ${adjustLight(b, amount)}, ${a})`; -} - export function opacity(color: string, percentage: number) { const rgba = Color.from(color); if (rgba == null) return color; @@ -358,6 +336,10 @@ export class HSVA { } } +export function getCssVariable(variable: string, css: { getPropertyValue(property: string): string }): string { + return css.getPropertyValue(variable).trim(); +} + export class Color { static from(value: string | Color): Color { if (value instanceof Color) return value; @@ -366,7 +348,7 @@ export class Color { } static fromCssVariable(variable: string, css: { getPropertyValue(property: string): string }): Color { - return parseColor(css.getPropertyValue(variable).trim()) || Color.red; + return parseColor(getCssVariable(variable, css)) || Color.red; } static fromHex(hex: string): Color { @@ -677,14 +659,12 @@ export function format(color: Color): string { return formatRGBA(color); } -const cssColorRegex = /^((?:rgb|hsl)a?)\((-?\d+%?)[,\s]+(-?\d+%?)[,\s]+(-?\d+%?)[,\s]*(-?[\d.]+%?)?\)$/i; +const cssColorRegex = /^((?:rgb|hsl)a?)\((-?\d+(?:%|deg)?)[,\s]+(-?\d+%?)[,\s]+(-?\d+%?)[,\s]*(-?[\d.]+%?)?\)$/i; export function parseColor(value: string): Color | null { - const length = value.length; + value = value.trim(); // Invalid color - if (length === 0) { - return null; - } + if (value.length === 0) return null; // Begin with a # if (value.charCodeAt(0) === CharCode.Hash) { @@ -692,9 +672,7 @@ export function parseColor(value: string): Color | null { } const result = cssColorRegex.exec(value); - if (result == null) { - return null; - } + if (result == null) return null; const mode = result[1]; let colors: number[]; @@ -730,54 +708,51 @@ export function parseColor(value: string): Color | null { */ export function parseHexColor(hex: string): Color | null { hex = hex.trim(); - const length = hex.length; - if (length === 0) { - // Invalid color - return null; - } + // Invalid color + if (length === 0) return null; + + // Begin with a # if (hex.charCodeAt(0) !== CharCode.Hash) { - // Does not begin with a # return null; } - if (length === 7) { - // #RRGGBB format - const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); - const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); - const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); - return new Color(new RGBA(r, g, b, 1)); - } - - if (length === 9) { - // #RRGGBBAA format - const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); - const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); - const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); - const a = 16 * _parseHexDigit(hex.charCodeAt(7)) + _parseHexDigit(hex.charCodeAt(8)); - return new Color(new RGBA(r, g, b, a / 255)); - } - - if (length === 4) { - // #RGB format - const r = _parseHexDigit(hex.charCodeAt(1)); - const g = _parseHexDigit(hex.charCodeAt(2)); - const b = _parseHexDigit(hex.charCodeAt(3)); - return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b)); - } - - if (length === 5) { - // #RGBA format - const r = _parseHexDigit(hex.charCodeAt(1)); - const g = _parseHexDigit(hex.charCodeAt(2)); - const b = _parseHexDigit(hex.charCodeAt(3)); - const a = _parseHexDigit(hex.charCodeAt(4)); - return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b, (16 * a + a) / 255)); + switch (length) { + case 7: { + // #RRGGBB format + const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); + const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); + const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); + return new Color(new RGBA(r, g, b, 1)); + } + case 9: { + // #RRGGBBAA format + const r = 16 * _parseHexDigit(hex.charCodeAt(1)) + _parseHexDigit(hex.charCodeAt(2)); + const g = 16 * _parseHexDigit(hex.charCodeAt(3)) + _parseHexDigit(hex.charCodeAt(4)); + const b = 16 * _parseHexDigit(hex.charCodeAt(5)) + _parseHexDigit(hex.charCodeAt(6)); + const a = 16 * _parseHexDigit(hex.charCodeAt(7)) + _parseHexDigit(hex.charCodeAt(8)); + return new Color(new RGBA(r, g, b, a / 255)); + } + case 4: { + // #RGB format + const r = _parseHexDigit(hex.charCodeAt(1)); + const g = _parseHexDigit(hex.charCodeAt(2)); + const b = _parseHexDigit(hex.charCodeAt(3)); + return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b)); + } + case 5: { + // #RGBA format + const r = _parseHexDigit(hex.charCodeAt(1)); + const g = _parseHexDigit(hex.charCodeAt(2)); + const b = _parseHexDigit(hex.charCodeAt(3)); + const a = _parseHexDigit(hex.charCodeAt(4)); + return new Color(new RGBA(16 * r + r, 16 * g + g, 16 * b + b, (16 * a + a) / 255)); + } + default: + // Invalid color + return null; } - - // Invalid color - return null; } function _parseHexDigit(charCode: CharCode): number { diff --git a/src/comparers.ts b/src/system/comparers.ts similarity index 100% rename from src/comparers.ts rename to src/system/comparers.ts diff --git a/src/system/counter.ts b/src/system/counter.ts new file mode 100644 index 0000000000000..7338fe80f6911 --- /dev/null +++ b/src/system/counter.ts @@ -0,0 +1,18 @@ +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) + +export type Counter = { readonly current: number; next(): number }; + +export function getScopedCounter(): Counter { + let counter = 0; + return { + get current() { + return counter; + }, + next: function () { + if (counter === maxSmallIntegerV8) { + counter = 0; + } + return ++counter; + }, + }; +} diff --git a/src/system/date.ts b/src/system/date.ts index b2e1085921aa6..f5bb490a4781b 100644 --- a/src/system/date.ts +++ b/src/system/date.ts @@ -26,7 +26,7 @@ const numberFormatCache = new Map(); export function setDefaultDateLocales(locales: string | string[] | null | undefined) { if (typeof locales === 'string') { - if (locales === 'system') { + if (locales === 'system' || locales.trim().length === 0) { defaultLocales = undefined; } else { defaultLocales = [locales]; @@ -210,8 +210,7 @@ export function formatDate( ) => { if (literal != null) return (literal as string).substring(1, literal.length - 1); - for (const key in groups) { - const value = groups[key]; + for (const [key, value] of Object.entries(groups)) { if (value == null) continue; const part = parts.find(p => p.type === key); @@ -237,19 +236,21 @@ export function getDateDifference( first: Date | number, second: Date | number, unit?: 'days' | 'hours' | 'minutes' | 'seconds', + roundFn?: (value: number) => number, ): number { const diff = (typeof second === 'number' ? second : second.getTime()) - (typeof first === 'number' ? first : first.getTime()); + const round = roundFn ?? Math.floor; switch (unit) { case 'days': - return Math.floor(diff / (1000 * 60 * 60 * 24)); + return round(diff / (1000 * 60 * 60 * 24)); case 'hours': - return Math.floor(diff / (1000 * 60 * 60)); + return round(diff / (1000 * 60 * 60)); case 'minutes': - return Math.floor(diff / (1000 * 60)); + return round(diff / (1000 * 60)); case 'seconds': - return Math.floor(diff / 1000); + return round(diff / 1000); default: return diff; } @@ -275,8 +276,7 @@ function getDateTimeFormatOptionsFromFormatString( for (const { groups } of format.matchAll(customDateTimeFormatParserRegex)) { if (groups == null) continue; - for (const key in groups) { - const value = groups[key]; + for (const [key, value] of Object.entries(groups)) { if (value == null) continue; switch (key) { diff --git a/src/system/decorators/gate.ts b/src/system/decorators/gate.ts index 3701bfc71113e..0858689c0b1f0 100644 --- a/src/system/decorators/gate.ts +++ b/src/system/decorators/gate.ts @@ -3,7 +3,8 @@ import { isPromise } from '../promise'; import { resolveProp } from './resolver'; export function gate any>(resolver?: (...args: Parameters) => string) { - return (target: any, key: string, descriptor: PropertyDescriptor) => { + return (_target: any, key: string, descriptor: PropertyDescriptor) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type let fn: Function | undefined; if (typeof descriptor.value === 'function') { fn = descriptor.value; @@ -29,7 +30,7 @@ export function gate any>(resolver?: (...args: Parame if (promise === undefined) { let result; try { - result = fn!.apply(this, args); + result = fn.apply(this, args); if (result == null || !isPromise(result)) { return result; } @@ -39,7 +40,7 @@ export function gate any>(resolver?: (...args: Parame this[prop] = undefined; return r; }) - .catch(ex => { + .catch((ex: unknown) => { this[prop] = undefined; throw ex; }); diff --git a/src/system/decorators/log.ts b/src/system/decorators/log.ts index 2625755e08ca9..ea3e1f5acc39e 100644 --- a/src/system/decorators/log.ts +++ b/src/system/decorators/log.ts @@ -1,15 +1,13 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ import { hrtime } from '@env/hrtime'; -import { LogLevel, slowCallWarningThreshold } from '../../constants'; -import { getLoggableName, Logger } from '../../logger'; -import type { LogScope } from '../../logScope'; -import { clearLogScope, getNextLogScopeId, setLogScope } from '../../logScope'; import { getParameters } from '../function'; +import { getLoggableName, Logger } from '../logger'; +import { slowCallWarningThreshold } from '../logger.constants'; +import type { LogScope } from '../logger.scope'; +import { clearLogScope, getLoggableScopeBlock, logScopeIdGenerator, setLogScope } from '../logger.scope'; import { isPromise } from '../promise'; import { getDurationMilliseconds } from '../string'; -const emptyStr = ''; - export interface LogContext { id: number; instance: any; @@ -29,11 +27,10 @@ interface LogOptions any> { 4?: ((arg: Parameters[4]) => unknown) | string | false; [key: number]: (((arg: any) => unknown) | string | false) | undefined; }; - condition?(...args: Parameters): boolean; + if?(this: any, ...args: Parameters): boolean; enter?(...args: Parameters): string; - exit?(result: PromiseType>): string; + exit?: ((result: PromiseType>) => string) | boolean; prefix?(context: LogContext, ...args: Parameters): string; - sanitize?(key: string, value: any): any; logThreshold?: number; scoped?: boolean; singleLine?: boolean; @@ -43,6 +40,7 @@ interface LogOptions any> { export const LogInstanceNameFn = Symbol('logInstanceNameFn'); export function logName(fn: (c: T, name: string) => string) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type return (target: Function) => { (target as any)[LogInstanceNameFn] = fn; }; @@ -56,23 +54,21 @@ type PromiseType = T extends Promise ? U : T; export function log any>(options?: LogOptions, debug = false) { let overrides: LogOptions['args'] | undefined; - let conditionFn: LogOptions['condition'] | undefined; + let ifFn: LogOptions['if'] | undefined; let enterFn: LogOptions['enter'] | undefined; let exitFn: LogOptions['exit'] | undefined; let prefixFn: LogOptions['prefix'] | undefined; - let sanitizeFn: LogOptions['sanitize'] | undefined; - let logThreshold = 0; - let scoped = false; - let singleLine = false; - let timed = true; + let logThreshold: NonNullable['logThreshold']> = 0; + let scoped: NonNullable['scoped']> = false; + let singleLine: NonNullable['singleLine']> = false; + let timed: NonNullable['timed']> = true; if (options != null) { ({ args: overrides, - condition: conditionFn, + if: ifFn, enter: enterFn, exit: exitFn, prefix: prefixFn, - sanitize: sanitizeFn, logThreshold = 0, scoped = true, singleLine = false, @@ -89,12 +85,12 @@ export function log any>(options?: LogOptions, deb scoped = true; } - const logFn = (debug ? Logger.debug.bind(Logger) : Logger.log.bind(Logger)) as - | typeof Logger.debug - | typeof Logger.log; - const warnFn = Logger.warn.bind(Logger); + const debugging = Logger.isDebugging; + const logFn: (message: string, ...params: any[]) => void = debug ? Logger.debug : Logger.log; + const logLevel = debugging ? 'debug' : 'info'; - return (target: any, key: string, descriptor: PropertyDescriptor & Record) => { + return (_target: any, key: string, descriptor: PropertyDescriptor & Record) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type let fn: Function | undefined; let fnKey: string | undefined; if (typeof descriptor.value === 'function') { @@ -106,40 +102,30 @@ export function log any>(options?: LogOptions, deb } if (fn == null || fnKey == null) throw new Error('Not supported'); - const parameters = getParameters(fn); + const parameters = overrides !== false ? getParameters(fn) : []; descriptor[fnKey] = function (this: any, ...args: Parameters) { - const scopeId = getNextLogScopeId(); - - if ( - (!Logger.isDebugging && - !Logger.enabled(LogLevel.Debug) && - !(Logger.enabled(LogLevel.Info) && !debug)) || - (conditionFn != null && !conditionFn(...args)) - ) { - return fn!.apply(this, args); + if ((!debugging && !Logger.enabled(logLevel)) || (ifFn != null && !ifFn.apply(this, args))) { + return fn.apply(this, args); } - let instanceName: string; - if (this != null) { - instanceName = getLoggableName(this); - if (this.constructor?.[LogInstanceNameFn]) { - instanceName = target.constructor[LogInstanceNameFn](this, instanceName); - } - } else { - instanceName = emptyStr; - } + const prevScopeId = logScopeIdGenerator.current; + const scopeId = logScopeIdGenerator.next(); + + const instanceName = this != null ? getLoggableName(this) : undefined; - let prefix = `${scoped ? `[${scopeId.toString(16).padStart(5)}] ` : emptyStr}${ - instanceName ? `${instanceName}.` : emptyStr - }${key}`; + let prefix = instanceName + ? scoped + ? `${getLoggableScopeBlock(scopeId, prevScopeId)} ${instanceName}.${key}` + : `${instanceName}.${key}` + : key; if (prefixFn != null) { prefix = prefixFn( { id: scopeId, instance: this, - instanceName: instanceName, + instanceName: instanceName ?? '', name: key, prefix: prefix, }, @@ -149,18 +135,17 @@ export function log any>(options?: LogOptions, deb let scope: LogScope | undefined; if (scoped) { - scope = { scopeId: scopeId, prefix: prefix }; - setLogScope(scopeId, scope); + scope = setLogScope(scopeId, { scopeId: scopeId, prevScopeId: prevScopeId, prefix: prefix }); } - const enter = enterFn != null ? enterFn(...args) : emptyStr; + const enter = enterFn != null ? enterFn(...args) : ''; let loggableParams: string; if (overrides === false || args.length === 0) { - loggableParams = emptyStr; + loggableParams = ''; if (!singleLine) { - logFn(`${prefix}${enter}`); + logFn.call(Logger, `${prefix}${enter}`); } } else { loggableParams = ''; @@ -193,36 +178,34 @@ export function log any>(options?: LogOptions, deb loggableParams += ', '; } - paramLogValue = Logger.toLoggable(paramValue, sanitizeFn); + paramLogValue = Logger.toLoggable(paramValue); } loggableParams += paramName ? `${paramName}=${paramLogValue}` : paramLogValue; } if (!singleLine) { - logFn( - `${prefix}${enter}${ - loggableParams && (debug || Logger.enabled(LogLevel.Debug) || Logger.isDebugging) - ? `(${loggableParams})` - : emptyStr - }`, - ); + logFn.call(Logger, loggableParams ? `${prefix}${enter}(${loggableParams})` : `${prefix}${enter}`); } } if (singleLine || timed || exitFn != null) { const start = timed ? hrtime() : undefined; - const logError = (ex: Error) => { - const timing = start !== undefined ? ` \u2022 ${getDurationMilliseconds(start)} ms` : emptyStr; + const logError = (ex: unknown) => { + const timing = start !== undefined ? ` [${getDurationMilliseconds(start)}ms]` : ''; if (singleLine) { Logger.error( ex, - `${prefix}${enter}${loggableParams ? `(${loggableParams})` : emptyStr}`, - `failed${scope?.exitDetails ? scope.exitDetails : emptyStr}${timing}`, + loggableParams ? `${prefix}${enter}(${loggableParams})` : `${prefix}${enter}`, + scope?.exitDetails ? `failed${scope.exitDetails}${timing}` : `failed${timing}`, ); } else { - Logger.error(ex, prefix, `failed${scope?.exitDetails ? scope.exitDetails : emptyStr}${timing}`); + Logger.error( + ex, + prefix, + scope?.exitDetails ? `failed${scope.exitDetails}${timing}` : `failed${timing}`, + ); } if (scoped) { @@ -232,7 +215,7 @@ export function log any>(options?: LogOptions, deb let result; try { - result = fn!.apply(this, args); + result = fn.apply(this, args); } catch (ex) { logError(ex); throw ex; @@ -245,45 +228,50 @@ export function log any>(options?: LogOptions, deb if (start != null) { duration = getDurationMilliseconds(start); if (duration > slowCallWarningThreshold) { - exitLogFn = warnFn; - timing = ` \u2022 ${duration} ms (slow)`; + exitLogFn = Logger.warn; + timing = ` [*${duration}ms] (slow)`; } else { exitLogFn = logFn; - timing = ` \u2022 ${duration} ms`; + timing = ` [${duration}ms]`; } } else { - timing = emptyStr; + timing = ''; exitLogFn = logFn; } let exit; if (exitFn != null) { - try { - exit = exitFn(r); - } catch (ex) { - exit = `@log.exit error: ${ex}`; + if (typeof exitFn === 'function') { + try { + exit = exitFn(r); + } catch (ex) { + exit = `@log.exit error: ${ex}`; + } + } else if (exitFn === true) { + exit = `returned ${Logger.toLoggable(r)}`; } + } else if (scope?.exitFailed) { + exit = scope.exitFailed; + exitLogFn = Logger.error; } else { exit = 'completed'; } if (singleLine) { if (logThreshold === 0 || duration! > logThreshold) { - exitLogFn( - `${prefix}${enter}${ - loggableParams && (debug || Logger.enabled(LogLevel.Debug) || Logger.isDebugging) - ? `(${loggableParams})` - : emptyStr - } ${exit}${scope?.exitDetails ? scope.exitDetails : emptyStr}${timing}`, + exitLogFn.call( + Logger, + loggableParams + ? `${prefix}${enter}(${loggableParams}) ${exit}${scope?.exitDetails || ''}${timing}` + : `${prefix}${enter} ${exit}${scope?.exitDetails || ''}${timing}`, ); } } else { - exitLogFn( - `${prefix}${ - loggableParams && (debug || Logger.enabled(LogLevel.Debug) || Logger.isDebugging) - ? `(${loggableParams})` - : emptyStr - } ${exit}${scope?.exitDetails ? scope.exitDetails : emptyStr}${timing}`, + exitLogFn.call( + Logger, + loggableParams + ? `${prefix}(${loggableParams}) ${exit}${scope?.exitDetails || ''}${timing}` + : `${prefix} ${exit}${scope?.exitDetails || ''}${timing}`, ); } @@ -293,8 +281,7 @@ export function log any>(options?: LogOptions, deb }; if (result != null && isPromise(result)) { - const promise = result.then(logResult); - promise.catch(logError); + result.then(logResult, logError); } else { logResult(result); } @@ -302,7 +289,7 @@ export function log any>(options?: LogOptions, deb return result; } - return fn!.apply(this, args); + return fn.apply(this, args); }; }; } diff --git a/src/system/decorators/memoize.ts b/src/system/decorators/memoize.ts index fec7e56d0ebfa..45f4ce44379f8 100644 --- a/src/system/decorators/memoize.ts +++ b/src/system/decorators/memoize.ts @@ -2,7 +2,8 @@ import { resolveProp } from './resolver'; export function memoize any>(resolver?: (...args: Parameters) => string) { - return (target: any, key: string, descriptor: PropertyDescriptor & Record) => { + return (_target: any, key: string, descriptor: PropertyDescriptor & Record) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type let fn: Function | undefined; let fnKey: string | undefined; @@ -29,7 +30,7 @@ export function memoize any>(resolver?: (...args: Par return result; } - result = fn!.apply(this, args); + result = fn.apply(this, args); Object.defineProperty(this, prop, { configurable: false, enumerable: false, diff --git a/src/system/decorators/resolver.ts b/src/system/decorators/resolver.ts index 5eb02214a7ba2..fcf2a95794d56 100644 --- a/src/system/decorators/resolver.ts +++ b/src/system/decorators/resolver.ts @@ -3,66 +3,58 @@ import { isContainer } from '../../container'; import { isBranch } from '../../git/models/branch'; import { isCommit } from '../../git/models/commit'; import { isTag } from '../../git/models/tag'; -import { isViewNode } from '../../views/nodes/viewNode'; +import { isViewNode } from '../../views/nodes/abstract/viewNode'; function replacer(key: string, value: any): any { - if (key === '') return value; - - if (value == null) return value; - if (typeof value !== 'object') return value; + if (key === '' || value == null || typeof value !== 'object') return value; if (value instanceof Error) return String(value); if (value instanceof Uri) { - if ('sha' in (value as any) && (value as any).sha) { - return `${(value as any).sha}:${value.toString()}`; + if ('sha' in value && typeof value.sha === 'string' && value.sha) { + return `${value.sha}:${value.toString()}`; } return value.toString(); } if (isBranch(value) || isCommit(value) || isTag(value) || isViewNode(value)) { return value.toString(); } - if (isContainer(value)) { - return ''; - } + if (isContainer(value)) return ''; return value; } export function defaultResolver(...args: any[]): string { if (args.length === 0) return ''; - if (args.length !== 1) { - return JSON.stringify(args, replacer); - } + if (args.length > 1) return JSON.stringify(args, replacer); - const arg0 = args[0]; - if (arg0 == null) return ''; - switch (typeof arg0) { + const [arg] = args; + if (arg == null) return ''; + + switch (typeof arg) { case 'string': - return arg0; + return arg; case 'number': case 'boolean': case 'undefined': case 'symbol': case 'bigint': - return String(arg0); + return String(arg); default: - if (arg0 instanceof Error) return String(arg0); - if (arg0 instanceof Uri) { - if ('sha' in arg0 && typeof arg0.sha === 'string' && arg0.sha) { - return `${arg0.sha}:${arg0.toString()}`; + if (arg instanceof Error) return String(arg); + if (arg instanceof Uri) { + if ('sha' in arg && typeof arg.sha === 'string' && arg.sha) { + return `${arg.sha}:${arg.toString()}`; } - return arg0.toString(); - } - if (isBranch(arg0) || isCommit(arg0) || isTag(arg0) || isViewNode(arg0)) { - return arg0.toString(); + return arg.toString(); } - if (isContainer(arg0)) { - return ''; + if (isBranch(arg) || isCommit(arg) || isTag(arg) || isViewNode(arg)) { + return arg.toString(); } + if (isContainer(arg)) return ''; - return JSON.stringify(arg0, replacer); + return JSON.stringify(arg, replacer); } } diff --git a/src/system/decorators/serialize.ts b/src/system/decorators/serialize.ts index 43e2f8127faa1..07050b56962cb 100644 --- a/src/system/decorators/serialize.ts +++ b/src/system/decorators/serialize.ts @@ -1,5 +1,6 @@ export function serialize(): (target: any, key: string, descriptor: PropertyDescriptor) => void { - return (target: any, key: string, descriptor: PropertyDescriptor) => { + return (_target: any, key: string, descriptor: PropertyDescriptor) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type let fn: Function | undefined; if (typeof descriptor.value === 'function') { fn = descriptor.value; @@ -21,8 +22,8 @@ export function serialize(): (target: any, key: string, descriptor: PropertyDesc } let promise: Promise | undefined = this[serializeKey]; - // eslint-disable-next-line no-return-await, @typescript-eslint/no-unsafe-return - const run = async () => await fn!.apply(this, args); + // eslint-disable-next-line no-return-await, @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await + const run = async () => await fn.apply(this, args); if (promise == null) { promise = run(); } else { diff --git a/src/system/decorators/timeout.ts b/src/system/decorators/timeout.ts deleted file mode 100644 index 08123a16ec9ba..0000000000000 --- a/src/system/decorators/timeout.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -import { cancellable, isPromise } from '../promise'; - -export function timeout(timeout: number): any; -export function timeout(timeoutFromLastArg: true, defaultTimeout?: number): any; -export function timeout(timeoutOrTimeoutFromLastArg: number | boolean, defaultTimeout?: number): any { - let timeout: number | undefined; - let timeoutFromLastArg = false; - if (typeof timeoutOrTimeoutFromLastArg === 'boolean') { - timeoutFromLastArg = timeoutOrTimeoutFromLastArg; - } else { - timeout = timeoutOrTimeoutFromLastArg; - } - - return (target: any, key: string, descriptor: PropertyDescriptor) => { - let fn: Function | undefined; - if (typeof descriptor.value === 'function') { - fn = descriptor.value; - } - if (fn == null) throw new Error('Not supported'); - - descriptor.value = function (this: any, ...args: any[]) { - if (timeoutFromLastArg) { - const lastArg = args[args.length - 1]; - if (lastArg != null && typeof lastArg === 'number') { - timeout = lastArg; - } else { - timeout = defaultTimeout; - } - } - - const result = fn?.apply(this, args); - if (timeout == null || timeout < 1 || !isPromise(result)) return result; - - return cancellable(result, timeout, { onDidCancel: resolve => resolve(undefined) }); - }; - }; -} diff --git a/src/system/event.ts b/src/system/event.ts index 84f1f94f1df41..8a118be803ae6 100644 --- a/src/system/event.ts +++ b/src/system/event.ts @@ -2,15 +2,18 @@ import type { Disposable, Event } from 'vscode'; import type { Deferred } from './promise'; export function once(event: Event): Event { - return (listener: (e: T) => unknown, thisArgs?: unknown, disposables?: Disposable[]) => { - const result = event( - e => { + return take(event, 1); +} + +export function take(event: Event, count: number): Event { + return (listener: (e: T) => unknown, thisArgs?: unknown) => { + let i = 0; + const result = event(e => { + if (++i >= count) { result.dispose(); - return listener.call(thisArgs, e); - }, - null, - disposables, - ); + } + return listener.call(thisArgs, e); + }); return result; }; @@ -20,18 +23,14 @@ export function promisify(event: Event): Promise { return new Promise(resolve => once(event)(resolve)); } -export function until(event: Event, predicate: (e: T) => boolean): Event { - return (listener: (e: T) => unknown, thisArgs?: unknown, disposables?: Disposable[]) => { - const result = event( - e => { - if (predicate(e)) { - result.dispose(); - } - return listener.call(thisArgs, e); - }, - null, - disposables, - ); +export function takeUntil(event: Event, predicate: (e: T) => boolean): Event { + return (listener: (e: T) => unknown, thisArgs?: unknown) => { + const result = event(e => { + if (predicate(e)) { + result.dispose(); + } + return listener.call(thisArgs, e); + }); return result; }; @@ -39,9 +38,11 @@ export function until(event: Event, predicate: (e: T) => boolean): Event = Omit, 'fulfill'>; -export interface DeferredEventExecutor { - (value: T, resolve: (value: U | PromiseLike) => void, reject: (reason: any) => void): any; -} +export type DeferredEventExecutor = ( + value: T, + resolve: (value: U | PromiseLike) => void, + reject: (reason: any) => void, +) => any; const resolveExecutor = (value: any, resolve: (value?: any) => void) => resolve(value); @@ -65,8 +66,10 @@ export function promisifyDeferred( let cancel: ((reason?: any) => void) | undefined; let disposable: Disposable; + let pending = true; const promise = new Promise((resolve, reject) => { cancel = () => { + pending = false; cancel = undefined; reject(); }; @@ -74,7 +77,10 @@ export function promisifyDeferred( disposable = event(async (value: T) => { try { await executor(value, resolve, reject); + pending = false; } catch (ex) { + pending = false; + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(ex); } }); @@ -83,14 +89,60 @@ export function promisifyDeferred( disposable.dispose(); return value; }, - reason => { + (reason: unknown) => { disposable.dispose(); throw reason; }, ); return { + get pending() { + return pending; + }, promise: promise, cancel: () => cancel?.(), }; } + +export function weakEvent( + event: Event, + listener: (e: T) => any, + thisArg: U, + alsoDisposeOnReleaseOrDispose?: Disposable[], +): Disposable { + const ref = new WeakRef(thisArg); + + let disposable: Disposable; + + const d = event((e: T) => { + const obj = ref.deref(); + if (obj != null) { + listener.call(obj, e); + } else { + disposable.dispose(); + } + }); + + if (alsoDisposeOnReleaseOrDispose == null) { + disposable = d; + } else { + disposable = disposableFrom(d, ...alsoDisposeOnReleaseOrDispose); + } + return disposable; +} + +function disposableFrom(...inDisposables: { dispose(): any }[]): Disposable { + let disposables: ReadonlyArray<{ dispose(): any }> | undefined = inDisposables; + return { + dispose: function () { + if (disposables) { + for (const disposable of disposables) { + if (disposable && typeof disposable.dispose === 'function') { + disposable.dispose(); + } + } + disposables = undefined; + } + }, + }; +} diff --git a/src/system/function.ts b/src/system/function.ts index c925bd5aabee6..ed94c1d6048c3 100644 --- a/src/system/function.ts +++ b/src/system/function.ts @@ -1,13 +1,10 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -// eslint-disable-next-line no-restricted-imports -import { debounce as _debounce, once as _once } from 'lodash-es'; import type { Disposable } from 'vscode'; export interface Deferrable any> { (...args: Parameters): ReturnType | undefined; cancel(): void; flush(): ReturnType | undefined; - pending?(): boolean; + pending(): boolean; } interface PropOfValue { @@ -15,72 +12,113 @@ interface PropOfValue { value: string | undefined; } -export interface DebounceOptions { - leading?: boolean; - maxWait?: number; - track?: boolean; - trailing?: boolean; -} - -export function debounce any>( +export function debounce ReturnType>( fn: T, - wait?: number, - options?: DebounceOptions, + wait: number, + aggregator?: (prevArgs: Parameters, nextArgs: Parameters) => Parameters, ): Deferrable { - const { track, ...opts }: DebounceOptions = { - track: false, - ...(options ?? {}), - }; + let lastArgs: Parameters; + let lastCallTime: number | undefined; + let lastThis: ThisType; + let result: ReturnType | undefined; + let timer: ReturnType | undefined; - if (track !== true) return _debounce(fn, wait, opts); + function invoke(): ReturnType | undefined { + const args = lastArgs; + const thisArg = lastThis; - let pending = false; + lastArgs = lastThis = undefined!; + result = fn.apply(thisArg, args); + return result; + } - const debounced = _debounce( - function (this: any, ...args: any[]) { - pending = false; - return fn.apply(this, args); - } as any as T, - wait, - options, - ); + function shouldInvoke(time: number) { + const timeSinceLastCall = time - (lastCallTime ?? 0); - const tracked: Deferrable = function (this: any, ...args: Parameters) { - pending = true; - return debounced.apply(this, args); - } as any; + // Either this is the first call, activity has stopped and we're at the + // trailing edge, the system time has gone backwards and we're treating + // it as the trailing edge + return lastCallTime == null || timeSinceLastCall >= wait || timeSinceLastCall < 0; + } - tracked.pending = function () { - return pending; - }; - tracked.cancel = function () { - return debounced.cancel.apply(debounced); - }; - tracked.flush = function () { - return debounced.flush.apply(debounced); - }; + function timerExpired() { + const time = Date.now(); + if (shouldInvoke(time)) { + trailingEdge(); + } else { + // Restart the timer + const timeSinceLastCall = time - (lastCallTime ?? 0); + timer = setTimeout(timerExpired, wait - timeSinceLastCall); + } + } - return tracked; -} + function trailingEdge() { + timer = undefined; -// export function debounceMemoized any>( -// fn: T, -// wait?: number, -// options?: DebounceOptions & { resolver?(...args: any[]): any } -// ): T { -// const { resolver, ...opts } = options || ({} as DebounceOptions & { resolver?: T }); + // Only invoke if we have `lastArgs` which means `fn` has been debounced at least once + if (lastArgs) return invoke(); + lastArgs = undefined!; + lastThis = undefined!; -// const memo = _memoize(() => { -// return debounce(fn, wait, opts); -// }, resolver); + return result; + } -// return function(this: any, ...args: []) { -// return memo.apply(this, args).apply(this, args); -// } as T; -// } + function cancel() { + if (timer != null) { + clearTimeout(timer); + } + lastArgs = undefined!; + lastCallTime = undefined!; + lastThis = undefined!; + timer = undefined!; + } + + function flush() { + if (timer == null) return result; + + clearTimeout(timer); + return trailingEdge(); + } + + function pending(): boolean { + return timer != null; + } + + function debounced(this: any, ...args: Parameters) { + const time = Date.now(); + const isInvoking = shouldInvoke(time); + + if (aggregator != null && lastArgs) { + lastArgs = aggregator(lastArgs, args); + } else { + lastArgs = args; + } + + // eslint-disable-next-line @typescript-eslint/no-this-alias + lastThis = this; + lastCallTime = time; + + if (isInvoking) { + if (timer == null) { + // Start the timer for the trailing edge. + timer = setTimeout(timerExpired, wait); + return result; + } + } + if (timer == null) { + timer = setTimeout(timerExpired, wait); + } + + return result; + } + + debounced.cancel = cancel; + debounced.flush = flush; + debounced.pending = pending; + return debounced; +} const comma = ','; -const emptyStr = ''; const equals = '='; const openBrace = '{'; const openParen = '('; @@ -90,13 +128,14 @@ const fnBodyRegex = /\(([\s\S]*)\)/; const fnBodyStripCommentsRegex = /(\/\*([\s\S]*?)\*\/|([^:]|^)\/\/(.*)$)/gm; const fnBodyStripParamDefaultValueRegex = /\s?=.*$/; +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type export function getParameters(fn: Function): string[] { if (typeof fn !== 'function') throw new Error('Not supported'); if (fn.length === 0) return []; let fnBody: string = Function.prototype.toString.call(fn); - fnBody = fnBody.replace(fnBodyStripCommentsRegex, emptyStr) || fnBody; + fnBody = fnBody.replace(fnBodyStripCommentsRegex, '') || fnBody; fnBody = fnBody.slice(0, fnBody.indexOf(openBrace)); let open = fnBody.indexOf(openParen); @@ -110,7 +149,7 @@ export function getParameters(fn: Function): string[] { const match = fnBodyRegex.exec(fnBody); return match != null - ? match[1].split(comma).map(param => param.trim().replace(fnBodyStripParamDefaultValueRegex, emptyStr)) + ? match[1].split(comma).map(param => param.trim().replace(fnBodyStripParamDefaultValueRegex, '')) : []; } @@ -125,7 +164,35 @@ export function is(o: object, propOrMatcher?: keyof T | ((o: a } export function once any>(fn: T): T { - return _once(fn); + let result: ReturnType; + let called = false; + + return function (this: any, ...args: Parameters): ReturnType { + if (!called) { + called = true; + result = fn.apply(this, args); + fn = undefined!; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return result; + } as T; +} + +type PartialArgs = { + [K in keyof P]: K extends keyof T ? T[K] : never; +}; + +type DropFirstN = { + 0: T; + 1: T extends [infer _, ...infer R] ? DropFirstN : T; +}[I['length'] extends N ? 0 : 1]; + +export function partial any, P extends any[]>( + fn: T, + ...partialArgs: PartialArgs, P> +): (...rest: DropFirstN, P['length']>) => ReturnType { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return (...rest) => fn(...partialArgs, ...rest); } export function propOf>(o: T, key: K) { @@ -173,3 +240,27 @@ export async function sequentialize unknown>( export function szudzikPairing(x: number, y: number): number { return x >= y ? x * x + x + y : x + y * y; } + +export function throttle ReturnType>(fn: T, delay: number) { + let waiting = false; + let waitingArgs: Parameters | undefined; + + return function (this: unknown, ...args: Parameters) { + if (waiting) { + waitingArgs = args; + + return; + } + + waiting = true; + fn.apply(this, args); + + setTimeout(() => { + waiting = false; + + if (waitingArgs != null) { + fn.apply(this, waitingArgs); + } + }, delay); + }; +} diff --git a/src/system/iterable.ts b/src/system/iterable.ts index 5b97719099a22..eb0eb9c5803ad 100644 --- a/src/system/iterable.ts +++ b/src/system/iterable.ts @@ -38,19 +38,18 @@ export function* chunkByStringLength(source: string[], maxLength: number): Itera } } -export function count(source: IterableIterator, predicate?: (item: T) => boolean): number { - let count = 0; - let next: IteratorResult; +export function count( + source: Iterable | IterableIterator | undefined, + predicate?: (item: T) => boolean, +): number { + if (source == null) return 0; - while (true) { - next = source.next(); - if (next.done) break; - - if (predicate === undefined || predicate(next.value)) { + let count = 0; + for (const item of source) { + if (predicate == null || predicate(item)) { count++; } } - return count; } @@ -61,12 +60,14 @@ export function every(source: Iterable | IterableIterator, predicate: ( return true; } -export function filter(source: Iterable | IterableIterator): Iterable; -export function filter(source: Iterable | IterableIterator, predicate: (item: T) => boolean): Iterable; +export function filter( + source: Iterable | IterableIterator, +): Iterable>; export function filter( source: Iterable | IterableIterator, predicate: (item: T) => item is U, ): Iterable; +export function filter(source: Iterable | IterableIterator, predicate: (item: T) => boolean): Iterable; export function* filter( source: Iterable | IterableIterator, predicate?: ((item: T) => item is U) | ((item: T) => boolean), @@ -100,11 +101,11 @@ export function forEach(source: Iterable | IterableIterator, fn: (item: } } -export function find(source: Iterable | IterableIterator, predicate: (item: T) => boolean): T | null { +export function find(source: Iterable | IterableIterator, predicate: (item: T) => boolean): T | undefined { for (const item of source) { if (predicate(item)) return item; } - return null; + return undefined; } export function findIndex(source: Iterable | IterableIterator, predicate: (item: T) => boolean): number { @@ -120,6 +121,19 @@ export function first(source: Iterable | IterableIterator): T | undefin return source[Symbol.iterator]().next().value as T | undefined; } +export function flatCount( + source: Iterable | IterableIterator | undefined, + accumulator: (item: T) => number, +): number { + if (source == null) return 0; + + let count = 0; + for (const item of source) { + count += accumulator(item); + } + return count; +} + export function* flatMap( source: Iterable | IterableIterator, mapper: (item: T) => Iterable, @@ -129,6 +143,77 @@ export function* flatMap( } } +export function flatten(source: Iterable> | IterableIterator>): IterableIterator { + return flatMap(source, i => i); +} + +export function groupBy( + source: Iterable | IterableIterator, + getGroupingKey: (item: T) => K, +): Record { + const result: Record = Object.create(null); + + for (const current of source) { + const key = getGroupingKey(current); + + const group = result[key]; + if (group == null) { + result[key] = [current]; + } else { + group.push(current); + } + } + + return result; +} + +export function groupByMap( + source: Iterable | IterableIterator, + getGroupingKey: (item: TValue) => TKey, + options?: { filterNullGroups?: boolean }, +): Map { + const result = new Map(); + + const filterNullGroups = options?.filterNullGroups ?? false; + + for (const current of source) { + const key = getGroupingKey(current); + if (key == null && filterNullGroups) continue; + + const group = result.get(key); + if (group == null) { + result.set(key, [current]); + } else { + group.push(current); + } + } + + return result; +} + +export function groupByFilterMap( + source: Iterable | IterableIterator, + getGroupingKey: (item: TValue) => TKey, + predicateMapper: (item: TValue) => TMapped | null | undefined, +): Map { + const result = new Map(); + + for (const current of source) { + const mapped = predicateMapper(current); + if (mapped == null) continue; + + const key = getGroupingKey(current); + const group = result.get(key); + if (group == null) { + result.set(key, [mapped]); + } else { + group.push(mapped); + } + } + + return result; +} + export function has(source: Iterable | IterableIterator, item: T): boolean { return some(source, i => i === item); } @@ -170,6 +255,48 @@ export function* map( } } +export function max(source: Iterable | IterableIterator): number; +export function max(source: Iterable | IterableIterator, getValue: (item: T) => number): number; +export function max(source: Iterable | IterableIterator, getValue?: (item: T) => number): number { + let max = Number.NEGATIVE_INFINITY; + if (getValue == null) { + for (const item of source as Iterable | IterableIterator) { + if (item > max) { + max = item; + } + } + } else { + for (const item of source) { + const value = getValue(item); + if (value > max) { + max = value; + } + } + } + return max; +} + +export function min(source: Iterable | IterableIterator): number; +export function min(source: Iterable | IterableIterator, getValue: (item: T) => number): number; +export function min(source: Iterable | IterableIterator, getValue?: (item: T) => number): number { + let min = Number.POSITIVE_INFINITY; + if (getValue == null) { + for (const item of source as Iterable | IterableIterator) { + if (item < min) { + min = item; + } + } + } else { + for (const item of source) { + const value = getValue(item); + if (value < min) { + min = value; + } + } + } + return min; +} + export function next(source: IterableIterator): T { return source.next().value as T; } @@ -189,6 +316,16 @@ export function some(source: Iterable | IterableIterator, predicate: (i return false; } +export function sum(source: Iterable | IterableIterator | undefined, getValue: (item: T) => number): number { + if (source == null) return 0; + + let sum = 0; + for (const item of source) { + sum += getValue(item); + } + return sum; +} + export function* take(source: Iterable | IterableIterator, count: number): Iterable { if (count > 0) { let i = 0; @@ -200,8 +337,10 @@ export function* take(source: Iterable | IterableIterator, count: numbe } } -export function* union(...sources: (Iterable | IterableIterator)[]): Iterable { +export function* union(...sources: (Iterable | IterableIterator | undefined)[]): Iterable { for (const source of sources) { + if (source == null) continue; + for (const item of source) { yield item; } @@ -210,24 +349,24 @@ export function* union(...sources: (Iterable | IterableIterator)[]): It export function uniqueBy( source: Iterable | IterableIterator, - uniqueKey: (item: TValue) => TKey, + getUniqueKey: (item: TValue) => TKey, onDuplicate: (original: TValue, current: TValue) => TValue | void, ): IterableIterator { - const uniques = new Map(); + const result = new Map(); for (const current of source) { - const value = uniqueKey(current); + const key = getUniqueKey(current); - const original = uniques.get(value); + const original = result.get(key); if (original === undefined) { - uniques.set(value, current); + result.set(key, current); } else { const updated = onDuplicate(original, current); if (updated !== undefined) { - uniques.set(value, updated); + result.set(key, updated); } } } - return uniques.values(); + return result.values(); } diff --git a/src/system/logger.constants.ts b/src/system/logger.constants.ts new file mode 100644 index 0000000000000..80aeb2820e7df --- /dev/null +++ b/src/system/logger.constants.ts @@ -0,0 +1,3 @@ +export const slowCallWarningThreshold = 500; + +export type LogLevel = 'off' | 'error' | 'warn' | 'info' | 'debug'; diff --git a/src/system/logger.scope.ts b/src/system/logger.scope.ts new file mode 100644 index 0000000000000..75c53225eb2e9 --- /dev/null +++ b/src/system/logger.scope.ts @@ -0,0 +1,74 @@ +import { getScopedCounter } from './counter'; + +export const logScopeIdGenerator = getScopedCounter(); + +const scopes = new Map(); + +export interface LogScope { + readonly scopeId?: number; + readonly prevScopeId?: number; + readonly prefix: string; + exitDetails?: string; + exitFailed?: string; +} + +export function clearLogScope(scopeId: number) { + scopes.delete(scopeId); +} + +export function getLoggableScopeBlock(scopeId: number, prevScopeId?: number) { + return prevScopeId == null + ? `[${scopeId.toString(16).padStart(13)}]` + : `[${prevScopeId.toString(16).padStart(5)} \u2192 ${scopeId.toString(16).padStart(5)}]`; +} + +export function getLoggableScopeBlockOverride(prefix: string, suffix?: string) { + if (suffix == null) return `[${prefix.padEnd(13)}]`; + + return `[${prefix}${suffix.padStart(13 - prefix.length)}]`; +} + +export function getLogScope(): LogScope | undefined { + return scopes.get(logScopeIdGenerator.current); +} + +export function getNewLogScope(prefix: string, scope: LogScope | boolean | undefined): LogScope { + if (scope != null && typeof scope !== 'boolean') + return { + scopeId: scope.scopeId, + prevScopeId: scope.prevScopeId, + prefix: `${scope.prefix}${prefix}`, + }; + + const prevScopeId = scope ? logScopeIdGenerator.current : undefined; + const scopeId = logScopeIdGenerator.next(); + return { + scopeId: scopeId, + prevScopeId: prevScopeId, + prefix: `${getLoggableScopeBlock(scopeId)} ${prefix}`, + }; +} + +export function startLogScope(prefix: string, scope: LogScope | boolean | undefined): LogScope & Disposable { + const newScope = getNewLogScope(prefix, scope); + scopes.set(newScope.scopeId!, newScope); + return { + ...newScope, + [Symbol.dispose]: () => clearLogScope(newScope.scopeId!), + }; +} + +export function setLogScope(scopeId: number, scope: LogScope) { + scope = { prevScopeId: logScopeIdGenerator.current, ...scope }; + scopes.set(scopeId, scope); + return scope; +} + +export function setLogScopeExit(scope: LogScope | undefined, details: string | undefined, failed?: string): void { + if (scope == null) return; + + scope.exitDetails = details; + if (failed != null) { + scope.exitFailed = failed; + } +} diff --git a/src/logger.ts b/src/system/logger.ts similarity index 59% rename from src/logger.ts rename to src/system/logger.ts index 2c9f5b178a462..e964288509974 100644 --- a/src/logger.ts +++ b/src/system/logger.ts @@ -1,7 +1,7 @@ -import { LogLevel } from './constants'; -import type { LogScope } from './logScope'; - -const emptyStr = ''; +import { LogInstanceNameFn } from './decorators/log'; +import type { LogLevel } from './logger.constants'; +import type { LogScope } from './logger.scope'; +import { padOrTruncateEnd } from './string'; const enum OrderedLevel { Off = 0, @@ -15,6 +15,7 @@ export interface LogChannelProvider { readonly name: string; createChannel(name: string): LogChannel; toLoggable?(o: unknown): string | undefined; + sanitize?: (key: string, value: any) => any; } export interface LogChannel { @@ -24,6 +25,11 @@ export interface LogChannel { show?(preserveFocus?: boolean): void; } +const sanitizedKeys = new Set(['accessToken', 'password', 'token']); +const defaultSanitize = function (key: string, value: any): any { + return sanitizedKeys.has(key) ? `<${value}>` : value; +}; + export const Logger = new (class Logger { private output: LogChannel | undefined; private provider: LogChannelProvider | undefined; @@ -45,7 +51,7 @@ export const Logger = new (class Logger { } private level: OrderedLevel = OrderedLevel.Off; - private _logLevel: LogLevel = LogLevel.Off; + private _logLevel: LogLevel = 'off'; get logLevel(): LogLevel { return this._logLevel; } @@ -53,11 +59,11 @@ export const Logger = new (class Logger { this._logLevel = value; this.level = toOrderedLevel(this._logLevel); - if (value === LogLevel.Off) { + if (value === 'off') { this.output?.dispose?.(); this.output = undefined; } else { - this.output = this.output ?? this.provider!.createChannel(this.provider!.name); + this.output ??= this.provider!.createChannel(this.provider!.name); } } @@ -77,16 +83,16 @@ export const Logger = new (class Logger { message = params.shift(); if (scopeOrMessage != null) { - message = `${scopeOrMessage.prefix} ${message ?? emptyStr}`; + message = `${scopeOrMessage.prefix} ${message ?? ''}`; } } if (this.isDebugging) { - console.log(this.timestamp, `[${this.provider!.name}]`, message ?? emptyStr, ...params); + console.log(`[${padOrTruncateEnd(this.provider!.name, 13)}]`, this.timestamp, message ?? '', ...params); } if (this.output == null || this.level < OrderedLevel.Debug) return; - this.output.appendLine(`${this.timestamp} ${message ?? emptyStr}${this.toLoggableParams(true, params)}`); + this.output.appendLine(`${this.timestamp} ${message ?? ''}${this.toLoggableParams(true, params)}`); } error(ex: Error | unknown, message?: string, ...params: any[]): void; @@ -98,7 +104,7 @@ export const Logger = new (class Logger { if (scopeOrMessage == null || typeof scopeOrMessage === 'string') { message = scopeOrMessage; } else { - message = `${scopeOrMessage.prefix} ${params.shift() ?? emptyStr}`; + message = `${scopeOrMessage.prefix} ${params.shift() ?? ''}`; } if (message == null) { @@ -112,12 +118,29 @@ export const Logger = new (class Logger { } if (this.isDebugging) { - console.error(this.timestamp, `[${this.provider!.name}]`, message ?? emptyStr, ...params, ex); + if (ex != null) { + console.error( + `[${padOrTruncateEnd(this.provider!.name, 13)}]`, + this.timestamp, + message ?? '', + ...params, + ex, + ); + } else { + console.error( + `[${padOrTruncateEnd(this.provider!.name, 13)}]`, + this.timestamp, + message ?? '', + ...params, + ); + } } if (this.output == null || this.level < OrderedLevel.Error) return; this.output.appendLine( - `${this.timestamp} ${message ?? emptyStr}${this.toLoggableParams(false, params)}\n${String(ex)}`, + `${this.timestamp} ${message ?? ''}${this.toLoggableParams(false, params)}${ + ex != null ? `\n${String(ex)}` : '' + }`, ); } @@ -133,16 +156,16 @@ export const Logger = new (class Logger { message = params.shift(); if (scopeOrMessage != null) { - message = `${scopeOrMessage.prefix} ${message ?? emptyStr}`; + message = `${scopeOrMessage.prefix} ${message ?? ''}`; } } if (this.isDebugging) { - console.log(this.timestamp, `[${this.provider!.name}]`, message ?? emptyStr, ...params); + console.log(`[${padOrTruncateEnd(this.provider!.name, 13)}]`, this.timestamp, message ?? '', ...params); } if (this.output == null || this.level < OrderedLevel.Info) return; - this.output.appendLine(`${this.timestamp} ${message ?? emptyStr}${this.toLoggableParams(false, params)}`); + this.output.appendLine(`${this.timestamp} ${message ?? ''}${this.toLoggableParams(false, params)}`); } warn(message: string, ...params: any[]): void; @@ -157,25 +180,31 @@ export const Logger = new (class Logger { message = params.shift(); if (scopeOrMessage != null) { - message = `${scopeOrMessage.prefix} ${message ?? emptyStr}`; + message = `${scopeOrMessage.prefix} ${message ?? ''}`; } } if (this.isDebugging) { - console.warn(this.timestamp, `[${this.provider!.name}]`, message ?? emptyStr, ...params); + console.warn(this.timestamp, `[${this.provider!.name}]`, message ?? '', ...params); } if (this.output == null || this.level < OrderedLevel.Warn) return; - this.output.appendLine(`${this.timestamp} ${message ?? emptyStr}${this.toLoggableParams(false, params)}`); + this.output.appendLine(`${this.timestamp} ${message ?? ''}${this.toLoggableParams(false, params)}`); } showOutputChannel(preserveFocus?: boolean): void { this.output?.show?.(preserveFocus); } - toLoggable(o: any, sanitize?: ((key: string, value: any) => any) | undefined) { + toLoggable(o: any, sanitize?: ((key: string, value: any) => any) | undefined): string { if (typeof o !== 'object') return String(o); + sanitize ??= this.provider!.sanitize ?? defaultSanitize; + + if (Array.isArray(o)) { + return `[${o.map(i => this.toLoggable(i, sanitize)).join(', ')}]`; + } + const loggable = this.provider!.toLoggable?.(o); if (loggable != null) return loggable; @@ -188,44 +217,103 @@ export const Logger = new (class Logger { private toLoggableParams(debugOnly: boolean, params: any[]) { if (params.length === 0 || (debugOnly && this.level < OrderedLevel.Debug && !this.isDebugging)) { - return emptyStr; + return ''; } const loggableParams = params.map(p => this.toLoggable(p)).join(', '); - return loggableParams.length !== 0 ? ` \u2014 ${loggableParams}` : emptyStr; + return loggableParams.length !== 0 ? ` \u2014 ${loggableParams}` : ''; } })(); +export class BufferedLogChannel implements LogChannel { + private readonly buffer: string[] = []; + private bufferTimer: ReturnType | undefined; + + constructor( + private readonly channel: RequireSome & { append(value: string): void }, + private readonly interval: number = 500, + ) {} + + dispose(): void { + clearInterval(this.bufferTimer); + this.bufferTimer = undefined; + + this.channel.dispose(); + } + + get name(): string { + return this.channel.name; + } + + appendLine(value: string) { + this.buffer.push(value); + this.bufferTimer ??= setInterval(() => this.flush(), this.interval); + } + + show(preserveFocus?: boolean): void { + this.channel.show?.(preserveFocus); + } + + private _emptyCounter = 0; + + private flush() { + if (this.buffer.length) { + this._emptyCounter = 0; + + const value = this.buffer.join('\n'); + this.buffer.length = 0; + + this.channel.append(value); + } else { + this._emptyCounter++; + if (this._emptyCounter > 10) { + clearInterval(this.bufferTimer); + this.bufferTimer = undefined; + this._emptyCounter = 0; + } + } + } +} + function toOrderedLevel(logLevel: LogLevel): OrderedLevel { switch (logLevel) { - case LogLevel.Off: + case 'off': return OrderedLevel.Off; - case LogLevel.Error: + case 'error': return OrderedLevel.Error; - case LogLevel.Warn: + case 'warn': return OrderedLevel.Warn; - case LogLevel.Info: + case 'info': return OrderedLevel.Info; - case LogLevel.Debug: + case 'debug': return OrderedLevel.Debug; default: return OrderedLevel.Off; } } +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type export function getLoggableName(instance: Function | object) { - let name: string; + let ctor; if (typeof instance === 'function') { if (instance.prototype?.constructor == null) return instance.name; - name = instance.prototype.constructor.name ?? emptyStr; + ctor = instance.prototype.constructor; } else { - name = instance.constructor?.name ?? emptyStr; + ctor = instance.constructor; } + let name: string = ctor?.name ?? ''; + // Strip webpack module name (since I never name classes with an _) const index = name.indexOf('_'); - return index === -1 ? name : name.substr(index + 1); + name = index === -1 ? name : name.substring(index + 1); + + if (ctor?.[LogInstanceNameFn] != null) { + name = ctor[LogInstanceNameFn](instance, name); + } + + return name; } export interface LogProvider { @@ -234,16 +322,16 @@ export interface LogProvider { } export const defaultLogProvider: LogProvider = { - enabled: (logLevel: LogLevel) => Logger.enabled(logLevel), + enabled: (logLevel: LogLevel) => Logger.enabled(logLevel) || Logger.isDebugging, log: (logLevel: LogLevel, scope: LogScope | undefined, message: string, ...params: any[]) => { switch (logLevel) { - case LogLevel.Error: - Logger.error('', scope, message, ...params); + case 'error': + Logger.error(undefined, scope, message, ...params); break; - case LogLevel.Warn: + case 'warn': Logger.warn(scope, message, ...params); break; - case LogLevel.Info: + case 'info': Logger.log(scope, message, ...params); break; default: diff --git a/src/system/mru.ts b/src/system/mru.ts new file mode 100644 index 0000000000000..bdc894b4ab899 --- /dev/null +++ b/src/system/mru.ts @@ -0,0 +1,66 @@ +export class MRU { + private stack: T[] = []; + + constructor( + public readonly maxSize: number = 10, + private readonly comparator?: (a: T, b: T) => boolean, + ) {} + + get count(): number { + return this.stack.length; + } + + private _position: number = 0; + get position(): number { + return this._position; + } + + add(item: T): void { + if (this._position > 0) { + this.stack.splice(0, this._position); + this._position = 0; + } + + const index = + this.comparator != null ? this.stack.findIndex(i => this.comparator!(item, i)) : this.stack.indexOf(item); + if (index !== -1) { + this.stack.splice(index, 1); + } else if (this.stack.length === this.maxSize) { + this.stack.pop(); + } + + this.stack.unshift(item); + this._position = 0; + } + + get(position?: number): T | undefined { + if (position != null) { + if (position < 0 || position >= this.stack.length) return undefined; + return this.stack[position]; + } + return this.stack.length > 0 ? this.stack[0] : undefined; + } + + insert(item: T): void { + if (this._position > 0) { + this.stack.splice(0, this._position); + this._position = 0; + } + this.stack.unshift(item); + this._position++; + } + + navigate(direction: 'back' | 'forward'): T | undefined { + if (this.stack.length <= 1) return undefined; + + if (direction === 'back') { + if (this._position >= this.stack.length - 1) return undefined; + this._position += 1; + } else { + if (this._position <= 0) return undefined; + this._position -= 1; + } + + return this.stack[this._position]; + } +} diff --git a/src/system/object.ts b/src/system/object.ts index b9cf5cbbb1c8c..e519e3ed55fc2 100644 --- a/src/system/object.ts +++ b/src/system/object.ts @@ -8,72 +8,87 @@ export function areEqual(a: any, b: any): boolean { return JSON.stringify(a) === JSON.stringify(b); } -export function flatten( - o: any, - options: { arrays?: 'join' | 'spread'; prefix?: string; skipPaths?: string[]; skipNulls: true; stringify: true }, -): Record; -export function flatten( - o: any, - options: { arrays?: 'join' | 'spread'; prefix?: string; skipPaths?: string[]; skipNulls: true; stringify?: false }, -): Record>; -export function flatten( - o: any, - options: { arrays?: 'join' | 'spread'; prefix?: string; skipPaths?: string[]; skipNulls?: false; stringify: true }, -): Record; -export function flatten( - o: any, - options: { arrays?: 'join' | 'spread'; prefix?: string; skipPaths?: string[]; skipNulls?: false; stringify: 'all' }, -): Record; -export function flatten( - o: any, - options?: { - arrays?: 'join' | 'spread'; - prefix?: string; - skipPaths?: string[]; - skipNulls?: boolean; - stringify?: boolean; - }, -): Record; -export function flatten( - o: any, - options?: { - arrays?: 'join' | 'spread'; - prefix?: string; - skipPaths?: string[]; - skipNulls?: boolean; - stringify?: boolean | 'all'; - }, -): Record { - const skipPaths = - options?.skipPaths != null && options.skipPaths.length - ? options?.prefix - ? options.skipPaths.map(p => `${options.prefix}.${p}`) - : options.skipPaths - : undefined; - const skipNulls = options?.skipNulls ?? false; - const stringify = options?.stringify ?? false; +type AddPrefix

= P extends '' | undefined ? K : `${P}.${K}`; +type AddArrayIndex

= P extends '' | undefined ? `[${I}]` : `${P}[${I}]`; + +type Merge = MergeUnion; +type MergeUnion = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void + ? { [K in keyof I]: I[K] } + : never; + +type FlattenArray = T extends (infer U)[] + ? U extends object + ? { [Key in `${AddArrayIndex}.${string}`]: string | number | boolean } + : { [Key in AddArrayIndex]: string | number | boolean } + : T extends object + ? { [Key in `${AddArrayIndex}.${string}`]: string | number | boolean } + : { [Key in AddArrayIndex]: string | number | boolean }; + +type FlattenSpread = T extends ReadonlyArray + ? FlattenArray + : { + [K in keyof T]: T[K] extends ReadonlyArray + ? FlattenArray>> + : T[K] extends object + ? FlattenSpread>> + : { + [Key in AddPrefix>]: T[K] extends string | number | boolean + ? T[K] + : string; + }; + }[keyof T]; + +type FlattenJoin = { + [K in keyof T]: T[K] extends ReadonlyArray + ? { [Key in AddPrefix>]: string } + : T[K] extends object + ? FlattenJoin>> + : { + [Key in AddPrefix>]: T[K] extends string | number | boolean ? T[K] : string; + }; +}[keyof T]; + +type Flatten< + T extends object | null | undefined, + P extends string | undefined, + JoinArrays extends boolean, +> = T extends object ? Merge : FlattenSpread> : object; + +type FlattenOptions = { + joinArrays?: boolean; + skipPaths?: string[]; +}; + +export function flatten( + o: T, + prefix?: P, + options?: O, +): Flatten extends true ? true : false> { + const joinArrays = options?.joinArrays ?? false; + + const skipPaths = options?.skipPaths?.length + ? prefix + ? options.skipPaths.map(p => `${prefix}.${p}`) + : options.skipPaths + : undefined; function flattenCore(flattened: Record, key: string, value: any) { if (skipPaths?.includes(key)) return; if (Object(value) !== value) { - if (value == null) { - if (skipNulls) return; - - flattened[key] = stringify ? (stringify == 'all' ? JSON.stringify(value) : value ?? null) : value; - } else if (typeof value === 'string') { - flattened[key] = value; - } else if (stringify) { - flattened[key] = - typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value); - } else { - flattened[key] = value; - } + if (value == null) return; + + flattened[key] = + typeof value === 'string' + ? value + : typeof value === 'number' || typeof value === 'boolean' + ? value + : JSON.stringify(value); } else if (Array.isArray(value)) { const len = value.length; if (len === 0) return; - if (options?.arrays === 'join') { + if (joinArrays) { flattened[key] = value.join(','); } else { for (let i = 0; i < len; i++) { @@ -91,8 +106,8 @@ export function flatten( } const flattened: Record = Object.create(null); - flattenCore(flattened, options?.prefix ?? '', o); - return flattened; + flattenCore(flattened, prefix ?? '', o); + return flattened as Flatten extends true ? true : false>; } export function paths(o: Record, path?: string): string[] { diff --git a/src/system/paging.ts b/src/system/paging.ts new file mode 100644 index 0000000000000..c7a448c2cdbb9 --- /dev/null +++ b/src/system/paging.ts @@ -0,0 +1,31 @@ +import type { PagedResult } from '../git/gitProvider'; + +export class PageableResult { + private cached: Mutable> | undefined; + + constructor(private readonly fetch: (paging: PagedResult['paging']) => Promise>) {} + + async *values(): AsyncIterable> { + if (this.cached != null) { + for (const value of this.cached.values) { + yield value; + } + } + + let results = this.cached; + while (results == null || results.paging?.more) { + results = await this.fetch(results?.paging); + + if (this.cached == null) { + this.cached = results; + } else { + this.cached.values.push(...results.values); + this.cached.paging = results.paging; + } + + for (const value of results.values) { + yield value; + } + } + } +} diff --git a/src/system/path.ts b/src/system/path.ts index c536909fcc37c..49e7a83ce235a 100644 --- a/src/system/path.ts +++ b/src/system/path.ts @@ -1,53 +1,13 @@ -// eslint-disable-next-line no-restricted-imports -import { isAbsolute as _isAbsolute, basename, dirname } from 'path'; -import { Uri } from 'vscode'; +import { isAbsolute as _isAbsolute, basename } from 'path'; import { isLinux, isWindows } from '@env/platform'; -import { Schemes } from '../constants'; -// TODO@eamodio don't import from string here since it will break the tests because of ESM dependencies -// import { CharCode } from './string'; -// eslint-disable-next-line no-restricted-imports export { basename, dirname, extname, join as joinPaths } from 'path'; -const slash = 47; //slash; +const slash = 47; //CharCode.Slash; const driveLetterNormalizeRegex = /(?<=^\/?)([A-Z])(?=:\/)/; const hasSchemeRegex = /^([a-zA-Z][\w+.-]+):/; const pathNormalizeRegex = /\\/g; -const vslsHasPrefixRegex = /^[/|\\]~(?:\d+?|external)(?:[/|\\]|$)/; -const vslsRootUriRegex = /^[/|\\]~(?:\d+?|external)(?:[/|\\]|$)/; - -export function addVslsPrefixIfNeeded(path: string): string; -export function addVslsPrefixIfNeeded(uri: Uri): Uri; -export function addVslsPrefixIfNeeded(pathOrUri: string | Uri): string | Uri; -export function addVslsPrefixIfNeeded(pathOrUri: string | Uri): string | Uri { - if (typeof pathOrUri === 'string') { - if (maybeUri(pathOrUri)) { - pathOrUri = Uri.parse(pathOrUri); - } - } - - if (typeof pathOrUri === 'string') { - if (hasVslsPrefix(pathOrUri)) return pathOrUri; - - pathOrUri = normalizePath(pathOrUri); - return `/~0${pathOrUri.charCodeAt(0) === slash ? pathOrUri : `/${pathOrUri}`}`; - } - - let path = pathOrUri.fsPath; - if (hasVslsPrefix(path)) return pathOrUri; - - path = normalizePath(path); - return pathOrUri.with({ path: `/~0${path.charCodeAt(0) === slash ? path : `/${path}`}` }); -} - -export function hasVslsPrefix(path: string): boolean { - return vslsHasPrefixRegex.test(path); -} - -export function isVslsRoot(path: string): boolean { - return vslsRootUriRegex.test(path); -} export function commonBase(s1: string, s2: string, delimiter: string, ignoreCase?: boolean): string | undefined { const index = commonBaseIndex(s1, s2, delimiter, ignoreCase); @@ -76,90 +36,10 @@ export function commonBaseIndex(s1: string, s2: string, delimiter: string, ignor return index; } -export function getBestPath(uri: Uri): string; -export function getBestPath(pathOrUri: string | Uri): string; -export function getBestPath(pathOrUri: string | Uri): string { - if (typeof pathOrUri === 'string') { - if (!hasSchemeRegex.test(pathOrUri)) return normalizePath(pathOrUri); - - pathOrUri = Uri.parse(pathOrUri, true); - } - - return normalizePath(pathOrUri.scheme === Schemes.File ? pathOrUri.fsPath : pathOrUri.path); -} - export function getScheme(path: string): string | undefined { return hasSchemeRegex.exec(path)?.[1]; } -export function isChild(path: string, base: string | Uri): boolean; -export function isChild(uri: Uri, base: string | Uri): boolean; -export function isChild(pathOrUri: string | Uri, base: string | Uri): boolean { - if (typeof base === 'string') { - if (base.charCodeAt(0) !== slash) { - base = `/${base}`; - } - - return ( - isDescendent(pathOrUri, base) && - (typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.path) - .substr(base.length + (base.charCodeAt(base.length - 1) === slash ? 0 : 1)) - .split('/').length === 1 - ); - } - - return ( - isDescendent(pathOrUri, base) && - (typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.path) - .substr(base.path.length + (base.path.charCodeAt(base.path.length - 1) === slash ? 0 : 1)) - .split('/').length === 1 - ); -} - -export function isDescendent(path: string, base: string | Uri): boolean; -export function isDescendent(uri: Uri, base: string | Uri): boolean; -export function isDescendent(pathOrUri: string | Uri, base: string | Uri): boolean; -export function isDescendent(pathOrUri: string | Uri, base: string | Uri): boolean { - if (typeof base === 'string') { - base = normalizePath(base); - if (base.charCodeAt(0) !== slash) { - base = `/${base}`; - } - } - - if (typeof pathOrUri === 'string') { - pathOrUri = normalizePath(pathOrUri); - if (pathOrUri.charCodeAt(0) !== slash) { - pathOrUri = `/${pathOrUri}`; - } - } - - if (typeof base === 'string') { - return ( - base.length === 1 || - (typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.path).startsWith( - base.charCodeAt(base.length - 1) === slash ? base : `${base}/`, - ) - ); - } - - if (typeof pathOrUri === 'string') { - return ( - base.path.length === 1 || - pathOrUri.startsWith(base.path.charCodeAt(base.path.length - 1) === slash ? base.path : `${base.path}/`) - ); - } - - return ( - base.scheme === pathOrUri.scheme && - base.authority === pathOrUri.authority && - (base.path.length === 1 || - pathOrUri.path.startsWith( - base.path.charCodeAt(base.path.length - 1) === slash ? base.path : `${base.path}/`, - )) - ); -} - export function isAbsolute(path: string): boolean { return !maybeUri(path) && _isAbsolute(path); } @@ -191,55 +71,10 @@ export function normalizePath(path: string): string { return path; } -export function relative(from: string, to: string, ignoreCase?: boolean): string { - from = hasSchemeRegex.test(from) ? Uri.parse(from, true).path : normalizePath(from); - to = hasSchemeRegex.test(to) ? Uri.parse(to, true).path : normalizePath(to); - - const index = commonBaseIndex(`${to}/`, `${from}/`, '/', ignoreCase); - return index > 0 ? to.substring(index + 1) : to; -} - -export function relativeDir(relativePath: string, relativeTo?: string): string { - const dirPath = dirname(relativePath); - if (!dirPath || dirPath === '.' || dirPath === relativeTo) return ''; - if (!relativeTo) return dirPath; - - const [relativeDirPath] = splitPath(dirPath, relativeTo); - return relativeDirPath; -} - -export function splitPath( - pathOrUri: string | Uri, - repoPath: string | undefined, - splitOnBaseIfMissing: boolean = false, - ignoreCase?: boolean, -): [string, string] { - pathOrUri = getBestPath(pathOrUri); - - if (repoPath) { - let repoUri; - if (hasSchemeRegex.test(repoPath)) { - repoUri = Uri.parse(repoPath, true); - repoPath = getBestPath(repoUri); - } else { - repoPath = normalizePath(repoPath); - } - - const index = commonBaseIndex(`${repoPath}/`, `${pathOrUri}/`, '/', ignoreCase); - if (index > 0) { - repoPath = pathOrUri.substring(0, index); - pathOrUri = pathOrUri.substring(index + 1); - } else if (pathOrUri.charCodeAt(0) === slash) { - pathOrUri = pathOrUri.slice(1); - } - - if (repoUri != null) { - repoPath = repoUri.with({ path: repoPath }).toString(); - } - } else { - repoPath = normalizePath(splitOnBaseIfMissing ? dirname(pathOrUri) : ''); - pathOrUri = splitOnBaseIfMissing ? basename(pathOrUri) : pathOrUri; +export function pathEquals(a: string, b: string, ignoreCase?: boolean): boolean { + if (ignoreCase || (ignoreCase == null && !isLinux)) { + a = a.toLowerCase(); + b = b.toLowerCase(); } - - return [pathOrUri, repoPath]; + return normalizePath(a) === normalizePath(b); } diff --git a/src/system/promise.ts b/src/system/promise.ts index 120ce61e1d891..a990ba46a3a8f 100644 --- a/src/system/promise.ts +++ b/src/system/promise.ts @@ -5,48 +5,55 @@ export type PromiseOrValue = Promise | T; export function any(...promises: Promise[]): Promise { return new Promise((resolve, reject) => { - const errors: Error[] = []; let settled = false; + const onFullfilled = (r: T) => { + settled = true; + resolve(r); + }; + + let errors: Error[]; + const onRejected = (ex: unknown) => { + if (settled) return; + if (!(ex instanceof Error)) { + debugger; + return; + } + + if (errors == null) { + errors = [ex]; + } else { + errors.push(ex); + } + + if (promises.length - errors.length < 1) { + reject(new AggregateError(errors)); + } + }; for (const promise of promises) { - // eslint-disable-next-line no-loop-func - void (async () => { - try { - const result = await promise; - if (settled) return; - - resolve(result); - settled = true; - } catch (ex) { - errors.push(ex); - } finally { - if (!settled) { - if (promises.length - errors.length < 1) { - reject(new AggregateError(errors)); - settled = true; - } - } - } - })(); + promise.then(onFullfilled, onRejected); } }); } -export async function* fastestSettled(promises: Promise[]): AsyncIterable> { +export async function* asSettled(promises: Promise[]): AsyncIterable> { const map = new Map( - promises.map((promise, i) => [ - i, - promise.then( - v => - ({ index: i, value: v, status: 'fulfilled' } as unknown as PromiseFulfilledResult & { - index: number; - }), - e => - ({ index: i, reason: e, status: 'rejected' } as unknown as PromiseRejectedResult & { - index: number; - }), - ), - ]), + promises.map( + (promise, i) => + [ + i, + promise.then( + v => + ({ index: i, value: v, status: 'fulfilled' }) as unknown as PromiseFulfilledResult & { + index: number; + }, + (ex: unknown) => + ({ index: i, reason: ex, status: 'rejected' }) as unknown as PromiseRejectedResult & { + index: number; + }, + ), + ] as const, + ), ); while (map.size) { @@ -57,68 +64,76 @@ export async function* fastestSettled(promises: Promise[]): AsyncIterable< } export class PromiseCancelledError = Promise> extends Error { - constructor(public readonly promise: T, message: string) { + constructor( + public readonly promise: T, + message: string, + ) { super(message); } } -export class PromiseCancelledErrorWithId = Promise> extends PromiseCancelledError { - constructor(public readonly id: TKey, promise: T, message: string) { - super(promise, message); - } -} - export function cancellable( promise: Promise, - timeoutOrToken?: number | CancellationToken, - options: { + timeout?: number | CancellationToken, + cancellation?: CancellationToken, + options?: { cancelMessage?: string; - onDidCancel?(resolve: (value: T | PromiseLike) => void, reject: (reason?: any) => void): void; - } = {}, + onDidCancel?( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: any) => void, + reason: 'cancelled' | 'timedout', + ): void; + }, ): Promise { - if (timeoutOrToken == null || (typeof timeoutOrToken === 'number' && timeoutOrToken <= 0)) return promise; + if (timeout == null && cancellation == null) return promise; return new Promise((resolve, reject) => { let fulfilled = false; - let timer: ReturnType | undefined; - let disposable: Disposable | undefined; - - if (typeof timeoutOrToken === 'number') { - timer = setTimeout(() => { - if (typeof options.onDidCancel === 'function') { - options.onDidCancel(resolve, reject); - } else { - reject(new PromiseCancelledError(promise, options.cancelMessage ?? 'TIMED OUT')); - } - }, timeoutOrToken); - } else { - disposable = timeoutOrToken.onCancellationRequested(() => { - disposable?.dispose(); - if (fulfilled) return; - - if (typeof options.onDidCancel === 'function') { - options.onDidCancel(resolve, reject); - } else { - reject(new PromiseCancelledError(promise, options.cancelMessage ?? 'CANCELLED')); - } - }); + let disposeCancellation: Disposable | undefined; + let disposeTimeout: Disposable | undefined; + + const resolver = (reason: 'cancelled' | 'timedout') => { + disposeCancellation?.dispose(); + disposeTimeout?.dispose(); + + if (fulfilled) return; + + if (options?.onDidCancel != null) { + options.onDidCancel(resolve, reject, reason); + } else { + reject( + new PromiseCancelledError( + promise, + options?.cancelMessage ?? (reason === 'cancelled' ? 'CANCELLED' : 'TIMED OUT'), + ), + ); + } + }; + + disposeCancellation = cancellation?.onCancellationRequested(() => resolver('cancelled')); + if (timeout != null) { + if (typeof timeout === 'number') { + const timer = setTimeout(() => resolver('timedout'), timeout); + disposeTimeout = { dispose: () => clearTimeout(timer) }; + } else { + disposeTimeout = timeout.onCancellationRequested(() => resolver('timedout')); + } } promise.then( () => { fulfilled = true; - if (timer != null) { - clearTimeout(timer); - } - disposable?.dispose(); + disposeCancellation?.dispose(); + disposeTimeout?.dispose(); + resolve(promise); }, - ex => { + (ex: unknown) => { fulfilled = true; - if (timer != null) { - clearTimeout(timer); - } - disposable?.dispose(); + disposeCancellation?.dispose(); + disposeTimeout?.dispose(); + + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(ex); }, ); @@ -126,135 +141,566 @@ export function cancellable( } export interface Deferred { - promise: Promise; + readonly pending: boolean; + readonly promise: Promise; fulfill: (value: T) => void; - cancel(): void; + cancel(e?: Error): void; } export function defer(): Deferred { - const deferred: Deferred = { promise: undefined!, fulfill: undefined!, cancel: undefined! }; + const deferred: Mutable> = { + pending: true, + promise: undefined!, + fulfill: undefined!, + cancel: undefined!, + }; deferred.promise = new Promise((resolve, reject) => { - deferred.fulfill = resolve; - deferred.cancel = reject; + deferred.fulfill = function (value) { + deferred.pending = false; + resolve(value); + }; + deferred.cancel = function (e?: Error) { + deferred.pending = false; + if (e != null) { + reject(e); + } else { + reject(); + } + }; }); return deferred; } -export function getSettledValue(promise: PromiseSettledResult): T | undefined; -export function getSettledValue(promise: PromiseSettledResult, defaultValue: NonNullable): NonNullable; +export function getDeferredPromiseIfPending(deferred: Deferred | undefined): Promise | undefined { + return deferred?.pending ? deferred.promise : undefined; +} + +export function getSettledValue(promise: PromiseSettledResult | undefined): T | undefined; +export function getSettledValue( + promise: PromiseSettledResult | undefined, + defaultValue: NonNullable, +): NonNullable; export function getSettledValue( - promise: PromiseSettledResult, + promise: PromiseSettledResult | undefined, defaultValue: T | undefined = undefined, ): T | typeof defaultValue { - return promise.status === 'fulfilled' ? promise.value : defaultValue; + return promise?.status === 'fulfilled' ? promise.value : defaultValue; } export function isPromise(obj: PromiseLike | T): obj is Promise { - return obj instanceof Promise || typeof (obj as PromiseLike)?.then === 'function'; + return obj != null && (obj instanceof Promise || typeof (obj as PromiseLike)?.then === 'function'); } -export function progress(promise: Promise, intervalMs: number, onProgress: () => boolean): Promise { - return new Promise((resolve, reject) => { - let timer: ReturnType | undefined; - timer = setInterval(() => { - if (onProgress()) { - if (timer != null) { - clearInterval(timer); - timer = undefined; - } +type PausedResult = { + value: Promise; + paused: true; + reason: 'cancelled' | 'timedout'; +}; + +export type CompletedResult = { + value: T; + paused: false; +}; + +export type MaybePausedResult = PausedResult | CompletedResult; + +export function pauseOnCancelOrTimeout( + promise: T | Promise, + cancellation?: undefined, + timeout?: undefined, +): Promise>; +export function pauseOnCancelOrTimeout( + promise: T | Promise, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult) => void | Promise, +): Promise>; +export function pauseOnCancelOrTimeout( + promise: T | Promise, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult) => void | Promise, +): Promise> { + if (!isPromise(promise)) { + return Promise.resolve({ value: promise, paused: false } satisfies MaybePausedResult); + } + + if (cancellation == null && timeout == null) { + return promise.then(value => ({ value: value, paused: false }) satisfies CompletedResult); + } + + let disposeCancellation: Disposable | undefined; + let disposeTimeout: Disposable | undefined; + + const result = Promise.race([ + promise.then(value => { + disposeCancellation?.dispose(); + disposeTimeout?.dispose(); + + if (cancellation?.isCancellationRequested) { + return { + value: Promise.resolve(value), + paused: true, + reason: 'cancelled', + } satisfies MaybePausedResult; } - }, intervalMs); - promise.then( - () => { - if (timer != null) { - clearInterval(timer); - timer = undefined; + return { value: value, paused: false } satisfies MaybePausedResult; + }), + new Promise>(resolve => { + const resolver = (reason: 'cancelled' | 'timedout') => { + disposeCancellation?.dispose(); + disposeTimeout?.dispose(); + + resolve({ + value: promise, + paused: true, + reason: reason, + } satisfies MaybePausedResult); + }; + + disposeCancellation = cancellation?.onCancellationRequested(() => resolver('cancelled')); + if (timeout != null) { + const signal = typeof timeout === 'number' ? AbortSignal.timeout(timeout) : timeout; + + const handler = () => resolver('timedout'); + signal.addEventListener('abort', handler); + disposeTimeout = { dispose: () => signal.removeEventListener('abort', handler) }; + } + }), + ]); + + return continuation == null + ? result + : result.then(r => { + if (r.paused) { + setTimeout(() => continuation(r), 0); } + return r; + }); +} - resolve(promise); - }, - ex => { - if (timer != null) { - clearInterval(timer); - timer = undefined; +export async function pauseOnCancelOrTimeoutMap( + source: Map>, + ignoreErrors: true, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise>>; +export async function pauseOnCancelOrTimeoutMap( + source: Map>, + ignoreErrors?: boolean, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise>>; +export async function pauseOnCancelOrTimeoutMap( + source: Map>, + ignoreErrors?: boolean, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise>> { + if (source.size === 0) return source as unknown as Map>; + + // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers + if (timeout != null && typeof timeout === 'number') { + timeout = AbortSignal.timeout(timeout); + } + + const results = await Promise.all( + map(source, ([id, promise]) => + pauseOnCancelOrTimeout( + promise.catch((ex: unknown) => (ignoreErrors || !(ex instanceof Error) ? undefined : ex)), + cancellation, + timeout, + ).then(result => [id, result] as const), + ), + ); + + if (continuation != null) { + if (results.some(([, r]) => r.paused)) { + async function getContinuationValue() { + const completed = new Map>(); + + for (const [id, result] of results) { + completed.set(id, { value: result.paused ? await result.value : result.value, paused: false }); } - reject(ex); - }, - ); - }); + return completed; + } + + const cancelled = results.some(([, r]) => r.paused && r.reason === 'cancelled'); + + void continuation({ + value: getContinuationValue(), + paused: true, + reason: cancelled ? 'cancelled' : 'timedout', + }); + } + } + + return new Map>(results); } -export function raceAll( - promises: Promise[], - timeout?: number, -): Promise<(TPromise | PromiseCancelledError>)[]>; -export function raceAll( - promises: Map>, - timeout?: number, -): Promise>>>; -export function raceAll( - ids: Iterable, - fn: (id: T) => Promise, - timeout?: number, -): Promise>>>; -export async function raceAll( - promisesOrIds: Promise[] | Map> | Iterable, - timeoutOrFn?: number | ((id: T) => Promise), - timeout?: number, -) { - let promises; - if (timeoutOrFn != null && typeof timeoutOrFn !== 'number') { - promises = new Map(map]>(promisesOrIds as Iterable, id => [id, timeoutOrFn(id)])); - } else { - timeout = timeoutOrFn; - promises = promisesOrIds as Promise[] | Map>; +export async function pauseOnCancelOrTimeoutMapPromise( + source: Promise> | undefined>, + ignoreErrors: true, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise> | undefined>>; +export async function pauseOnCancelOrTimeoutMapPromise( + source: Promise> | undefined>, + ignoreErrors?: boolean, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise> | undefined>>; +export async function pauseOnCancelOrTimeoutMapPromise( + source: Promise> | undefined>, + ignoreErrors?: boolean, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: (result: PausedResult>>) => void | Promise, +): Promise> | undefined>> { + // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers + if (timeout != null && typeof timeout === 'number') { + timeout = AbortSignal.timeout(timeout); } - if (promises instanceof Map) { - return new Map( - await Promise.all( - map<[T, Promise], Promise<[T, TPromise | PromiseCancelledErrorWithId>]>>( - promises.entries(), - timeout == null - ? ([id, promise]) => promise.then(p => [id, p]) - : ([id, promise]) => - Promise.race([ - promise, - - new Promise>>(resolve => - setTimeout( - () => resolve(new PromiseCancelledErrorWithId(id, promise, 'TIMED OUT')), - timeout, - ), - ), - ]).then(p => [id, p]), - ), - ), - ); + const mapPromise = source.then(m => + m == null ? m : pauseOnCancelOrTimeoutMap(m, ignoreErrors, cancellation, timeout, continuation), + ); + + const result = await pauseOnCancelOrTimeout(source, cancellation, timeout); + return result.paused + ? { value: mapPromise, paused: result.paused, reason: result.reason } + : { value: await mapPromise, paused: false }; +} + +export async function pauseOnCancelOrTimeoutMapTuple( + source: Map | undefined, ...U]>, + cancellation?: undefined, + timeout?: undefined, +): Promise | undefined, ...U]>>; +export async function pauseOnCancelOrTimeoutMapTuple( + source: Map | undefined, ...U]>, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: ( + result: PausedResult | undefined, ...U]>>, + ) => void | Promise, +): Promise | undefined, ...U]>>; +export async function pauseOnCancelOrTimeoutMapTuple( + source: Map | undefined, ...U]>, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: ( + result: PausedResult | undefined, ...U]>>, + ) => void | Promise, +): Promise | undefined, ...U]>> { + if (source.size === 0) { + return source as unknown as Map | undefined, ...U]>; + } + + // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers + if (timeout != null && typeof timeout === 'number') { + timeout = AbortSignal.timeout(timeout); + } + + const results = await Promise.all( + map(source, ([id, [promise, ...rest]]) => + promise == null + ? ([id, [undefined, ...rest]] as const) + : pauseOnCancelOrTimeout( + promise.catch(() => undefined), + cancellation, + timeout, + ).then(result => [id, [result as MaybePausedResult | undefined, ...rest]] as const), + ), + ); + + if (continuation != null) { + if (results.some(([, [r]]) => r?.paused ?? false)) { + async function getContinuationValue() { + const completed = new Map | undefined, ...U]>(); + + for (const [id, [r, ...rest]] of results) { + completed.set(id, [ + { value: r?.paused ? await r.value : r?.value, paused: false }, + ...rest, + ] as const); + } + + return completed; + } + + const cancelled = results.some(([, [r]]) => r?.paused && r.reason === 'cancelled'); + + void continuation({ + value: getContinuationValue(), + paused: true, + reason: cancelled ? 'cancelled' : 'timedout', + }); + } + } + + return new Map | undefined, ...U]>(results); +} + +export async function pauseOnCancelOrTimeoutMapTuplePromise( + source: Promise | undefined, ...U]> | undefined>, + cancellation?: undefined, + timeout?: undefined, +): Promise | undefined, ...U]> | undefined>>; +export async function pauseOnCancelOrTimeoutMapTuplePromise( + source: Promise | undefined, ...U]> | undefined>, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: ( + result: PausedResult | undefined, ...U]>>, + ) => void | Promise, +): Promise | undefined, ...U]> | undefined>>; +export async function pauseOnCancelOrTimeoutMapTuplePromise( + source: Promise | undefined, ...U]> | undefined>, + cancellation?: CancellationToken, + timeout?: number | AbortSignal, + continuation?: ( + result: PausedResult | undefined, ...U]>>, + ) => void | Promise, +): Promise | undefined, ...U]> | undefined>> { + // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers + if (timeout != null && typeof timeout === 'number') { + timeout = AbortSignal.timeout(timeout); } - return Promise.all( - timeout == null - ? promises - : promises.map(p => - Promise.race([ - p, - new Promise>>(resolve => - setTimeout(() => resolve(new PromiseCancelledError(p, 'TIMED OUT')), timeout), - ), - ]), - ), + const mapPromise = source.then(m => + m == null ? m : pauseOnCancelOrTimeoutMapTuple(m, cancellation, timeout, continuation), ); + + const result = await pauseOnCancelOrTimeout(source, cancellation, timeout); + return result.paused + ? { value: mapPromise, paused: result.paused, reason: result.reason } + : { value: await mapPromise, paused: false }; +} + +// type PromiseKeys = { +// [K in keyof T]: T[K] extends Promise | undefined ? K : never; +// }[keyof T]; +// type WithCompletedResult> = Omit & { +// [K in U]: CompletedResult | undefined> | undefined; +// }; +// type WithMaybePausedResult> = Omit & { +// [K in U]: MaybePausedResult | undefined> | undefined; +// }; + +// export async function pauseOnCancelOrTimeoutMapOnProp>( +// source: Map, +// prop: U, +// cancellation?: undefined, +// timeout?: undefined, +// ): Promise>>; +// export async function pauseOnCancelOrTimeoutMapOnProp>( +// source: Map, +// prop: U, +// cancellation?: CancellationToken, +// timeout?: number | AbortSignal, +// continuation?: (result: PausedResult>>) => void | Promise, +// ): Promise>>; +// export async function pauseOnCancelOrTimeoutMapOnProp>( +// source: Map, +// prop: U, +// cancellation?: CancellationToken, +// timeout?: number | AbortSignal, +// continuation?: (result: PausedResult>>) => void | Promise, +// ): Promise>> { +// if (source.size === 0) { +// return source as unknown as Map>; +// } + +// // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers +// if (timeout != null && typeof timeout === 'number') { +// timeout = AbortSignal.timeout(timeout); +// } + +// const results = await Promise.all( +// map(source, ([id, item]) => +// item[prop] == null +// ? ([id, item as WithMaybePausedResult] as const) +// : pauseOnCancelOrTimeout( +// (item[prop] as Promise).catch(() => undefined), +// cancellation, +// timeout, +// ).then(result => { +// (item as any)[prop] = result; +// return [id, item as WithMaybePausedResult] as const; +// }), +// ), +// ); + +// if (continuation != null) { +// if (results.some(([, r]) => (r as any)[prop]?.paused ?? false)) { +// async function getContinuationValue() { +// const completed = new Map>(); + +// for (const [id, result] of results) { +// const r = result[prop]; // as MaybePausedResult> | undefined; +// (result as /*WithCompletedResult*/ any)[prop] = r?.paused ? await r.value : r?.value; +// completed.set(id, result as WithCompletedResult); +// } + +// return completed; +// } + +// const cancelled = results.some(([, result]) => { +// const r = result[prop]; +// return r?.paused && r.reason === 'cancelled'; +// }); + +// void continuation({ +// value: getContinuationValue(), +// paused: true, +// reason: cancelled ? 'cancelled' : 'timedout', +// }); +// } +// } + +// return new Map>(results); +// } + +// export async function pauseOnCancelOrTimeoutMapOnPropPromise>( +// source: Promise | undefined>, +// prop: U, +// cancellation?: undefined, +// timeout?: undefined, +// ): Promise> | undefined>>; +// export async function pauseOnCancelOrTimeoutMapOnPropPromise>( +// source: Promise | undefined>, +// prop: U, +// cancellation?: CancellationToken, +// timeout?: number | AbortSignal, +// continuation?: (result: PausedResult>>) => void | Promise, +// ): Promise> | undefined>>; +// export async function pauseOnCancelOrTimeoutMapOnPropPromise>( +// source: Promise | undefined>, +// prop: U, +// cancellation?: CancellationToken, +// timeout?: number | AbortSignal, +// continuation?: (result: PausedResult>>) => void | Promise, +// ): Promise> | undefined>> { +// // Change the timeout to an AbortSignal if it is a number to avoid creating lots of timers +// if (timeout != null && typeof timeout === 'number') { +// timeout = AbortSignal.timeout(timeout); +// } + +// const mapPromise = source.then(m => +// m == null ? m : pauseOnCancelOrTimeoutMapOnProp(m, prop, cancellation, timeout, continuation), +// ); + +// const result = await pauseOnCancelOrTimeout(source, cancellation, timeout); +// return result.paused +// ? { value: mapPromise, paused: result.paused, reason: result.reason } +// : { value: await mapPromise, paused: false }; +// } + +// export function progress(promise: Promise, intervalMs: number, onProgress: () => boolean): Promise { +// return new Promise((resolve, reject) => { +// let timer: ReturnType | undefined; +// timer = setInterval(() => { +// if (onProgress()) { +// if (timer != null) { +// clearInterval(timer); +// timer = undefined; +// } +// } +// }, intervalMs); + +// promise.then( +// () => { +// if (timer != null) { +// clearInterval(timer); +// timer = undefined; +// } + +// resolve(promise); +// }, +// ex => { +// if (timer != null) { +// clearInterval(timer); +// timer = undefined; +// } + +// reject(ex); +// }, +// ); +// }); +// } + +// export async function resolveMap( +// source: Map>, +// ignoreErrors?: false, +// ): Promise>; +// export async function resolveMap( +// source: Promise> | undefined>, +// ignoreErrors?: false, +// ): Promise | undefined>; +// export async function resolveMap( +// source: Map>, +// ignoreErrors: true, +// ): Promise | undefined>; +// export async function resolveMap( +// source: Promise> | undefined>, +// ignoreErrors: true, +// ): Promise>; +// export async function resolveMap( +// source: Map> | Promise> | undefined>, +// ignoreErrors?: boolean, +// ): Promise | undefined> { +// if (isPromise(source)) { +// const map = await source; +// if (map == null) return undefined; + +// source = map; +// } + +// const promises = map(source, ([id, promise]) => +// promise.then( +// p => [id, p as T | Error | undefined] as const, +// ex => [id, (ignoreErrors || !(ex instanceof Error) ? undefined : ex) as T | Error | undefined] as const, +// ), +// ); +// return new Map(await Promise.all(promises)); +// } + +export type TimedResult = { readonly value: T; readonly duration: number }; +export async function timed(promise: Promise): Promise> { + const start = Date.now(); + const value = await promise; + return { value: value, duration: Date.now() - start }; +} + +export async function timedWithSlowThreshold( + promise: Promise, + slowThreshold: { timeout: number; onSlow: (duration: number) => void }, +): Promise> { + const start = Date.now(); + + const result = await pauseOnCancelOrTimeout(promise, undefined, slowThreshold.timeout); + + const value = result.paused + ? await result.value.finally(() => slowThreshold.onSlow(Date.now() - start)) + : result.value; + + return { value: value, duration: Date.now() - start }; } -export async function wait(ms: number): Promise { - await new Promise(resolve => setTimeout(resolve, ms)); +export function wait(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } -export async function waitUntilNextTick(): Promise { - await new Promise(resolve => queueMicrotask(resolve)); +export function waitUntilNextTick(): Promise { + return new Promise(resolve => queueMicrotask(resolve)); } export class AggregateError extends Error { diff --git a/src/system/searchTree.ts b/src/system/searchTree.ts index 881eaba8125c3..a85cf718b3a89 100644 --- a/src/system/searchTree.ts +++ b/src/system/searchTree.ts @@ -49,7 +49,10 @@ export class PathIterator implements IKeyIterator { private _from!: number; private _to!: number; - constructor(private readonly _splitOnBackslash: boolean = true, private readonly _caseSensitive: boolean = true) {} + constructor( + private readonly _splitOnBackslash: boolean = true, + private readonly _caseSensitive: boolean = true, + ) {} reset(key: string): this { this._value = key.replace(/\\$|\/$/, ''); @@ -201,11 +204,11 @@ export class TernarySearchTree { } delete(key: K): void { - return this._delete(key, false); + this._delete(key, false); } deleteSuperstr(key: K): void { - return this._delete(key, true); + this._delete(key, true); } private _delete(key: K, superStr: boolean): void { diff --git a/src/system/serialize.ts b/src/system/serialize.ts deleted file mode 100644 index d40cac14bf2b7..0000000000000 --- a/src/system/serialize.ts +++ /dev/null @@ -1,27 +0,0 @@ -export type Serialized = T extends Function - ? never - : T extends Date - ? number - : T extends object - ? { - [K in keyof T]: T[K] extends Date ? number : Serialized; - } - : T; - -export function serialize(obj: T): Serialized { - try { - function replacer(this: any, key: string, value: unknown) { - if (value instanceof Date) return value.getTime(); - if (value instanceof Map || value instanceof Set) return [...value.entries()]; - if (value instanceof Function || value instanceof Error) return undefined; - if (value instanceof RegExp) return value.toString(); - - const original = this[key]; - return original instanceof Date ? original.getTime() : value; - } - return JSON.parse(JSON.stringify(obj, replacer)) as Serialized; - } catch (ex) { - debugger; - throw ex; - } -} diff --git a/src/system/stopwatch.ts b/src/system/stopwatch.ts index fadc4d38b3e61..a480604a44224 100644 --- a/src/system/stopwatch.ts +++ b/src/system/stopwatch.ts @@ -1,9 +1,12 @@ import { hrtime } from '@env/hrtime'; -import { GlyphChars, LogLevel } from '../constants'; -import type { LogProvider } from '../logger'; -import { defaultLogProvider } from '../logger'; -import type { LogScope } from '../logScope'; -import { getNextLogScopeId } from '../logScope'; +import type { LogProvider } from './logger'; +import { defaultLogProvider } from './logger'; +import type { LogLevel } from './logger.constants'; +import type { LogScope } from './logger.scope'; +import { getNewLogScope } from './logger.scope'; + +(Symbol as any).dispose ??= Symbol('Symbol.dispose'); +(Symbol as any).asyncDispose ??= Symbol('Symbol.asyncDispose'); type StopwatchLogOptions = { message?: string; suffix?: string }; type StopwatchOptions = { @@ -11,10 +14,10 @@ type StopwatchOptions = { logLevel?: StopwatchLogLevel; provider?: LogProvider; }; -type StopwatchLogLevel = Exclude; +type StopwatchLogLevel = Exclude; -export class Stopwatch { - private readonly instance = `[${String(getNextLogScopeId()).padStart(5)}] `; +export class Stopwatch implements Disposable { + private readonly logScope: LogScope; private readonly logLevel: StopwatchLogLevel; private readonly logProvider: LogProvider; @@ -23,13 +26,10 @@ export class Stopwatch { return this._time; } - constructor(public readonly scope: string | LogScope | undefined, options?: StopwatchOptions, ...params: any[]) { - let logScope; - if (typeof scope !== 'string') { - logScope = scope; - scope = ''; - this.instance = ''; - } + private _stopped = false; + + constructor(scope: string | LogScope | undefined, options?: StopwatchOptions, ...params: any[]) { + this.logScope = scope != null && typeof scope !== 'string' ? scope : getNewLogScope(scope ?? '', false); let logOptions: StopwatchLogOptions | undefined; if (typeof options?.log === 'boolean') { @@ -38,7 +38,7 @@ export class Stopwatch { logOptions = options?.log ?? {}; } - this.logLevel = options?.logLevel ?? LogLevel.Info; + this.logLevel = options?.logLevel ?? 'info'; this.logProvider = options?.provider ?? defaultLogProvider; this._time = hrtime(); @@ -48,57 +48,51 @@ export class Stopwatch { if (params.length) { this.logProvider.log( this.logLevel, - logScope, - `${this.instance}${scope}${logOptions.message ?? ''}${logOptions.suffix ?? ''}`, + this.logScope, + `${logOptions.message ?? ''}${logOptions.suffix ?? ''}`, ...params, ); } else { this.logProvider.log( this.logLevel, - logScope, - `${this.instance}${scope}${logOptions.message ?? ''}${logOptions.suffix ?? ''}`, + this.logScope, + `${logOptions.message ?? ''}${logOptions.suffix ?? ''}`, ); } } } + [Symbol.dispose](): void { + this.stop(); + } + elapsed(): number { const [secs, nanosecs] = hrtime(this._time); return secs * 1000 + Math.floor(nanosecs / 1000000); } log(options?: StopwatchLogOptions): void { - this.logCore(this.scope, options, false); + this.logCore(options, false); } restart(options?: StopwatchLogOptions): void { - this.logCore(this.scope, options, true); + this.logCore(options, true); this._time = hrtime(); + this._stopped = false; } stop(options?: StopwatchLogOptions): void { + if (this._stopped) return; + this.restart(options); + this._stopped = true; } - private logCore( - scope: string | LogScope | undefined, - options: StopwatchLogOptions | undefined, - logTotalElapsed: boolean, - ): void { + private logCore(options: StopwatchLogOptions | undefined, logTotalElapsed: boolean): void { if (!this.logProvider.enabled(this.logLevel)) return; - let logScope; - if (typeof scope !== 'string') { - logScope = scope; - scope = ''; - } - if (!logTotalElapsed) { - this.logProvider.log( - this.logLevel, - logScope, - `${this.instance}${scope}${options?.message ?? ''}${options?.suffix ?? ''}`, - ); + this.logProvider.log(this.logLevel, this.logScope, `${options?.message ?? ''}${options?.suffix ?? ''}`); return; } @@ -106,27 +100,21 @@ export class Stopwatch { const [secs, nanosecs] = hrtime(this._time); const ms = secs * 1000 + Math.floor(nanosecs / 1000000); - const prefix = `${this.instance}${scope}${options?.message ?? ''}`; + const prefix = options?.message ?? ''; this.logProvider.log( - ms > 250 ? LogLevel.Warn : this.logLevel, - logScope, - `${prefix ? `${prefix} ${GlyphChars.Dot} ` : ''}${ms} ms${options?.suffix ?? ''}`, + ms > 250 ? 'warn' : this.logLevel, + this.logScope, + `${prefix ? `${prefix} ` : ''}[${ms}ms]${options?.suffix ?? ''}`, ); } +} - private static readonly watches = new Map(); - - static start(key: string, options?: StopwatchOptions, ...params: any[]): void { - Stopwatch.watches.get(key)?.log(); - Stopwatch.watches.set(key, new Stopwatch(key, options, ...params)); - } - - static log(key: string, options?: StopwatchLogOptions): void { - Stopwatch.watches.get(key)?.log(options); - } - - static stop(key: string, options?: StopwatchLogOptions): void { - Stopwatch.watches.get(key)?.stop(options); - Stopwatch.watches.delete(key); - } +export function maybeStopWatch( + scope: string | LogScope | undefined, + options?: StopwatchOptions, + ...params: any[] +): Stopwatch | undefined { + return (options?.provider ?? defaultLogProvider).enabled(options?.logLevel ?? 'info') + ? new Stopwatch(scope, options, ...params) + : undefined; } diff --git a/src/system/string.ts b/src/system/string.ts index ef764646b64bf..7a21e6807db68 100644 --- a/src/system/string.ts +++ b/src/system/string.ts @@ -1,11 +1,24 @@ -import ansiRegex from 'ansi-regex'; import { hrtime } from '@env/hrtime'; +import type { + WidthOptions as StringWidthOptions, + TruncationOptions as StringWidthTruncationOptions, + Result as TruncatedStringWidthResult, +} from '@gk-nzaytsev/fast-string-truncated-width'; +import getTruncatedStringWidth from '@gk-nzaytsev/fast-string-truncated-width'; import { CharCode } from '../constants'; export { fromBase64, base64 } from '@env/base64'; -const compareCollator = new Intl.Collator(undefined, { sensitivity: 'accent' }); +export function capitalize(s: string) { + return `${s[0].toLocaleUpperCase()}${s.slice(1)}`; +} + +let compareCollator: Intl.Collator | undefined; export function compareIgnoreCase(a: string, b: string): 0 | -1 | 1 { + if (compareCollator == null) { + compareCollator = new Intl.Collator(undefined, { sensitivity: 'accent' }); + } + const result = compareCollator.compare(a, b); // Intl.Collator.compare isn't guaranteed to always return 1 or -1 on all platforms so normalize it return result === 0 ? 0 : result > 0 ? 1 : -1; @@ -18,8 +31,13 @@ export function equalsIgnoreCase(a: string | null | undefined, b: string | null return compareIgnoreCase(a, b) === 0; } -export const sortCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); -export const sortCompare = sortCollator.compare; +let sortCollator: Intl.Collator | undefined; +export function sortCompare(x: string, y: string): number { + if (sortCollator == null) { + sortCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); + } + return sortCollator.compare(x, y); +} export function compareSubstring( a: string, @@ -113,7 +131,11 @@ export function encodeHtmlWeak(s: string | undefined): string | undefined { } const escapeMarkdownRegex = /[\\`*_{}[\]()#+\-.!]/g; +const unescapeMarkdownRegex = /\\([\\`*_{}[\]()#+\-.!])/g; + const escapeMarkdownHeaderRegex = /^===/gm; +const unescapeMarkdownHeaderRegex = /^\u200b===/gm; + // const sampleMarkdown = '## message `not code` *not important* _no underline_ \n> don\'t quote me \n- don\'t list me \n+ don\'t list me \n1. don\'t list me \nnot h1 \n=== \nnot h2 \n---\n***\n---\n___'; const markdownQuotedRegex = /\r?\n/g; @@ -130,6 +152,16 @@ export function escapeMarkdown(s: string, options: { quoted?: boolean } = {}): s return s.trim().replace(markdownQuotedRegex, '\t\\\n> '); } +export function unescapeMarkdown(s: string): string { + return ( + s + // Unescape markdown + .replace(unescapeMarkdownRegex, '$1') + // Unescape markdown header + .replace(unescapeMarkdownHeaderRegex, '===') + ); +} + export function escapeRegex(s: string) { return s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); } @@ -182,17 +214,59 @@ export function* getLines(data: string | string[], char: string = '\n'): Iterabl } } +const defaultTruncationOptions: StringWidthTruncationOptions = { + ellipsisWidth: 0, + limit: 2 ** 30 - 1, // Max number that can be stored in V8's smis (small integers) +}; + +const defaultWidthOptions: StringWidthOptions = { + ansiWidth: 0, + controlWidth: 0, + ambiguousWidth: 1, + emojiWidth: 2, + fullWidthWidth: 2, + regularWidth: 1, + wideWidth: 2, +}; + +export function getTruncatedWidth(s: string, limit: number, ellipsisWidth: number): TruncatedStringWidthResult { + if (s == null || s.length === 0) { + return { + truncated: false, + ellipsed: false, + width: 0, + index: 0, + }; + } + + return getTruncatedStringWidth(s, { limit: limit, ellipsisWidth: ellipsisWidth ?? 0 }, defaultWidthOptions); +} + +export function getWidth(s: string): number { + if (s == null || s.length === 0) return 0; + + const result = getTruncatedStringWidth(s, defaultTruncationOptions, defaultWidthOptions); + return result.width; +} + const superscripts = ['\u00B9', '\u00B2', '\u00B3', '\u2074', '\u2075', '\u2076', '\u2077', '\u2078', '\u2079']; export function getSuperscript(num: number) { return superscripts[num - 1] ?? ''; } -const tokenRegex = /\$\{('.*?[^\\]'|\W*)?([^|]*?)(?:\|(\d+)(-|\?)?)?('.*?[^\\]'|\W*)?\}/g; +const tokenRegex = /\$\{(?:'(.*?[^\\])'|(\W*))?([^|]*?)(?:\|(\d+)(-|\?)?)?(?:'(.*?[^\\])'|(\W*))?\}/g; const tokenSanitizeRegex = /\$\{(?:'.*?[^\\]'|\W*)?(\w*?)(?:'.*?[^\\]'|[\W\d]*)\}/g; const tokenGroupCharacter = "'"; const tokenGroupCharacterEscapedRegex = /(\\')/g; -const tokenGroupRegex = /^'?(.*?)'?$/s; + +interface TokenMatch { + key: string; + start: number; + end: number; + options: TokenOptions; +} +const templateTokenMap = new Map(); export interface TokenOptions { collapseWhitespace: boolean; @@ -202,88 +276,215 @@ export interface TokenOptions { truncateTo: number | undefined; } -export function getTokensFromTemplate(template: string) { - const tokens: { key: string; options: TokenOptions }[] = []; +function isWordChar(code: number): boolean { + return ( + code === 95 /* _ */ || + (code >= 0x61 && code <= 0x7a) || // lowercase letters + (code >= 0x41 && code <= 0x5a) || // uppercase letters + (code >= 0x30 && code <= 0x39) // digits + ); +} - let match; - do { - match = tokenRegex.exec(template); - if (match == null) break; - - let [, prefix, key, truncateTo, option, suffix] = match; - // Check for a prefix group - if (prefix != null) { - match = tokenGroupRegex.exec(prefix); - if (match != null) { - [, prefix] = match; - prefix = prefix.replace(tokenGroupCharacterEscapedRegex, tokenGroupCharacter); +export function getTokensFromTemplate(template: string): TokenMatch[] { + let tokens = templateTokenMap.get(template); + if (tokens != null) return tokens; + + tokens = []; + const length = template.length; + + let position = 0; + while (position < length) { + const tokenStart = template.indexOf('${', position); + if (tokenStart === -1) break; + + const tokenEnd = template.indexOf('}', tokenStart); + if (tokenEnd === -1) break; + + let tokenPos = tokenStart + 2; + + let key = ''; + let prefix = ''; + let truncateTo = ''; + let collapseWhitespace = false; + let padDirection: 'left' | 'right' = 'right'; + let suffix = ''; + + if (template[tokenPos] === "'") { + const start = ++tokenPos; + tokenPos = template.indexOf("'", tokenPos); + if (tokenPos === -1) break; + + if (start !== tokenPos) { + prefix = template.slice(start, tokenPos); + } + tokenPos++; + } else if (!isWordChar(template.charCodeAt(tokenPos))) { + const start = tokenPos++; + while (tokenPos < tokenEnd && !isWordChar(template.charCodeAt(tokenPos))) { + tokenPos++; + } + + if (start !== tokenPos) { + prefix = template.slice(start, tokenPos); } } - // Check for a suffix group - if (suffix != null) { - match = tokenGroupRegex.exec(suffix); - if (match != null) { - [, suffix] = match; - suffix = suffix.replace(tokenGroupCharacterEscapedRegex, tokenGroupCharacter); + while (tokenPos < tokenEnd) { + let code = template.charCodeAt(tokenPos); + if (isWordChar(code)) { + key += template[tokenPos++]; + } else { + if (code !== 0x7c /* | */) break; + + while (tokenPos < tokenEnd) { + code = template.charCodeAt(++tokenPos); + if (code >= 0x30 && code <= 0x39 /* digits */) { + truncateTo += template[tokenPos]; + continue; + } + + if (code === 0x3f /* ? */) { + collapseWhitespace = true; + tokenPos++; + } else if (code === 0x2d /* - */) { + padDirection = 'left'; + tokenPos++; + } + + break; + } } } + if (tokenPos < tokenEnd) { + if (template[tokenPos] === "'") { + const start = ++tokenPos; + tokenPos = template.indexOf("'", tokenPos); + if (tokenPos === -1) break; + + if (start !== tokenPos) { + suffix = template.slice(start, tokenPos); + } + tokenPos++; + } else if (!isWordChar(template.charCodeAt(tokenPos))) { + const start = tokenPos++; + while (tokenPos < tokenEnd && !isWordChar(template.charCodeAt(tokenPos))) { + tokenPos++; + } + + if (start !== tokenPos) { + suffix = template.slice(start, tokenPos); + } + } + } + + position = tokenEnd + 1; tokens.push({ key: key, + start: tokenStart, + end: position, options: { - collapseWhitespace: option === '?', - padDirection: option === '-' ? 'left' : 'right', prefix: prefix || undefined, suffix: suffix || undefined, - truncateTo: truncateTo == null ? undefined : parseInt(truncateTo, 10), + truncateTo: truncateTo ? parseInt(truncateTo, 10) : undefined, + collapseWhitespace: collapseWhitespace, + padDirection: padDirection, }, }); - } while (true); + } + templateTokenMap.set(template, tokens); return tokens; } -const tokenSanitizeReplacement = `$\${$1=this.$1,($1 == null ? '' : $1)}`; -const interpolationMap = new Map(); +// FYI, this is about twice as slow as getTokensFromTemplate +export function getTokensFromTemplateRegex(template: string): TokenMatch[] { + let tokens = templateTokenMap.get(template); + if (tokens != null) return tokens; -export function interpolate(template: string, context: object | undefined): string { - if (template == null || template.length === 0) return template; - if (context == null) return template.replace(tokenSanitizeRegex, ''); + tokens = []; - let fn = interpolationMap.get(template); - if (fn == null) { - // eslint-disable-next-line @typescript-eslint/no-implied-eval - fn = new Function(`return \`${template.replace(tokenSanitizeRegex, tokenSanitizeReplacement)}\`;`); - interpolationMap.set(template, fn); + let match; + while ((match = tokenRegex.exec(template))) { + const [, prefixGroup, prefixNonGroup, key, truncateTo, option, suffixGroup, suffixNonGroup] = match; + const start = match.index; + const end = start + match[0].length; + + let prefix = prefixGroup || prefixNonGroup || undefined; + if (prefix) { + prefix = prefix.replace(tokenGroupCharacterEscapedRegex, tokenGroupCharacter); + } + + let suffix = suffixGroup || suffixNonGroup || undefined; + if (suffix) { + suffix = suffix.replace(tokenGroupCharacterEscapedRegex, tokenGroupCharacter); + } + + tokens.push({ + key: key, + start: start, + end: end, + options: { + collapseWhitespace: option === '?', + padDirection: option === '-' ? 'left' : 'right', + prefix: prefix, + suffix: suffix, + truncateTo: truncateTo == null ? undefined : parseInt(truncateTo, 10), + }, + }); } - return fn.call(context) as string; + templateTokenMap.set(template, tokens); + return tokens; } -// eslint-disable-next-line prefer-arrow-callback -const AsyncFunction = Object.getPrototypeOf(async function () { - /* noop */ -}).constructor; +export function interpolate(template: string, context: object | undefined): string { + if (template == null || template.length === 0) return template; + if (context == null) return template.replace(tokenSanitizeRegex, ''); -const tokenSanitizeReplacementAsync = `$\${$1=this.$1,($1 == null ? '' : typeof $1.then === 'function' ? (($1 = await $1),$1 == null ? '' : $1) : $1)}`; + const tokens = getTokensFromTemplate(template); + if (tokens.length === 0) return template; -const interpolationAsyncMap = new Map(); + let position = 0; + let result = ''; + for (const token of tokens) { + result += template.slice(position, token.start) + ((context as Record)[token.key] ?? ''); + position = token.end; + } + + if (position < template.length) { + result += template.slice(position); + } + + return result; +} export async function interpolateAsync(template: string, context: object | undefined): Promise { if (template == null || template.length === 0) return template; if (context == null) return template.replace(tokenSanitizeRegex, ''); - let fn = interpolationAsyncMap.get(template); - if (fn == null) { - // // eslint-disable-next-line @typescript-eslint/no-implied-eval - const body = `return \`${template.replace(tokenSanitizeRegex, tokenSanitizeReplacementAsync)}\`;`; - fn = new AsyncFunction(body); - interpolationAsyncMap.set(template, fn); + const tokens = getTokensFromTemplate(template); + if (tokens.length === 0) return template; + + let position = 0; + let result = ''; + let value; + for (const token of tokens) { + value = (context as Record)[token.key]; + if (value != null && typeof value === 'object' && typeof value.then === 'function') { + value = await value; + } + + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + result += template.slice(position, token.start) + (value ?? ''); + position = token.end; + } + + if (position < template.length) { + result += template.slice(position); } - const value = await fn.call(context); - return value as string; + return result; } export function isLowerAsciiLetter(code: number): boolean { @@ -300,38 +501,10 @@ export function pad(s: string, before: number = 0, after: number = 0, padding: s return `${before === 0 ? '' : padding.repeat(before)}${s}${after === 0 ? '' : padding.repeat(after)}`; } -export function padLeft(s: string, padTo: number, padding: string = '\u00a0', width?: number) { - const diff = padTo - (width ?? getWidth(s)); - return diff <= 0 ? s : padding.repeat(diff) + s; -} - -export function padLeftOrTruncate(s: string, max: number, padding?: string, width?: number) { - width = width ?? getWidth(s); - if (width < max) return padLeft(s, max, padding, width); - if (width > max) return truncate(s, max, undefined, width); - return s; -} - -export function padRight(s: string, padTo: number, padding: string = '\u00a0', width?: number) { - const diff = padTo - (width ?? getWidth(s)); - return diff <= 0 ? s : s + padding.repeat(diff); -} - -export function padOrTruncate(s: string, max: number, padding?: string, width?: number) { - const left = max < 0; - max = Math.abs(max); - - width = width ?? getWidth(s); - if (width < max) return left ? padLeft(s, max, padding, width) : padRight(s, max, padding, width); - if (width > max) return truncate(s, max, undefined, width); - return s; -} - -export function padRightOrTruncate(s: string, max: number, padding?: string, width?: number) { - width = width ?? getWidth(s); - if (width < max) return padRight(s, max, padding, width); - if (width > max) return truncate(s, max); - return s; +export function padOrTruncateEnd(s: string, maxLength: number, fillString?: string) { + if (s.length === maxLength) return s; + if (s.length > maxLength) return s.substring(0, maxLength); + return s.padEnd(maxLength, fillString); } export function pluralize( @@ -371,7 +544,7 @@ export function splitLast(s: string, splitter: string) { const index = s.lastIndexOf(splitter); if (index === -1) return [s]; - return [s.substr(index), s.substring(0, index - 1)]; + return [s.substring(index), s.substring(0, index - 1)]; } export function splitSingle(s: string, splitter: string) { @@ -437,114 +610,191 @@ export function truncateMiddle(s: string, truncateTo: number, ellipsis: string = return `${s.slice(0, Math.floor(truncateTo / 2) - 1)}${ellipsis}${s.slice(width - Math.ceil(truncateTo / 2))}`; } -let cachedAnsiRegex: RegExp | undefined; -const containsNonAsciiRegex = /[^\x20-\x7F\u00a0\u2026]/; +// Below adapted from https://github.com/pieroxy/lz-string -// See sindresorhus/string-width -export function getWidth(s: string): number { - if (s == null || s.length === 0) return 0; +const keyStrBase64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; +const baseReverseDic: Record> = {}; +function getBaseValue(alphabet: string, character: string | number) { + if (!baseReverseDic[alphabet]) { + baseReverseDic[alphabet] = {}; + for (let i = 0; i < alphabet.length; i++) { + baseReverseDic[alphabet][alphabet.charAt(i)] = i; + } + } + return baseReverseDic[alphabet][character]; +} - // Shortcut to avoid needless string `RegExp`s, replacements, and allocations - if (!containsNonAsciiRegex.test(s)) return s.length; +export function decompressFromBase64LZString(input: string | undefined) { + if (input == null || input === '') return ''; + return ( + _decompressLZString(input.length, 32, (index: number) => getBaseValue(keyStrBase64, input.charAt(index))) ?? '' + ); +} - if (cachedAnsiRegex == null) { - cachedAnsiRegex = ansiRegex(); +function _decompressLZString(length: number, resetValue: any, getNextValue: (index: number) => number) { + const dictionary = []; + let next; + let enlargeIn = 4; + let dictSize = 4; + let numBits = 3; + let entry: any = ''; + const result = []; + let i; + let w: any; + let bits; + let resb; + let maxpower; + let power; + let c; + const data = { val: getNextValue(0), position: resetValue, index: 1 }; + + for (i = 0; i < 3; i += 1) { + dictionary[i] = i; } - s = s.replace(cachedAnsiRegex, ''); - if (s.length === 0) return 0; + bits = 0; + maxpower = Math.pow(2, 2); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } - let count = 0; - let emoji = 0; - let joiners = 0; - - const graphemes = [...s]; - for (let i = 0; i < graphemes.length; i++) { - const code = graphemes[i].codePointAt(0)!; - - // Ignore control characters - if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue; - - // Ignore combining characters - if (code >= 0x300 && code <= 0x36f) continue; - - if ( - (code >= 0x1f600 && code <= 0x1f64f) || // Emoticons - (code >= 0x1f300 && code <= 0x1f5ff) || // Misc Symbols and Pictographs - (code >= 0x1f680 && code <= 0x1f6ff) || // Transport and Map - (code >= 0x2600 && code <= 0x26ff) || // Misc symbols - (code >= 0x2700 && code <= 0x27bf) || // Dingbats - (code >= 0xfe00 && code <= 0xfe0f) || // Variation Selectors - (code >= 0x1f900 && code <= 0x1f9ff) || // Supplemental Symbols and Pictographs - (code >= 65024 && code <= 65039) || // Variation selector - (code >= 8400 && code <= 8447) // Combining Diacritical Marks for Symbols - ) { - if (code >= 0x1f3fb && code <= 0x1f3ff) continue; // emoji modifier fitzpatrick type - - emoji++; - count += 2; - continue; + const fromCharCode = String.fromCharCode; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + switch ((next = bits)) { + case 0: + bits = 0; + maxpower = Math.pow(2, 8); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + c = fromCharCode(bits); + break; + case 1: + bits = 0; + maxpower = Math.pow(2, 16); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + c = fromCharCode(bits); + break; + case 2: + return ''; + } + dictionary[3] = c; + w = c; + result.push(c); + while (true) { + if (data.index > length) { + return ''; } - // Ignore zero-width joiners '\u200d' - if (code === 8205) { - joiners++; - count -= 2; - continue; + bits = 0; + maxpower = Math.pow(2, numBits); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; } - // Surrogates - if (code > 0xffff) { - i++; + switch ((c = bits)) { + case 0: + bits = 0; + maxpower = Math.pow(2, 8); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + + dictionary[dictSize++] = fromCharCode(bits); + c = dictSize - 1; + enlargeIn--; + break; + case 1: + bits = 0; + maxpower = Math.pow(2, 16); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + dictionary[dictSize++] = fromCharCode(bits); + c = dictSize - 1; + enlargeIn--; + break; + case 2: + return result.join(''); } - count += isFullwidthCodePoint(code) ? 2 : 1; - } + if (enlargeIn == 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } - const offset = emoji - joiners; - if (offset > 1) { - count += offset - 1; - } - return count; -} - -// See sindresorhus/is-fullwidth-code-point -function isFullwidthCodePoint(cp: number) { - // code points are derived from: - // http://www.unix.org/Public/UNIDATA/EastAsianWidth.txt - if ( - cp >= 0x1100 && - (cp <= 0x115f || // Hangul Jamo - cp === 0x2329 || // LEFT-POINTING ANGLE BRACKET - cp === 0x232a || // RIGHT-POINTING ANGLE BRACKET - // CJK Radicals Supplement .. Enclosed CJK Letters and Months - (cp >= 0x2e80 && cp <= 0x3247 && cp !== 0x303f) || - // Enclosed CJK Letters and Months .. CJK Unified Ideographs Extension A - (cp >= 0x3250 && cp <= 0x4dbf) || - // CJK Unified Ideographs .. Yi Radicals - (cp >= 0x4e00 && cp <= 0xa4c6) || - // Hangul Jamo Extended-A - (cp >= 0xa960 && cp <= 0xa97c) || - // Hangul Syllables - (cp >= 0xac00 && cp <= 0xd7a3) || - // CJK Compatibility Ideographs - (cp >= 0xf900 && cp <= 0xfaff) || - // Vertical Forms - (cp >= 0xfe10 && cp <= 0xfe19) || - // CJK Compatibility Forms .. Small Form Variants - (cp >= 0xfe30 && cp <= 0xfe6b) || - // Halfwidth and Fullwidth Forms - (cp >= 0xff01 && cp <= 0xff60) || - (cp >= 0xffe0 && cp <= 0xffe6) || - // Kana Supplement - (cp >= 0x1b000 && cp <= 0x1b001) || - // Enclosed Ideographic Supplement - (cp >= 0x1f200 && cp <= 0x1f251) || - // CJK Unified Ideographs Extension B .. Tertiary Ideographic Plane - (cp >= 0x20000 && cp <= 0x3fffd)) - ) { - return true; - } + if (dictionary[c]) { + entry = dictionary[c]!; + } else if (c === dictSize) { + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + entry = w + w.charAt(0); + } else { + return undefined; + } + result.push(entry); + + // Add w+entry[0] to the dictionary. + + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + dictionary[dictSize++] = w + entry.charAt(0); + enlargeIn--; - return false; + w = entry; + + if (enlargeIn == 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } + } } diff --git a/src/system/trie.ts b/src/system/trie.ts index 802e55cddb243..73dac59a18903 100644 --- a/src/system/trie.ts +++ b/src/system/trie.ts @@ -1,9 +1,7 @@ -import type { Uri } from 'vscode'; import { isLinux } from '@env/platform'; +import type { Uri } from 'vscode'; import { filterMap } from './iterable'; import { normalizePath as _normalizePath } from './path'; -// TODO@eamodio don't import from string here since it will break the tests because of ESM dependencies -// import { CharCode } from './string'; const slash = 47; //CharCode.Slash; diff --git a/src/system/utils.ts b/src/system/utils.ts deleted file mode 100644 index 18f79d7574a99..0000000000000 --- a/src/system/utils.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { ColorTheme, TextDocument, TextDocumentShowOptions, TextEditor, Uri } from 'vscode'; -import { ColorThemeKind, env, ViewColumn, window, workspace } from 'vscode'; -import { configuration } from '../configuration'; -import { CoreCommands, ImageMimetypes, Schemes } from '../constants'; -import { isGitUri } from '../git/gitUri'; -import { Logger } from '../logger'; -import { executeCoreCommand } from './command'; -import { extname } from './path'; - -export function findTextDocument(uri: Uri): TextDocument | undefined { - const normalizedUri = uri.toString(); - return workspace.textDocuments.find(d => d.uri.toString() === normalizedUri); -} - -export function findEditor(uri: Uri): TextEditor | undefined { - const active = window.activeTextEditor; - const normalizedUri = uri.toString(); - - for (const e of [...(active != null ? [active] : []), ...window.visibleTextEditors]) { - // Don't include diff editors - if (e.document.uri.toString() === normalizedUri && e?.viewColumn != null) { - return e; - } - } - - return undefined; -} - -export async function findOrOpenEditor( - uri: Uri, - options?: TextDocumentShowOptions & { throwOnError?: boolean }, -): Promise { - const e = findEditor(uri); - if (e != null) { - if (!options?.preserveFocus) { - await window.showTextDocument(e.document, { ...options, viewColumn: e.viewColumn }); - } - - return e; - } - - return openEditor(uri, { viewColumn: window.activeTextEditor?.viewColumn, ...options }); -} - -export function findOrOpenEditors(uris: Uri[]): void { - const normalizedUris = new Map(uris.map(uri => [uri.toString(), uri])); - - for (const e of window.visibleTextEditors) { - // Don't include diff editors - if (e?.viewColumn != null) { - normalizedUris.delete(e.document.uri.toString()); - } - } - - for (const uri of normalizedUris.values()) { - void executeCoreCommand(CoreCommands.Open, uri, { background: true, preview: false }); - } -} - -export function getEditorIfActive(document: TextDocument): TextEditor | undefined { - const editor = window.activeTextEditor; - return editor != null && editor.document === document ? editor : undefined; -} - -export function getQuickPickIgnoreFocusOut() { - return !configuration.get('advanced.quickPick.closeOnFocusOut'); -} - -export function hasVisibleTextEditor(): boolean { - if (window.visibleTextEditors.length === 0) return false; - - return window.visibleTextEditors.some(e => isTextEditor(e)); -} - -export function isActiveDocument(document: TextDocument): boolean { - const editor = window.activeTextEditor; - return editor != null && editor.document === document; -} - -export function isDarkTheme(theme: ColorTheme): boolean { - return theme.kind === ColorThemeKind.Dark || theme.kind === ColorThemeKind.HighContrast; -} - -export function isLightTheme(theme: ColorTheme): boolean { - return theme.kind === ColorThemeKind.Light || theme.kind === ColorThemeKind.HighContrastLight; -} - -export function isVirtualUri(uri: Uri): boolean { - return uri.scheme === Schemes.Virtual || uri.scheme === Schemes.GitHub; -} - -export function isVisibleDocument(document: TextDocument): boolean { - if (window.visibleTextEditors.length === 0) return false; - - return window.visibleTextEditors.some(e => e.document === document); -} - -export function isTextEditor(editor: TextEditor): boolean { - const scheme = editor.document.uri.scheme; - return scheme !== Schemes.Output && scheme !== Schemes.DebugConsole; -} - -export async function openEditor( - uri: Uri, - options: TextDocumentShowOptions & { rethrow?: boolean } = {}, -): Promise { - const { rethrow, ...opts } = options; - try { - if (isGitUri(uri)) { - uri = uri.documentUri(); - } - - if (uri.scheme === Schemes.GitLens && ImageMimetypes[extname(uri.fsPath)]) { - await executeCoreCommand(CoreCommands.Open, uri); - - return undefined; - } - - const document = await workspace.openTextDocument(uri); - return window.showTextDocument(document, { - preserveFocus: false, - preview: true, - viewColumn: ViewColumn.Active, - ...opts, - }); - } catch (ex) { - const msg: string = ex?.toString() ?? ''; - if (msg.includes('File seems to be binary and cannot be opened as text')) { - await executeCoreCommand(CoreCommands.Open, uri); - - return undefined; - } - - if (rethrow) throw ex; - - Logger.error(ex, 'openEditor'); - return undefined; - } -} - -export async function openWalkthrough( - extensionId: string, - walkthroughId: string, - stepId?: string, - openToSide: boolean = true, -): Promise { - // Only open to side if there is an active tab - if (openToSide && window.tabGroups.activeTabGroup.activeTab == null) { - openToSide = false; - } - - // Takes the following params: walkthroughID: string | { category: string, step: string } | undefined, toSide: boolean | undefined - void (await executeCoreCommand( - CoreCommands.OpenWalkthrough, - { - category: `${extensionId}#${walkthroughId}`, - step: stepId ? `${extensionId}#${walkthroughId}#${stepId}` : undefined, - }, - openToSide, - )); -} - -export const enum OpenWorkspaceLocation { - CurrentWindow = 'currentWindow', - NewWindow = 'newWindow', - AddToWorkspace = 'addToWorkspace', -} - -export function openWorkspace( - uri: Uri, - options: { location?: OpenWorkspaceLocation; name?: string } = { location: OpenWorkspaceLocation.CurrentWindow }, -): void { - if (options?.location === OpenWorkspaceLocation.AddToWorkspace) { - const count = workspace.workspaceFolders?.length ?? 0; - return void workspace.updateWorkspaceFolders(count, 0, { uri: uri, name: options?.name }); - } - - return void executeCoreCommand(CoreCommands.OpenFolder, uri, { - forceNewWindow: options?.location === OpenWorkspaceLocation.NewWindow, - }); -} - -export function getEditorCommand() { - let editor; - switch (env.appName) { - case 'Visual Studio Code - Insiders': - editor = 'code-insiders --wait --reuse-window'; - break; - case 'Visual Studio Code - Exploration': - editor = 'code-exploration --wait --reuse-window'; - break; - case 'VSCodium': - editor = 'codium --wait --reuse-window'; - break; - default: - editor = 'code --wait --reuse-window'; - break; - } - return editor; -} diff --git a/src/system/version.ts b/src/system/version.ts index d8381f81b3f3b..3675a1c76fd83 100644 --- a/src/system/version.ts +++ b/src/system/version.ts @@ -49,7 +49,10 @@ export function fromString(version: string): Version { return from(major, minor, patch, pre); } -export function satisfies(v: string | Version | null | undefined, requirement: string): boolean { +export function satisfies( + v: string | Version | null | undefined, + requirement: `${'=' | '>' | '>=' | '<' | '<='} ${string}`, +): boolean { if (v == null) return false; const [op, version] = requirement.split(' '); diff --git a/src/system/cancellation.ts b/src/system/vscode/cancellation.ts similarity index 100% rename from src/system/cancellation.ts rename to src/system/vscode/cancellation.ts diff --git a/src/system/command.ts b/src/system/vscode/command.ts similarity index 50% rename from src/system/command.ts rename to src/system/vscode/command.ts index fc5ab53acba62..76ec671293f22 100644 --- a/src/system/command.ts +++ b/src/system/vscode/command.ts @@ -1,14 +1,15 @@ import type { Command as CoreCommand, Disposable, Uri } from 'vscode'; import { commands } from 'vscode'; -import type { Action, ActionContext } from '../api/gitlens'; -import type { Command } from '../commands/base'; -import type { CoreGitCommands } from '../constants'; -import { Commands, CoreCommands } from '../constants'; -import { Container } from '../container'; +import type { Action, ActionContext } from '../../api/gitlens'; +import type { Command } from '../../commands/base'; +import type { CoreCommands, CoreGitCommands, TreeViewCommands } from '../../constants.commands'; +import { Commands } from '../../constants.commands'; +import { Container } from '../../container'; +import { isWebviewContext } from '../webview'; -interface CommandConstructor { - new (container: Container): Command; -} +export type CommandCallback = Parameters[1]; + +type CommandConstructor = new (container: Container, ...args: any[]) => Command; const registrableCommands: CommandConstructor[] = []; export function command(): ClassDecorator { @@ -17,11 +18,35 @@ export function command(): ClassDecorator { }; } -export function registerCommand(command: string, callback: (...args: any[]) => any, thisArg?: any): Disposable { +export function registerCommand(command: string, callback: CommandCallback, thisArg?: any): Disposable { + return commands.registerCommand( + command, + function (this: any, ...args) { + let context: any; + if (command === Commands.GitCommands) { + const arg = args?.[0]; + if (arg?.command != null) { + context = { mode: args[0].command }; + if (arg?.state?.subcommand != null) { + context.submode = arg.state.subcommand; + } + } + } + Container.instance.telemetry.sendEvent('command', { command: command, context: context }); + callback.call(this, ...args); + }, + thisArg, + ); +} + +export function registerWebviewCommand(command: string, callback: CommandCallback, thisArg?: any): Disposable { return commands.registerCommand( command, function (this: any, ...args) { - Container.instance.telemetry.sendEvent('command', { command: command }); + Container.instance.telemetry.sendEvent('command', { + command: command, + webview: isWebviewContext(args[0]) ? args[0].webview : '', + }); callback.call(this, ...args); }, thisArg, @@ -42,21 +67,33 @@ export function executeActionCommand(action: Action, return commands.executeCommand(`${Commands.ActionPrefix}${action}`, { ...args, type: action }); } -type SupportedCommands = Commands | `gitlens.views.${string}.focus` | `gitlens.views.${string}.resetViewLocation`; - -export function executeCommand(command: SupportedCommands): Thenable; -export function executeCommand(command: SupportedCommands, arg: T): Thenable; -export function executeCommand( - command: SupportedCommands, - ...args: T -): Thenable; -export function executeCommand( - command: SupportedCommands, +export function createCommand( + command: Commands | TreeViewCommands, + title: string, ...args: T -): Thenable { +): CoreCommand { + return { + command: command, + title: title, + arguments: args, + }; +} + +export function executeCommand(command: Commands): Thenable; +export function executeCommand(command: Commands, arg: T): Thenable; +export function executeCommand(command: Commands, ...args: T): Thenable; +export function executeCommand(command: Commands, ...args: T): Thenable { return commands.executeCommand(command, ...args); } +export function createCoreCommand(command: CoreCommands, title: string, ...args: T): CoreCommand { + return { + command: command, + title: title, + arguments: args, + }; +} + export function executeCoreCommand(command: CoreCommands, arg: T): Thenable; export function executeCoreCommand( command: CoreCommands, @@ -66,12 +103,30 @@ export function executeCoreCommand( command: CoreCommands, ...args: T ): Thenable { - if (command !== CoreCommands.ExecuteDocumentSymbolProvider) { + if ( + command != 'setContext' && + command !== 'vscode.executeDocumentSymbolProvider' && + command !== 'vscode.changes' && + command !== 'vscode.diff' && + command !== 'vscode.open' + ) { Container.instance.telemetry.sendEvent('command/core', { command: command }); } return commands.executeCommand(command, ...args); } +export function createCoreGitCommand( + command: CoreGitCommands, + title: string, + ...args: T +): CoreCommand { + return { + command: command, + title: title, + arguments: args, + }; +} + export function executeCoreGitCommand(command: CoreGitCommands): Thenable; export function executeCoreGitCommand(command: CoreGitCommands, arg: T): Thenable; export function executeCoreGitCommand( diff --git a/src/configuration.ts b/src/system/vscode/configuration.ts similarity index 61% rename from src/configuration.ts rename to src/system/vscode/configuration.ts index 6eec110bfc192..a7199a71df1a9 100644 --- a/src/configuration.ts +++ b/src/system/vscode/configuration.ts @@ -1,21 +1,19 @@ -export * from './config'; - import type { ConfigurationChangeEvent, ConfigurationScope, Event, ExtensionContext } from 'vscode'; import { ConfigurationTarget, EventEmitter, workspace } from 'vscode'; -import type { Config } from './config'; -import { areEqual } from './system/object'; - -const configPrefix = 'gitlens'; +import type { Config, CoreConfig } from '../../config'; +import { extensionPrefix } from '../../constants'; +import { areEqual } from '../object'; interface ConfigurationOverrides { get(section: T, value: ConfigPathValue): ConfigPathValue; getAll(config: Config): Config; - onChange(e: ConfigurationChangeEvent): ConfigurationChangeEvent; + onDidChange(e: ConfigurationChangeEvent): ConfigurationChangeEvent; } export class Configuration { static configure(context: ExtensionContext): void { context.subscriptions.push( + // eslint-disable-next-line @typescript-eslint/no-use-before-define workspace.onDidChangeConfiguration(configuration.onConfigurationChanged, configuration), ); } @@ -30,25 +28,14 @@ export class Configuration { return this._onDidChangeAny.event; } - private _onWillChange = new EventEmitter(); - get onWillChange(): Event { - return this._onWillChange.event; - } - private onConfigurationChanged(e: ConfigurationChangeEvent) { - if (!e.affectsConfiguration(configPrefix)) { - this._onDidChangeAny.fire(e); - - return; - } - - this._onWillChange.fire(e); + this._onDidChangeAny.fire(e); + if (!e.affectsConfiguration(extensionPrefix)) return; - if (this._overrides?.onChange != null) { - e = this._overrides.onChange(e); + if (this._overrides?.onDidChange != null) { + e = this._overrides.onDidChange(e); } - this._onDidChangeAny.fire(e); this._onDidChange.fire(e); } @@ -67,61 +54,113 @@ export class Configuration { queueMicrotask(() => (this._overrides = undefined)); } - get(section: T, scope?: ConfigurationScope | null): ConfigPathValue; - get( - section: T, + get(section: S, scope?: ConfigurationScope | null): ConfigPathValue; + get( + section: S, scope: ConfigurationScope | null | undefined, - defaultValue: NonNullable>, - ): NonNullable>; - get( - section: T, + defaultValue: NonNullable>, + skipOverrides?: boolean, + ): NonNullable>; + get( + section: S, scope?: ConfigurationScope | null, - defaultValue?: NonNullable>, - ): ConfigPathValue { + defaultValue?: NonNullable>, + skipOverrides?: boolean, + ): ConfigPathValue { const value = defaultValue === undefined - ? workspace.getConfiguration(configPrefix, scope).get>(section)! - : workspace.getConfiguration(configPrefix, scope).get>(section, defaultValue)!; - return this._overrides?.get == null ? value : this._overrides.get(section, value); + ? workspace.getConfiguration(extensionPrefix, scope).get>(section)! + : workspace.getConfiguration(extensionPrefix, scope).get>(section, defaultValue)!; + return skipOverrides || this._overrides?.get == null ? value : this._overrides.get(section, value); } getAll(skipOverrides?: boolean): Config { - const config = workspace.getConfiguration().get(configPrefix)!; + const config = workspace.getConfiguration().get(extensionPrefix)!; return skipOverrides || this._overrides?.getAll == null ? config : this._overrides.getAll(config); } - getAny(section: string, scope?: ConfigurationScope | null): T | undefined; - getAny(section: string, scope: ConfigurationScope | null | undefined, defaultValue: T): T; - getAny(section: string, scope?: ConfigurationScope | null, defaultValue?: T): T | undefined { + getAny(section: S, scope?: ConfigurationScope | null): T | undefined; + getAny(section: S, scope: ConfigurationScope | null | undefined, defaultValue: T): T; + getAny(section: S, scope?: ConfigurationScope | null, defaultValue?: T): T | undefined { return defaultValue === undefined ? workspace.getConfiguration(undefined, scope).get(section) : workspace.getConfiguration(undefined, scope).get(section, defaultValue); } - changed( + getCore( + section: S, + scope?: ConfigurationScope | null, + ): CoreConfigPathValue | undefined; + getCore( + section: S, + scope: ConfigurationScope | null | undefined, + defaultValue: CoreConfigPathValue, + ): CoreConfigPathValue; + getCore( + section: S, + scope?: ConfigurationScope | null, + defaultValue?: CoreConfigPathValue, + ): CoreConfigPathValue | undefined { + return defaultValue === undefined + ? workspace.getConfiguration(undefined, scope).get>(section) + : workspace.getConfiguration(undefined, scope).get>(section, defaultValue); + } + + changed( + e: ConfigurationChangeEvent | undefined, + section: S | S[], + scope?: ConfigurationScope | null | undefined, + ): boolean { + if (e == null) return true; + + return Array.isArray(section) + ? section.some(s => e.affectsConfiguration(`${extensionPrefix}.${s}`, scope!)) + : e.affectsConfiguration(`${extensionPrefix}.${section}`, scope!); + } + + changedAny( + e: ConfigurationChangeEvent | undefined, + section: S | S[], + scope?: ConfigurationScope | null | undefined, + ): boolean { + if (e == null) return true; + + return Array.isArray(section) + ? section.some(s => e.affectsConfiguration(s, scope!)) + : e.affectsConfiguration(section, scope!); + } + + changedCore( e: ConfigurationChangeEvent | undefined, - section: T | T[], + section: S | S[], scope?: ConfigurationScope | null | undefined, ): boolean { if (e == null) return true; return Array.isArray(section) - ? section.some(s => e.affectsConfiguration(`${configPrefix}.${s}`, scope!)) - : e.affectsConfiguration(`${configPrefix}.${section}`, scope!); + ? section.some(s => e.affectsConfiguration(s, scope!)) + : e.affectsConfiguration(section, scope!); } - inspect>(section: T, scope?: ConfigurationScope | null) { + inspect>(section: S, scope?: ConfigurationScope | null) { return workspace - .getConfiguration(configPrefix, scope) - .inspect(section === undefined ? configPrefix : section); + .getConfiguration(extensionPrefix, scope) + .inspect(section === undefined ? extensionPrefix : section); } - inspectAny(section: string, scope?: ConfigurationScope | null) { + inspectAny(section: S, scope?: ConfigurationScope | null) { return workspace.getConfiguration(undefined, scope).inspect(section); } - isUnset(section: T, scope?: ConfigurationScope | null): boolean { - const inspect = configuration.inspect(section, scope)!; + inspectCore>( + section: S, + scope?: ConfigurationScope | null, + ) { + return workspace.getConfiguration(undefined, scope).inspect(section); + } + + isUnset(section: S, scope?: ConfigurationScope | null): boolean { + const inspect = this.inspect(section, scope)!; if (inspect.workspaceFolderValue !== undefined) return false; if (inspect.workspaceValue !== undefined) return false; if (inspect.globalValue !== undefined) return false; @@ -129,12 +168,12 @@ export class Configuration { return true; } - async migrate( + async migrate( from: string, - to: T, - options: { fallbackValue?: ConfigPathValue; migrationFn?(value: any): ConfigPathValue }, + to: S, + options: { fallbackValue?: ConfigPathValue; migrationFn?(value: any): ConfigPathValue }, ): Promise { - const inspection = configuration.inspect(from as any); + const inspection = this.inspect(from as any); if (inspection === undefined) return false; let migrated = false; @@ -198,17 +237,17 @@ export class Configuration { return migrated; } - async migrateIfMissing( + async migrateIfMissing( from: string, - to: T, - options: { migrationFn?(value: any): ConfigPathValue }, + to: S, + options: { migrationFn?(value: any): ConfigPathValue }, ): Promise { - const fromInspection = configuration.inspect(from as any); + const fromInspection = this.inspect(from as any); if (fromInspection === undefined) return; - const toInspection = configuration.inspect(to); + const toInspection = this.inspect(to); if (fromInspection.globalValue !== undefined) { - if (toInspection === undefined || toInspection.globalValue === undefined) { + if (toInspection?.globalValue === undefined) { await this.update( to, options.migrationFn != null @@ -227,7 +266,7 @@ export class Configuration { } if (fromInspection.workspaceValue !== undefined) { - if (toInspection === undefined || toInspection.workspaceValue === undefined) { + if (toInspection?.workspaceValue === undefined) { await this.update( to, options.migrationFn != null @@ -246,7 +285,7 @@ export class Configuration { } if (fromInspection.workspaceFolderValue !== undefined) { - if (toInspection === undefined || toInspection.workspaceFolderValue === undefined) { + if (toInspection?.workspaceFolderValue === undefined) { await this.update( to, options.migrationFn != null @@ -265,25 +304,25 @@ export class Configuration { } } - matches(match: T, section: ConfigPath, value: unknown): value is ConfigPathValue { + matches(match: S, section: ConfigPath, value: unknown): value is ConfigPathValue { return match === section; } - name(section: T): string { + name(section: S): string { return section; } - update( - section: T, - value: ConfigPathValue | undefined, + update( + section: S, + value: ConfigPathValue | undefined, target: ConfigurationTarget, ): Thenable { - return workspace.getConfiguration(configPrefix).update(section, value, target); + return workspace.getConfiguration(extensionPrefix).update(section, value, target); } - updateAny( - section: string, - value: any, + updateAny( + section: S, + value: T, target: ConfigurationTarget, scope?: ConfigurationScope | null, ): Thenable { @@ -292,25 +331,25 @@ export class Configuration { .update(section, value, target); } - updateEffective(section: T, value: ConfigPathValue | undefined): Thenable { - const inspect = configuration.inspect(section)!; + updateEffective(section: S, value: ConfigPathValue | undefined): Thenable { + const inspect = this.inspect(section)!; if (inspect.workspaceFolderValue !== undefined) { if (value === inspect.workspaceFolderValue) return Promise.resolve(undefined); - return configuration.update(section, value, ConfigurationTarget.WorkspaceFolder); + return this.update(section, value, ConfigurationTarget.WorkspaceFolder); } if (inspect.workspaceValue !== undefined) { if (value === inspect.workspaceValue) return Promise.resolve(undefined); - return configuration.update(section, value, ConfigurationTarget.Workspace); + return this.update(section, value, ConfigurationTarget.Workspace); } if (inspect.globalValue === value || (inspect.globalValue === undefined && value === inspect.defaultValue)) { return Promise.resolve(undefined); } - return configuration.update( + return this.update( section, areEqual(value, inspect.defaultValue) ? undefined : value, ConfigurationTarget.Global, @@ -337,8 +376,11 @@ export type PathValue> = P extends `${infer Key}.${infer Re : never : never : P extends keyof T - ? T[P] - : never; + ? T[P] + : never; export type ConfigPath = Path; export type ConfigPathValue

= PathValue; + +export type CoreConfigPath = Path; +export type CoreConfigPathValue

= PathValue; diff --git a/src/system/vscode/context.ts b/src/system/vscode/context.ts new file mode 100644 index 0000000000000..0ddced1162d2a --- /dev/null +++ b/src/system/vscode/context.ts @@ -0,0 +1,32 @@ +import { EventEmitter } from 'vscode'; +import type { ContextKeys } from '../../constants.context'; +import { executeCoreCommand } from './command'; + +const contextStorage = new Map(); + +const _onDidChangeContext = new EventEmitter(); +export const onDidChangeContext = _onDidChangeContext.event; + +export function getContext(key: T): ContextKeys[T] | undefined; +export function getContext(key: T, defaultValue: ContextKeys[T]): ContextKeys[T]; +export function getContext( + key: T, + defaultValue?: ContextKeys[T], +): ContextKeys[T] | undefined { + return (contextStorage.get(key) as ContextKeys[T] | undefined) ?? defaultValue; +} + +export async function setContext( + key: T, + value: ContextKeys[T] | undefined, +): Promise { + if (contextStorage.get(key) === value) return; + + if (value == null) { + contextStorage.delete(key); + } else { + contextStorage.set(key, value); + } + void (await executeCoreCommand('setContext', key, value ?? undefined)); + _onDidChangeContext.fire(key); +} diff --git a/src/system/formatPath.ts b/src/system/vscode/formatPath.ts similarity index 88% rename from src/system/formatPath.ts rename to src/system/vscode/formatPath.ts index b77c4e45655b2..d2c53fcce4548 100644 --- a/src/system/formatPath.ts +++ b/src/system/vscode/formatPath.ts @@ -1,6 +1,7 @@ import type { Uri } from 'vscode'; -import { basename, getBestPath, relativeDir } from './path'; -import { truncateLeft, truncateMiddle } from './string'; +import { basename } from '../path'; +import { truncateLeft, truncateMiddle } from '../string'; +import { getBestPath, relativeDir } from './path'; export function formatPath( pathOrUri: string | Uri, diff --git a/src/keyboard.ts b/src/system/vscode/keyboard.ts similarity index 76% rename from src/keyboard.ts rename to src/system/vscode/keyboard.ts index 33ea52de55d1e..981881a30db76 100644 --- a/src/keyboard.ts +++ b/src/system/vscode/keyboard.ts @@ -1,10 +1,11 @@ import { Disposable } from 'vscode'; -import { ContextKeys } from './constants'; +import type { Keys } from '../../constants'; +import { extensionPrefix, keys } from '../../constants'; +import { log } from '../decorators/log'; +import { Logger } from '../logger'; +import { getLogScope, setLogScopeExit } from '../logger.scope'; +import { registerCommand } from './command'; import { setContext } from './context'; -import { Logger } from './logger'; -import { getLogScope } from './logScope'; -import { registerCommand } from './system/command'; -import { log } from './system/decorators/log'; export declare interface KeyCommand { onDidPressKey?(key: Keys): void | Promise; @@ -13,23 +14,8 @@ export declare interface KeyCommand { const keyNoopCommand = Object.create(null) as KeyCommand; export { keyNoopCommand as KeyNoopCommand }; -export const keys = [ - 'left', - 'alt+left', - 'ctrl+left', - 'right', - 'alt+right', - 'ctrl+right', - 'alt+,', - 'alt+.', - 'escape', -] as const; -export type Keys = (typeof keys)[number]; - export type KeyMapping = { [K in Keys]?: KeyCommand | (() => Promise) }; -type IndexableKeyMapping = KeyMapping & { - [index: string]: KeyCommand | (() => Promise) | undefined; -}; +type IndexableKeyMapping = KeyMapping & Record Promise) | undefined>; const mappings: KeyMapping[] = []; @@ -52,9 +38,7 @@ export class KeyboardScope implements Disposable { const index = mappings.indexOf(this._mapping); const scope = getLogScope(); - if (scope != null) { - scope.exitDetails = ` \u2022 index=${index}`; - } + setLogScopeExit(scope, ` \u2022 index=${index}`); if (index === mappings.length - 1) { mappings.pop(); @@ -78,15 +62,13 @@ export class KeyboardScope implements Disposable { const mapping = mappings[mappings.length - 1]; if (mapping !== this._mapping || mapping[key] == null) { - if (scope != null) { - scope.exitDetails = ' \u2022 skipped'; - } + setLogScopeExit(scope, ' \u2022 skipped'); return; } mapping[key] = undefined; - await setContext(`${ContextKeys.KeyPrefix}${key}`, false); + await setContext(`${extensionPrefix}:key:${key}`, false); } @log({ @@ -129,9 +111,7 @@ export class KeyboardScope implements Disposable { const mapping = mappings[mappings.length - 1]; if (mapping !== this._mapping) { - if (scope != null) { - scope.exitDetails = ' \u2022 skipped'; - } + setLogScopeExit(scope, ' \u2022 skipped'); return; } @@ -140,12 +120,12 @@ export class KeyboardScope implements Disposable { mapping[key] = command; if (!set) { - await setContext(`${ContextKeys.KeyPrefix}${key}`, true); + await setContext(`${extensionPrefix}:key:${key}`, true); } } private async updateKeyCommandsContext(mapping: KeyMapping) { - await Promise.all(keys.map(key => setContext(`${ContextKeys.KeyPrefix}${key}`, Boolean(mapping?.[key])))); + await Promise.all(keys.map(key => setContext(`${extensionPrefix}:key:${key}`, Boolean(mapping?.[key])))); } } @@ -153,7 +133,9 @@ export class Keyboard implements Disposable { private readonly _disposable: Disposable; constructor() { - const subscriptions = keys.map(key => registerCommand(`gitlens.key.${key}`, () => this.execute(key), this)); + const subscriptions = keys.map(key => + registerCommand(`${extensionPrefix}.key.${key}`, () => this.execute(key), this), + ); this._disposable = Disposable.from(...subscriptions); } @@ -186,9 +168,7 @@ export class Keyboard implements Disposable { const scope = getLogScope(); if (!mappings.length) { - if (scope != null) { - scope.exitDetails = ' \u2022 skipped, no mappings'; - } + setLogScopeExit(scope, ' \u2022 skipped, no mappings'); return; } @@ -201,9 +181,7 @@ export class Keyboard implements Disposable { command = await command(); } if (typeof command?.onDidPressKey !== 'function') { - if (scope != null) { - scope.exitDetails = ' \u2022 skipped, no callback'; - } + setLogScopeExit(scope, ' \u2022 skipped, no callback'); return; } diff --git a/src/system/vscode/path.ts b/src/system/vscode/path.ts new file mode 100644 index 0000000000000..5cd1eeb6d258f --- /dev/null +++ b/src/system/vscode/path.ts @@ -0,0 +1,209 @@ +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { isAbsolute as _isAbsolute, basename, dirname } from 'path'; +import { Uri } from 'vscode'; +import { Schemes } from '../../constants'; +import { commonBaseIndex, maybeUri, normalizePath } from '../path'; +// TODO@eamodio don't import from string here since it will break the tests because of ESM dependencies +// import { CharCode } from './string'; + +const slash = 47; //slash; + +const hasSchemeRegex = /^([a-zA-Z][\w+.-]+):/; +const vslsHasPrefixRegex = /^[/|\\]~(?:\d+?|external)(?:[/|\\]|$)/; +const vslsRootUriRegex = /^[/|\\]~(?:\d+?|external)(?:[/|\\]|$)/; + +export function addVslsPrefixIfNeeded(path: string): string; +export function addVslsPrefixIfNeeded(uri: Uri): Uri; +export function addVslsPrefixIfNeeded(pathOrUri: string | Uri): string | Uri; +export function addVslsPrefixIfNeeded(pathOrUri: string | Uri): string | Uri { + if (typeof pathOrUri === 'string') { + if (maybeUri(pathOrUri)) { + pathOrUri = Uri.parse(pathOrUri); + } + } + + if (typeof pathOrUri === 'string') { + if (hasVslsPrefix(pathOrUri)) return pathOrUri; + + pathOrUri = normalizePath(pathOrUri); + return `/~0${pathOrUri.charCodeAt(0) === slash ? pathOrUri : `/${pathOrUri}`}`; + } + + let path = pathOrUri.fsPath; + if (hasVslsPrefix(path)) return pathOrUri; + + path = normalizePath(path); + return pathOrUri.with({ path: `/~0${path.charCodeAt(0) === slash ? path : `/${path}`}` }); +} + +export function hasVslsPrefix(path: string): boolean { + return vslsHasPrefixRegex.test(path); +} + +export function isVslsRoot(path: string): boolean { + return vslsRootUriRegex.test(path); +} + +// export function commonBase(s1: string, s2: string, delimiter: string, ignoreCase?: boolean): string | undefined { +// const index = commonBaseIndex(s1, s2, delimiter, ignoreCase); +// return index > 0 ? s1.substring(0, index + 1) : undefined; +// } + +// export function commonBaseIndex(s1: string, s2: string, delimiter: string, ignoreCase?: boolean): number { +// if (s1.length === 0 || s2.length === 0) return 0; + +// if (ignoreCase ?? !isLinux) { +// s1 = s1.toLowerCase(); +// s2 = s2.toLowerCase(); +// } + +// let char; +// let index = 0; +// for (let i = 0; i < s1.length; i++) { +// char = s1[i]; +// if (char !== s2[i]) break; + +// if (char === delimiter) { +// index = i; +// } +// } + +// return index; +// } + +export function getBestPath(uri: Uri): string; +export function getBestPath(pathOrUri: string | Uri): string; +export function getBestPath(pathOrUri: string | Uri): string { + if (typeof pathOrUri === 'string') { + if (!hasSchemeRegex.test(pathOrUri)) return normalizePath(pathOrUri); + + pathOrUri = Uri.parse(pathOrUri, true); + } + + return normalizePath(pathOrUri.scheme === Schemes.File ? pathOrUri.fsPath : pathOrUri.path); +} + +// export function getScheme(path: string): string | undefined { +// return hasSchemeRegex.exec(path)?.[1]; +// } + +export function isChild(path: string, base: string | Uri): boolean; +export function isChild(uri: Uri, base: string | Uri): boolean; +export function isChild(pathOrUri: string | Uri, base: string | Uri): boolean { + if (typeof base === 'string') { + if (base.charCodeAt(0) !== slash) { + base = `/${base}`; + } + + return ( + isDescendant(pathOrUri, base) && + (typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.path) + .substring(base.length + (base.charCodeAt(base.length - 1) === slash ? 0 : 1)) + .split('/').length === 1 + ); + } + + return ( + isDescendant(pathOrUri, base) && + (typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.path) + .substring(base.path.length + (base.path.charCodeAt(base.path.length - 1) === slash ? 0 : 1)) + .split('/').length === 1 + ); +} + +export function isDescendant(path: string, base: string | Uri): boolean; +export function isDescendant(uri: Uri, base: string | Uri): boolean; +export function isDescendant(pathOrUri: string | Uri, base: string | Uri): boolean; +export function isDescendant(pathOrUri: string | Uri, base: string | Uri): boolean { + if (typeof base === 'string') { + base = normalizePath(base); + if (base.charCodeAt(0) !== slash) { + base = `/${base}`; + } + } + + if (typeof pathOrUri === 'string') { + pathOrUri = normalizePath(pathOrUri); + if (pathOrUri.charCodeAt(0) !== slash) { + pathOrUri = `/${pathOrUri}`; + } + } + + if (typeof base === 'string') { + return ( + base.length === 1 || + (typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.path).startsWith( + base.charCodeAt(base.length - 1) === slash ? base : `${base}/`, + ) + ); + } + + if (typeof pathOrUri === 'string') { + return ( + base.path.length === 1 || + pathOrUri.startsWith(base.path.charCodeAt(base.path.length - 1) === slash ? base.path : `${base.path}/`) + ); + } + + return ( + base.scheme === pathOrUri.scheme && + base.authority === pathOrUri.authority && + (base.path.length === 1 || + pathOrUri.path.startsWith( + base.path.charCodeAt(base.path.length - 1) === slash ? base.path : `${base.path}/`, + )) + ); +} + +export function relative(from: string, to: string, ignoreCase?: boolean): string { + from = hasSchemeRegex.test(from) ? Uri.parse(from, true).path : normalizePath(from); + to = hasSchemeRegex.test(to) ? Uri.parse(to, true).path : normalizePath(to); + + const index = commonBaseIndex(`${to}/`, `${from}/`, '/', ignoreCase); + return index > 0 ? to.substring(index + 1) : to; +} + +export function relativeDir(relativePath: string, relativeTo?: string): string { + const dirPath = dirname(relativePath); + if (!dirPath || dirPath === '.' || dirPath === relativeTo) return ''; + if (!relativeTo) return dirPath; + + const [relativeDirPath] = splitPath(dirPath, relativeTo); + return relativeDirPath; +} + +export function splitPath( + pathOrUri: string | Uri, + root: string | undefined, + splitOnBaseIfMissing: boolean = false, + ignoreCase?: boolean, +): [path: string, root: string] { + pathOrUri = getBestPath(pathOrUri); + + if (root) { + let repoUri; + if (hasSchemeRegex.test(root)) { + repoUri = Uri.parse(root, true); + root = getBestPath(repoUri); + } else { + root = normalizePath(root); + } + + const index = commonBaseIndex(`${root}/`, `${pathOrUri}/`, '/', ignoreCase); + if (index > 0) { + root = pathOrUri.substring(0, index); + pathOrUri = pathOrUri.substring(index + 1); + } else if (pathOrUri.charCodeAt(0) === slash) { + pathOrUri = pathOrUri.slice(1); + } + + if (repoUri != null) { + root = repoUri.with({ path: root }).toString(); + } + } else { + root = normalizePath(splitOnBaseIfMissing ? dirname(pathOrUri) : ''); + pathOrUri = splitOnBaseIfMissing ? basename(pathOrUri) : pathOrUri; + } + + return [pathOrUri, root]; +} diff --git a/src/system/vscode/serialize.ts b/src/system/vscode/serialize.ts new file mode 100644 index 0000000000000..ba9558de03912 --- /dev/null +++ b/src/system/vscode/serialize.ts @@ -0,0 +1,46 @@ +import { Uri } from 'vscode'; +import type { Branded } from '../brand'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export type Serialized = T extends Function + ? never + : T extends Date + ? number + : T extends Uri + ? string + : T extends Branded + ? U + : T extends any[] + ? Serialized[] + : T extends object + ? { + [K in keyof T]: T[K] extends Date ? number : Serialized; + } + : T; + +export function serialize(obj: T): Serialized; +export function serialize(obj: T | undefined): Serialized | undefined; +export function serialize(obj: T | undefined): Serialized | undefined { + if (obj == null) return undefined; + + try { + function replacer(this: any, key: string, value: unknown) { + if (value instanceof Date) return value.getTime(); + if (value instanceof Map || value instanceof Set) return [...value.entries()]; + if (value instanceof Function || value instanceof Error) return undefined; + if (value instanceof RegExp) return value.toString(); + if (value instanceof Uri) return value.toString(); + + const original = this[key]; + return original instanceof Date + ? original.getTime() + : original instanceof Uri + ? original.toString() + : value; + } + return JSON.parse(JSON.stringify(obj, replacer)) as Serialized; + } catch (ex) { + debugger; + throw ex; + } +} diff --git a/src/system/vscode/storage.ts b/src/system/vscode/storage.ts new file mode 100644 index 0000000000000..18cd78512860c --- /dev/null +++ b/src/system/vscode/storage.ts @@ -0,0 +1,169 @@ +import type { Disposable, Event, ExtensionContext, SecretStorageChangeEvent } from 'vscode'; +import { EventEmitter } from 'vscode'; +import { extensionPrefix } from '../../constants'; +import type { + DeprecatedGlobalStorage, + DeprecatedWorkspaceStorage, + GlobalStorage, + SecretKeys, + WorkspaceStorage, +} from '../../constants.storage'; +import { debug } from '../decorators/log'; + +type GlobalStorageKeys = keyof (GlobalStorage & DeprecatedGlobalStorage); +type WorkspaceStorageKeys = keyof (WorkspaceStorage & DeprecatedWorkspaceStorage); + +export type StorageChangeEvent = + | { + /** + * The key of the stored value that has changed. + */ + readonly key: GlobalStorageKeys; + readonly workspace: false; + } + | { + /** + * The key of the stored value that has changed. + */ + readonly key: WorkspaceStorageKeys; + readonly workspace: true; + }; + +export class Storage implements Disposable { + private _onDidChange = new EventEmitter(); + get onDidChange(): Event { + return this._onDidChange.event; + } + + private _onDidChangeSecrets = new EventEmitter(); + get onDidChangeSecrets(): Event { + return this._onDidChangeSecrets.event; + } + + private readonly _disposable: Disposable; + constructor(private readonly context: ExtensionContext) { + this._disposable = this.context.secrets.onDidChange(e => this._onDidChangeSecrets.fire(e)); + } + + dispose(): void { + this._disposable.dispose(); + } + + get(key: T): GlobalStorage[T] | undefined; + /** @deprecated */ + get(key: T): DeprecatedGlobalStorage[T] | undefined; + get(key: T, defaultValue: GlobalStorage[T]): GlobalStorage[T]; + @debug({ logThreshold: 50 }) + get(key: GlobalStorageKeys, defaultValue?: unknown): unknown | undefined { + return this.context.globalState.get(`${extensionPrefix}:${key}`, defaultValue); + } + + @debug({ logThreshold: 250 }) + async delete(key: GlobalStorageKeys): Promise { + await this.context.globalState.update(`${extensionPrefix}:${key}`, undefined); + this._onDidChange.fire({ key: key, workspace: false }); + } + + @debug({ logThreshold: 250 }) + async deleteWithPrefix(prefix: ExtractPrefixes): Promise { + return this.deleteWithPrefixCore(prefix); + } + + async deleteWithPrefixCore( + prefix?: ExtractPrefixes, + exclude?: GlobalStorageKeys[], + ): Promise { + const qualifiedKeyPrefix = `${extensionPrefix}:`; + + for (const qualifiedKey of this.context.globalState.keys() as `${typeof extensionPrefix}:${GlobalStorageKeys}`[]) { + if (!qualifiedKey.startsWith(qualifiedKeyPrefix)) continue; + + const key = qualifiedKey.substring(qualifiedKeyPrefix.length) as GlobalStorageKeys; + if (prefix == null || key === prefix || key.startsWith(`${prefix}:`)) { + if (exclude?.includes(key)) continue; + + await this.context.globalState.update(key, undefined); + this._onDidChange.fire({ key: key, workspace: false }); + } + } + } + + @debug({ logThreshold: 250 }) + async reset(): Promise { + return this.deleteWithPrefixCore(undefined, ['premium:subscription']); + } + + @debug({ args: { 1: false }, logThreshold: 250 }) + async store(key: T, value: GlobalStorage[T] | undefined): Promise { + await this.context.globalState.update(`${extensionPrefix}:${key}`, value); + this._onDidChange.fire({ key: key, workspace: false }); + } + + @debug({ args: false, logThreshold: 250 }) + async getSecret(key: SecretKeys): Promise { + return this.context.secrets.get(key); + } + + @debug({ args: false, logThreshold: 250 }) + async deleteSecret(key: SecretKeys): Promise { + return this.context.secrets.delete(key); + } + + @debug({ args: false, logThreshold: 250 }) + async storeSecret(key: SecretKeys, value: string): Promise { + return this.context.secrets.store(key, value); + } + + getWorkspace(key: T): WorkspaceStorage[T] | undefined; + /** @deprecated */ + getWorkspace(key: T): DeprecatedWorkspaceStorage[T] | undefined; + getWorkspace(key: T, defaultValue: WorkspaceStorage[T]): WorkspaceStorage[T]; + @debug({ logThreshold: 25 }) + getWorkspace(key: WorkspaceStorageKeys, defaultValue?: unknown): unknown | undefined { + return this.context.workspaceState.get(`${extensionPrefix}:${key}`, defaultValue); + } + + @debug({ logThreshold: 250 }) + async deleteWorkspace(key: WorkspaceStorageKeys): Promise { + await this.context.workspaceState.update(`${extensionPrefix}:${key}`, undefined); + this._onDidChange.fire({ key: key, workspace: true }); + } + + @debug({ logThreshold: 250 }) + async deleteWorkspaceWithPrefix(prefix: ExtractPrefixes): Promise { + return this.deleteWorkspaceWithPrefixCore(prefix); + } + + async deleteWorkspaceWithPrefixCore( + prefix?: ExtractPrefixes, + exclude?: WorkspaceStorageKeys[], + ): Promise { + const qualifiedKeyPrefix = `${extensionPrefix}:`; + + for (const qualifiedKey of this.context.workspaceState.keys() as `${typeof extensionPrefix}:${WorkspaceStorageKeys}`[]) { + if (!qualifiedKey.startsWith(qualifiedKeyPrefix)) continue; + + const key = qualifiedKey.substring(qualifiedKeyPrefix.length) as WorkspaceStorageKeys; + if (prefix == null || key === prefix || key.startsWith(`${prefix}:`)) { + if (exclude?.includes(key)) continue; + + await this.context.workspaceState.update(key, undefined); + this._onDidChange.fire({ key: key, workspace: true }); + } + } + } + + @debug({ logThreshold: 250 }) + async resetWorkspace(): Promise { + return this.deleteWorkspaceWithPrefixCore(); + } + + @debug({ args: { 1: false }, logThreshold: 250 }) + async storeWorkspace( + key: T, + value: WorkspaceStorage[T] | undefined, + ): Promise { + await this.context.workspaceState.update(`${extensionPrefix}:${key}`, value); + this._onDidChange.fire({ key: key, workspace: true }); + } +} diff --git a/src/system/vscode/utils.ts b/src/system/vscode/utils.ts new file mode 100644 index 0000000000000..4a6741ae66d16 --- /dev/null +++ b/src/system/vscode/utils.ts @@ -0,0 +1,377 @@ +import type { ColorTheme, Tab, TextDocument, TextDocumentShowOptions, TextEditor, WorkspaceFolder } from 'vscode'; +import { version as codeVersion, ColorThemeKind, env, Uri, ViewColumn, window, workspace } from 'vscode'; +import { imageMimetypes, Schemes, trackableSchemes } from '../../constants'; +import { isGitUri } from '../../git/gitUri'; +import { Logger } from '../logger'; +import { extname, normalizePath } from '../path'; +import { satisfies } from '../version'; +import { executeCoreCommand } from './command'; +import { configuration } from './configuration'; +import { relative } from './path'; + +export function findTextDocument(uri: Uri): TextDocument | undefined { + const normalizedUri = uri.toString(); + return workspace.textDocuments.find(d => d.uri.toString() === normalizedUri); +} + +export function findEditor(uri: Uri): TextEditor | undefined { + const active = window.activeTextEditor; + const normalizedUri = uri.toString(); + + for (const e of [...(active != null ? [active] : []), ...window.visibleTextEditors]) { + // Don't include diff editors + if (e.document.uri.toString() === normalizedUri && e?.viewColumn != null) { + return e; + } + } + + return undefined; +} + +export async function findOrOpenEditor( + uri: Uri, + options?: TextDocumentShowOptions & { background?: boolean; throwOnError?: boolean }, +): Promise { + const e = findEditor(uri); + if (e != null) { + if (!options?.preserveFocus) { + await window.showTextDocument(e.document, { ...options, viewColumn: e.viewColumn }); + } + + return e; + } + + return openEditor(uri, { viewColumn: window.activeTextEditor?.viewColumn, ...options }); +} + +export function findOrOpenEditors(uris: Uri[], options?: TextDocumentShowOptions & { background?: boolean }): void { + const normalizedUris = new Map(uris.map(uri => [uri.toString(), uri])); + + for (const e of window.visibleTextEditors) { + // Don't include diff editors + if (e?.viewColumn != null) { + normalizedUris.delete(e.document.uri.toString()); + } + } + + options = { background: true, preview: false, ...options }; + for (const uri of normalizedUris.values()) { + void executeCoreCommand('vscode.open', uri, options); + } +} + +export function getEditorCommand() { + let editor; + switch (env.appName) { + case 'Visual Studio Code - Insiders': + editor = 'code-insiders --wait --reuse-window'; + break; + case 'Visual Studio Code - Exploration': + editor = 'code-exploration --wait --reuse-window'; + break; + case 'VSCodium': + editor = 'codium --wait --reuse-window'; + break; + case 'Cursor': + editor = 'cursor --wait --reuse-window'; + break; + default: + editor = 'code --wait --reuse-window'; + break; + } + return editor; +} + +export function getEditorIfActive(document: TextDocument): TextEditor | undefined { + const editor = window.activeTextEditor; + return editor != null && editor.document === document ? editor : undefined; +} + +export function getEditorIfVisible(uri: Uri): TextEditor | undefined; +export function getEditorIfVisible(document: TextDocument): TextEditor | undefined; +export function getEditorIfVisible(documentOrUri: TextDocument | Uri): TextEditor | undefined { + if (documentOrUri instanceof Uri) { + const uriString = documentOrUri.toString(); + return window.visibleTextEditors.find(e => e.document.uri.toString() === uriString); + } + + return window.visibleTextEditors.find(e => e.document === documentOrUri); +} + +export function getQuickPickIgnoreFocusOut() { + return !configuration.get('advanced.quickPick.closeOnFocusOut'); +} + +export function getTabUri(tab: Tab | undefined): Uri | undefined { + const input = tab?.input; + if (input == null || typeof input !== 'object') return undefined; + + if ('uri' in input && input.uri instanceof Uri) { + return input.uri; + } + + if ('modified' in input && input.modified instanceof Uri) { + return input.modified; + } + + return undefined; +} + +export function getWorkspaceFriendlyPath(uri: Uri): string { + const folder = workspace.getWorkspaceFolder(uri); + if (folder == null) return normalizePath(uri.fsPath); + + const relativePath = normalizePath(relative(folder.uri.fsPath, uri.fsPath)); + return relativePath || folder.name; +} + +export function hasVisibleTrackableTextEditor(uri?: Uri): boolean { + const editors = window.visibleTextEditors; + if (!editors.length) return false; + + if (uri == null) return editors.some(e => isTrackableTextEditor(e)); + + const uriString = uri.toString(); + return editors.some(e => e.document.uri.toString() === uriString && isTrackableTextEditor(e)); +} + +export function isActiveDocument(document: TextDocument): boolean { + return window.activeTextEditor?.document === document; +} + +export function isDarkTheme(theme: ColorTheme): boolean { + return theme.kind === ColorThemeKind.Dark || theme.kind === ColorThemeKind.HighContrast; +} + +export function isLightTheme(theme: ColorTheme): boolean { + return theme.kind === ColorThemeKind.Light || theme.kind === ColorThemeKind.HighContrastLight; +} + +export function isTextDocument(document: unknown): document is TextDocument { + if (document == null || typeof document !== 'object') return false; + + if ( + 'uri' in document && + document.uri instanceof Uri && + 'fileName' in document && + 'languageId' in document && + 'isDirty' in document && + 'isUntitled' in document + ) { + return true; + } + + return false; +} + +export function isTextEditor(editor: unknown): editor is TextEditor { + if (editor == null || typeof editor !== 'object') return false; + + if ('document' in editor && isTextDocument(editor.document) && 'viewColumn' in editor && 'selections' in editor) { + return true; + } + + return false; +} + +export function isTrackableTextEditor(editor: TextEditor): boolean { + return isTrackableUri(editor.document.uri); +} + +export function isTrackableUri(uri: Uri): boolean { + return trackableSchemes.has(uri.scheme as Schemes); +} + +export function isVirtualUri(uri: Uri): boolean { + return uri.scheme === Schemes.Virtual || uri.scheme === Schemes.GitHub; +} + +export function isVisibleDocument(document: TextDocument): boolean { + return window.visibleTextEditors.some(e => e.document === document); +} + +export function isWorkspaceFolder(folder: unknown): folder is WorkspaceFolder { + if (folder == null || typeof folder !== 'object') return false; + + if ('uri' in folder && folder.uri instanceof Uri && 'name' in folder && 'index' in folder) { + return true; + } + + return false; +} + +export async function openEditor( + uri: Uri, + options?: TextDocumentShowOptions & { background?: boolean; throwOnError?: boolean }, +): Promise { + let background; + let throwOnError; + if (options != null) { + ({ background, throwOnError, ...options } = options); + } + + try { + if (isGitUri(uri)) { + uri = uri.documentUri(); + } + + if (background || (uri.scheme === Schemes.GitLens && imageMimetypes[extname(uri.fsPath)])) { + await executeCoreCommand('vscode.open', uri, { background: background, ...options }); + + return undefined; + } + + const document = await workspace.openTextDocument(uri); + return await window.showTextDocument(document, { + preserveFocus: false, + preview: true, + viewColumn: ViewColumn.Active, + ...options, + }); + } catch (ex) { + const msg: string = ex?.toString() ?? ''; + if (msg.includes('File seems to be binary and cannot be opened as text')) { + await executeCoreCommand('vscode.open', uri); + + return undefined; + } + + if (throwOnError) throw ex; + + Logger.error(ex, 'openEditor'); + return undefined; + } +} + +export async function openChangesEditor( + resources: { uri: Uri; lhs: Uri | undefined; rhs: Uri | undefined }[], + title: string, + _options?: TextDocumentShowOptions, +): Promise { + try { + await executeCoreCommand( + 'vscode.changes', + title, + resources.map(r => [r.uri, r.lhs, r.rhs]), + ); + } catch (ex) { + Logger.error(ex, 'openChangesEditor'); + } +} + +export async function openDiffEditor( + lhs: Uri, + rhs: Uri, + title: string, + options?: TextDocumentShowOptions, +): Promise { + try { + await executeCoreCommand('vscode.diff', lhs, rhs, title, options); + } catch (ex) { + Logger.error(ex, 'openDiffEditor'); + } +} + +export async function openUrl(url: string): Promise; +export async function openUrl(url?: string): Promise; +export async function openUrl(url?: string): Promise { + if (url == null) return undefined; + + // Pass a string to openExternal to avoid double encoding issues: https://github.com/microsoft/vscode/issues/85930 + // vscode.d.ts currently says it only supports a Uri, but it actually accepts a string too + return (env.openExternal as unknown as (target: string) => Thenable)(url); +} + +export async function openWalkthrough( + extensionId: string, + walkthroughId: string, + stepId?: string, + openToSide: boolean = true, +): Promise { + // Only open to side if there is an active tab + if (openToSide && window.tabGroups.activeTabGroup.activeTab == null) { + openToSide = false; + } + + // Takes the following params: walkthroughID: string | { category: string, step: string } | undefined, toSide: boolean | undefined + void (await executeCoreCommand( + 'workbench.action.openWalkthrough', + { + category: `${extensionId}#${walkthroughId}`, + step: stepId, + }, + openToSide, + )); +} + +export type OpenWorkspaceLocation = 'currentWindow' | 'newWindow' | 'addToWorkspace'; + +export function openWorkspace( + uri: Uri, + options: { location?: OpenWorkspaceLocation; name?: string } = { location: 'currentWindow' }, +): void { + if (options?.location === 'addToWorkspace') { + const count = workspace.workspaceFolders?.length ?? 0; + return void workspace.updateWorkspaceFolders(count, 0, { uri: uri, name: options?.name }); + } + + return void executeCoreCommand('vscode.openFolder', uri, { + forceNewWindow: options?.location === 'newWindow', + }); +} + +export async function revealInFileExplorer(uri: Uri) { + void (await executeCoreCommand('revealFileInOS', uri)); +} + +export function supportedInVSCodeVersion(feature: 'language-models') { + switch (feature) { + case 'language-models': + return satisfies(codeVersion, '>= 1.90-insider'); + default: + return false; + } +} + +export function tabContainsUri(tab: Tab | undefined, uri: Uri | undefined): boolean { + const input = tab?.input; + if (uri == null || input == null || typeof input !== 'object') return false; + + const uriString = uri.toString(); + if ('uri' in input && input.uri instanceof Uri) { + return input.uri.toString() === uriString; + } + + if ('modified' in input && input.modified instanceof Uri) { + return input.modified.toString() === uriString; + } + + if ('original' in input && input.original instanceof Uri) { + return input.original.toString() === uriString; + } + + return false; +} + +const resourceContextKeyValueCache = new Map>(); + +export function getResourceContextKeyValue(uri: Uri) { + // If we are on a remote connection, VS Code's `TextDocument.uri` uses a `file://` scheme, + // but VS Code sets the `resource` context key to a "remote" url in the form of `vscode-remote://+/` + // So we need to try to generate that `vscode-remote://` version, which seems to work by getting the querystring from `env.asExternalUri` + if (uri.scheme === 'file' && env.remoteName) { + const uriKey = uri.toString(); + let promise = resourceContextKeyValueCache.get(uriKey); + if (promise == null) { + promise = env.asExternalUri(uri).then(u => u.query); + resourceContextKeyValueCache.set(uriKey, promise); + promise.then( + () => resourceContextKeyValueCache.delete(uriKey), + () => resourceContextKeyValueCache.delete(uriKey), + ); + } + return promise; + } + + return uri.toString(); +} diff --git a/src/system/vscode/vscode.ts b/src/system/vscode/vscode.ts new file mode 100644 index 0000000000000..cc7a73d4c5b8a --- /dev/null +++ b/src/system/vscode/vscode.ts @@ -0,0 +1,11 @@ +import type { ThemeIcon } from 'vscode'; +import { Uri } from 'vscode'; +import type { IconPath } from '../../@types/vscode.iconpath'; +import type { Container } from '../../container'; + +export function getIconPathUris(container: Container, filename: string): Exclude { + return { + dark: Uri.file(container.context.asAbsolutePath(`images/dark/${filename}`)), + light: Uri.file(container.context.asAbsolutePath(`images/light/${filename}`)), + }; +} diff --git a/src/system/webview.ts b/src/system/webview.ts index f67dd309903b1..6d63c0705ae30 100644 --- a/src/system/webview.ts +++ b/src/system/webview.ts @@ -1,29 +1,51 @@ -export interface WebviewItemContext { - webview?: string; +import type { WebviewIds, WebviewViewIds } from '../constants.views'; + +export function createWebviewCommandLink( + command: `${WebviewIds | WebviewViewIds}.${string}`, + webviewId: WebviewIds | WebviewViewIds, + webviewInstanceId: string | undefined, +): string { + return `command:${command}?${encodeURIComponent( + JSON.stringify({ webview: webviewId, webviewInstance: webviewInstanceId } satisfies WebviewContext), + )}`; +} + +export interface WebviewContext { + webview: WebviewIds | WebviewViewIds; + webviewInstance: string | undefined; +} + +export function isWebviewContext(item: object | null | undefined): item is WebviewContext { + if (item == null) return false; + + return 'webview' in item && item.webview != null; +} + +export interface WebviewItemContext extends Partial { webviewItem: string; webviewItemValue: TValue; + webviewItemsValues?: { webviewItem: string; webviewItemValue: TValue }[]; } export function isWebviewItemContext( item: object | null | undefined, -): item is WebviewItemContext { +): item is WebviewItemContext & WebviewContext { if (item == null) return false; - return 'webview' in item && 'webviewItem' in item; + return 'webview' in item && item.webview != null && 'webviewItem' in item; } -export interface WebviewItemGroupContext { - webview?: string; +export interface WebviewItemGroupContext extends Partial { webviewItemGroup: string; webviewItemGroupValue: TValue; } export function isWebviewItemGroupContext( item: object | null | undefined, -): item is WebviewItemGroupContext { +): item is WebviewItemGroupContext & WebviewContext { if (item == null) return false; - return 'webview' in item && 'webviewItemGroup' in item; + return 'webview' in item && item.webview != null && 'webviewItemGroup' in item; } export function serializeWebviewItemContext(context: T): string { diff --git a/src/telemetry/openTelemetryProvider.ts b/src/telemetry/openTelemetryProvider.ts index 37d58a9ae7d42..241863550d1ff 100644 --- a/src/telemetry/openTelemetryProvider.ts +++ b/src/telemetry/openTelemetryProvider.ts @@ -10,7 +10,13 @@ import { // ConsoleSpanExporter, SimpleSpanProcessor, } from '@opentelemetry/sdk-trace-base'; -import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; +import { + ATTR_DEPLOYMENT_ENVIRONMENT, + ATTR_DEPLOYMENT_ENVIRONMENT_NAME, + ATTR_DEVICE_ID, + ATTR_OS_TYPE, +} from '@opentelemetry/semantic-conventions/incubating'; import type { HttpsProxyAgent } from 'https-proxy-agent'; import type { TelemetryContext, TelemetryProvider } from './telemetry'; @@ -23,11 +29,12 @@ export class OpenTelemetryProvider implements TelemetryProvider { constructor(context: TelemetryContext, agent?: HttpsProxyAgent, debugging?: boolean) { this.provider = new BasicTracerProvider({ resource: new Resource({ - [SemanticResourceAttributes.SERVICE_NAME]: 'gitlens', - [SemanticResourceAttributes.SERVICE_VERSION]: context.extensionVersion, - [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: context.env, - [SemanticResourceAttributes.DEVICE_ID]: context.machineId, - [SemanticResourceAttributes.OS_TYPE]: context.platform, + [ATTR_SERVICE_NAME]: 'gitlens', + [ATTR_SERVICE_VERSION]: context.extensionVersion, + [ATTR_DEPLOYMENT_ENVIRONMENT]: context.env, + [ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: context.env, + [ATTR_DEVICE_ID]: context.machineId, + [ATTR_OS_TYPE]: context.platform, 'extension.id': context.extensionId, 'session.id': context.sessionId, language: context.language, @@ -46,9 +53,7 @@ export class OpenTelemetryProvider implements TelemetryProvider { // } const exporter = new OTLPTraceExporter({ - url: debugging - ? 'https://otel-dev.gitkraken.com:4318/v1/traces' - : 'https://otel.gitkraken.com:4318/v1/traces', + url: debugging ? 'https://otel-dev.gitkraken.com/v1/traces' : 'https://otel.gitkraken.com/v1/traces', compression: 'gzip' as any, httpAgentOptions: agent?.options, }); diff --git a/src/telemetry/telemetry.ts b/src/telemetry/telemetry.ts index 07cd575475d76..e65cc7af3eb43 100644 --- a/src/telemetry/telemetry.ts +++ b/src/telemetry/telemetry.ts @@ -1,10 +1,11 @@ +import { getProxyAgent } from '@env/fetch'; +import { getPlatform } from '@env/platform'; import type { AttributeValue, Span, TimeInput } from '@opentelemetry/api'; import type { Disposable } from 'vscode'; import { version as codeVersion, env } from 'vscode'; -import { getProxyAgent } from '@env/fetch'; -import { getPlatform } from '@env/platform'; -import { configuration } from '../configuration'; +import type { Source, TelemetryEvents, TelemetryGlobalContext } from '../constants.telemetry'; import type { Container } from '../container'; +import { configuration } from '../system/vscode/configuration'; export interface TelemetryContext { env: string; @@ -22,16 +23,18 @@ export interface TelemetryContext { vscodeVersion: string; } +export type TelemetryEventData = Record; + export interface TelemetryProvider extends Disposable { sendEvent(name: string, data?: Record, startTime?: TimeInput, endTime?: TimeInput): void; startEvent(name: string, data?: Record, startTime?: TimeInput): Span; setGlobalAttributes(attributes: Map): void; } -interface QueuedEvent { +interface QueuedEvent { type: 'sendEvent'; - name: string; - data?: Record; + name: T; + data?: TelemetryEvents[T]; global: Map; startTime: TimeInput; endTime: TimeInput; @@ -50,7 +53,7 @@ export class TelemetryService implements Disposable { constructor(private readonly container: Container) { container.context.subscriptions.push( configuration.onDidChange(e => { - if (!e.affectsConfiguration('telemetry.enabled')) return; + if (!configuration.changed(e, 'telemetry.enabled')) return; this.ensureTelemetry(container); }), @@ -120,6 +123,7 @@ export class TelemetryService implements Disposable { for (const { type, name, data, global } of queue) { if (type === 'sendEvent') { this.provider.setGlobalAttributes(global); + assertsTelemetryEventData(data); this.provider.sendEvent(name, stripNullOrUndefinedAttributes(data)); } } @@ -128,20 +132,24 @@ export class TelemetryService implements Disposable { this.provider.setGlobalAttributes(this.globalAttributes); } - sendEvent( - name: string, - data?: Record, + sendEvent( + name: T, + data?: TelemetryEvents[T], + source?: Source, startTime?: TimeInput, endTime?: TimeInput, ): void { if (!this._enabled) return; + assertsTelemetryEventData(data); + addSourceAttributes(source, data); + if (this.provider == null) { this.eventQueue.push({ type: 'sendEvent', name: name, data: data, - global: new Map([...this.globalAttributes]), + global: new Map(this.globalAttributes), startTime: startTime ?? Date.now(), endTime: endTime ?? Date.now(), }); @@ -151,13 +159,17 @@ export class TelemetryService implements Disposable { this.provider.sendEvent(name, stripNullOrUndefinedAttributes(data), startTime, endTime); } - startEvent( - name: string, - data?: Record, + startEvent( + name: T, + data?: TelemetryEvents[T], + source?: Source, startTime?: TimeInput, ): Disposable | undefined { if (!this._enabled) return undefined; + assertsTelemetryEventData(data); + addSourceAttributes(source, data); + if (this.provider != null) { const span = this.provider.startEvent(name, stripNullOrUndefinedAttributes(data), startTime); return { @@ -167,7 +179,7 @@ export class TelemetryService implements Disposable { startTime = startTime ?? Date.now(); return { - dispose: () => this.sendEvent(name, data, startTime, Date.now()), + dispose: () => this.sendEvent(name, data, source, startTime, Date.now()), }; } @@ -183,32 +195,54 @@ export class TelemetryService implements Disposable { // ): void { // } - setGlobalAttribute(key: string, value: AttributeValue | null | undefined): void { + setGlobalAttribute( + key: T, + value: TelemetryGlobalContext[T] | null | undefined, + ): void { if (value == null) { - this.globalAttributes.delete(key); + this.globalAttributes.delete(`global.${key}`); } else { - this.globalAttributes.set(key, value); + this.globalAttributes.set(`global.${key}`, value); } this.provider?.setGlobalAttributes(this.globalAttributes); } - setGlobalAttributes(attributes: Record): void { + setGlobalAttributes(attributes: Partial): void { for (const [key, value] of Object.entries(attributes)) { if (value == null) { - this.globalAttributes.delete(key); + this.globalAttributes.delete(`global.${key}`); } else { - this.globalAttributes.set(key, value); + this.globalAttributes.set(`global.${key}`, value); } } this.provider?.setGlobalAttributes(this.globalAttributes); } - deleteGlobalAttribute(key: string): void { - this.globalAttributes.delete(key); + deleteGlobalAttribute(key: keyof TelemetryGlobalContext): void { + this.globalAttributes.delete(`global.${key}`); this.provider?.setGlobalAttributes(this.globalAttributes); } } +function addSourceAttributes( + source: Source | undefined, + data: Record | undefined, +) { + if (source == null) return; + + data ??= {}; + data['source.name'] = source.source; + if (source.detail != null) { + if (typeof source.detail === 'string') { + data['source.detail'] = source.detail; + } else if (typeof source.detail === 'object') { + for (const [key, value] of Object.entries(source.detail)) { + data[`source.detail.${key}`] = value; + } + } + } +} + function stripNullOrUndefinedAttributes(data: Record | undefined) { if (data == null) return undefined; @@ -220,3 +254,9 @@ function stripNullOrUndefinedAttributes(data: Record { - if (e.name === extensionTerminalName) { + if (e === _terminal) { _terminal = undefined; _disposable?.dispose(); _disposable = undefined; @@ -25,12 +23,3 @@ function ensureTerminal(): Terminal { return _terminal; } - -export function runGitCommandInTerminal(command: string, args: string, cwd: string, execute: boolean = false) { - const terminal = ensureTerminal(); - terminal.show(false); - const coreEditorConfig = configuration.get('terminal.overrideGitEditor') - ? `-c "core.editor=${getEditorCommand()}" ` - : ''; - terminal.sendText(`git -C "${cwd}" ${coreEditorConfig}${command} ${args}`, execute); -} diff --git a/src/terminal/linkProvider.ts b/src/terminal/linkProvider.ts index f1d1960aab5ef..ff6b6c2de8178 100644 --- a/src/terminal/linkProvider.ts +++ b/src/terminal/linkProvider.ts @@ -1,24 +1,23 @@ import type { Disposable, TerminalLink, TerminalLinkContext, TerminalLinkProvider } from 'vscode'; import { commands, window } from 'vscode'; -import type { - GitCommandsCommandArgs, - ShowCommitsInViewCommandArgs, - ShowQuickBranchHistoryCommandArgs, - ShowQuickCommitCommandArgs, -} from '../commands'; -import { configuration } from '../configuration'; -import { Commands } from '../constants'; +import type { GitCommandsCommandArgs } from '../commands/gitCommands'; +import type { InspectCommandArgs } from '../commands/inspect'; +import type { ShowQuickBranchHistoryCommandArgs } from '../commands/showQuickBranchHistory'; +import type { ShowQuickCommitCommandArgs } from '../commands/showQuickCommit'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import type { PagedResult } from '../git/gitProvider'; import type { GitBranch } from '../git/models/branch'; import { getBranchNameWithoutRemote } from '../git/models/branch'; -import { GitReference } from '../git/models/reference'; +import { createReference } from '../git/models/reference'; import type { GitTag } from '../git/models/tag'; +import { configuration } from '../system/vscode/configuration'; const commandsRegexShared = /\b(g(?:it)?\b\s*)\b(branch|checkout|cherry-pick|fetch|grep|log|merge|pull|push|rebase|reset|revert|show|stash|status|tag)\b/gi; // Since negative lookbehind isn't supported in all browsers, leave out the negative lookbehind condition `(? = { + const link: GitTerminalLink = { startIndex: match.index, length: ref.length, tooltip: 'Show Commit', @@ -177,8 +176,7 @@ export class GitTerminalLinkProvider implements Disposable, TerminalLinkProvider ? { command: Commands.ShowInDetailsView, args: { - repoPath: repoPath, - refs: [ref], + ref: createReference(ref, repoPath, { refType: 'revision' }), }, } : { diff --git a/src/test/.eslintrc.json b/src/test/.eslintrc.json deleted file mode 100644 index 7c27e3de2bf47..0000000000000 --- a/src/test/.eslintrc.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": ["../../.eslintrc.base.json"], - "env": { - "node": true - }, - "parserOptions": { - "project": "tsconfig.test.json" - }, - "rules": { - "no-restricted-imports": "off", - "@typescript-eslint/no-unused-vars": "off" - } -} diff --git a/src/test/runTest.ts b/src/test/runTest.ts deleted file mode 100644 index b89e6d3e19d25..0000000000000 --- a/src/test/runTest.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as path from 'path'; -import * as process from 'process'; -import { runTests } from '@vscode/test-electron'; - -async function main() { - try { - // The folder containing the Extension Manifest package.json - // Passed to `--extensionDevelopmentPath` - const extensionDevelopmentPath = path.resolve(__dirname, '../../'); - - // The path to test runner - // Passed to --extensionTestsPath - const extensionTestsPath = path.resolve(__dirname, './suite/index'); - - // Download VS Code, unzip it and run the integration test - await runTests({ extensionDevelopmentPath: extensionDevelopmentPath, extensionTestsPath: extensionTestsPath }); - } catch (err) { - console.error('Failed to run tests'); - process.exit(1); - } -} - -void main(); diff --git a/src/test/suite/index.ts b/src/test/suite/index.ts index c5a5fad086b99..052590573e760 100644 --- a/src/test/suite/index.ts +++ b/src/test/suite/index.ts @@ -1,38 +1,40 @@ -import * as path from 'path'; +import path from 'path'; import { glob } from 'glob'; import Mocha from 'mocha'; export function run(): Promise { // Create the mocha test const mocha = new Mocha({ - ui: 'bdd', + ui: 'tdd', color: true, }); const testsRoot = path.resolve(__dirname, '..'); return new Promise((c, e) => { - glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { - if (err != null) { - return e(err); - } - - // Add files to the test suite - files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); - - try { - // Run the mocha test - mocha.run(failures => { - if (failures > 0) { - e(new Error(`${failures} tests failed.`)); - } else { - c(); - } + glob('**/**.test.js', { cwd: testsRoot }) + .then(files => { + // Add files to the test suite + files.forEach(f => { + mocha.addFile(path.resolve(testsRoot, f)); }); - } catch (err) { + + try { + // Run the mocha test + mocha.run(failures => { + if (failures > 0) { + e(new Error(`${failures} tests failed.`)); + } else { + c(); + } + }); + } catch (err) { + console.error(err); + } + }) + .catch((err: unknown) => { console.error(err); - e(err); - } - }); + return err; + }); }); } diff --git a/src/trackers/documentTracker.ts b/src/trackers/documentTracker.ts index f17e431cf601f..c6104d37d695b 100644 --- a/src/trackers/documentTracker.ts +++ b/src/trackers/documentTracker.ts @@ -10,10 +10,7 @@ import type { TextLine, } from 'vscode'; import { Disposable, EndOfLine, env, EventEmitter, Uri, window, workspace } from 'vscode'; -import { configuration } from '../configuration'; -import { ContextKeys } from '../constants'; import type { Container } from '../container'; -import { setContext } from '../context'; import type { RepositoriesChangeEvent } from '../git/gitProviderService'; import type { GitUri } from '../git/gitUri'; import { isGitUri } from '../git/gitUri'; @@ -23,62 +20,70 @@ import { debug } from '../system/decorators/log'; import { once } from '../system/event'; import type { Deferrable } from '../system/function'; import { debounce } from '../system/function'; -import { filter, join, map } from '../system/iterable'; -import { findTextDocument, isActiveDocument, isTextEditor } from '../system/utils'; -import type { DocumentBlameStateChangeEvent } from './trackedDocument'; -import { TrackedDocument } from './trackedDocument'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; +import { findTextDocument, getResourceContextKeyValue, isVisibleDocument } from '../system/vscode/utils'; +import type { TrackedGitDocument } from './trackedDocument'; +import { createTrackedGitDocument } from './trackedDocument'; -export * from './trackedDocument'; - -export interface DocumentContentChangeEvent { +export interface DocumentContentChangeEvent { readonly editor: TextEditor; - readonly document: TrackedDocument; - readonly contentChanges: ReadonlyArray; + readonly document: TrackedGitDocument; + readonly contentChanges: readonly TextDocumentContentChangeEvent[]; +} + +export interface DocumentBlameStateChangeEvent { + readonly editor: TextEditor | undefined; + readonly document: TrackedGitDocument; + readonly blameable: boolean; } -export interface DocumentDirtyStateChangeEvent { +export interface DocumentDirtyStateChangeEvent { readonly editor: TextEditor; - readonly document: TrackedDocument; + readonly document: TrackedGitDocument; readonly dirty: boolean; } -export interface DocumentDirtyIdleTriggerEvent { +export interface DocumentDirtyIdleTriggerEvent { readonly editor: TextEditor; - readonly document: TrackedDocument; + readonly document: TrackedGitDocument; } -export class DocumentTracker implements Disposable { - private _onDidChangeBlameState = new EventEmitter>(); - get onDidChangeBlameState(): Event> { +export class GitDocumentTracker implements Disposable { + private _onDidChangeBlameState = new EventEmitter(); + get onDidChangeBlameState(): Event { return this._onDidChangeBlameState.event; } - private _onDidChangeContent = new EventEmitter>(); - get onDidChangeContent(): Event> { + private _onDidChangeContent = new EventEmitter(); + get onDidChangeContent(): Event { return this._onDidChangeContent.event; } - private _onDidChangeDirtyState = new EventEmitter>(); - get onDidChangeDirtyState(): Event> { + private _onDidChangeDirtyState = new EventEmitter(); + get onDidChangeDirtyState(): Event { return this._onDidChangeDirtyState.event; } - private _onDidTriggerDirtyIdle = new EventEmitter>(); - get onDidTriggerDirtyIdle(): Event> { + private _onDidTriggerDirtyIdle = new EventEmitter(); + get onDidTriggerDirtyIdle(): Event { return this._onDidTriggerDirtyIdle.event; } private _dirtyIdleTriggerDelay: number; + private _dirtyIdleTriggeredDebounced: Deferrable<(e: DocumentDirtyIdleTriggerEvent) => void> | undefined; + private _dirtyStateChangedDebounced: Deferrable<(e: DocumentDirtyStateChangeEvent) => void> | undefined; private readonly _disposable: Disposable; - protected readonly _documentMap = new Map>>(); + private readonly _documentMap = new Map>(); - constructor(protected readonly container: Container) { + constructor(private readonly container: Container) { this._disposable = Disposable.from( once(container.onReady)(this.onReady, this), configuration.onDidChange(this.onConfigurationChanged, this), window.onDidChangeActiveTextEditor(this.onActiveTextEditorChanged, this), - // window.onDidChangeVisibleTextEditors(debounce(this.onVisibleEditorsChanged, 5000), this), - workspace.onDidChangeTextDocument(debounce(this.onTextDocumentChanged, 50), this), + window.onDidChangeVisibleTextEditors(this.onVisibleTextEditorsChanged, this), + workspace.onDidOpenTextDocument(this.onTextDocumentOpened, this), + workspace.onDidChangeTextDocument(this.onTextDocumentChanged, this), workspace.onDidCloseTextDocument(this.onTextDocumentClosed, this), workspace.onDidSaveTextDocument(this.onTextDocumentSaved, this), this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), @@ -96,40 +101,38 @@ export class DocumentTracker implements Disposable { private onReady(): void { this.onConfigurationChanged(); - this.onActiveTextEditorChanged(window.activeTextEditor); - } - - private _timer: ReturnType | undefined; - private onActiveTextEditorChanged(editor: TextEditor | undefined) { - if (editor != null && !isTextEditor(editor)) return; - if (this._timer != null) { - clearTimeout(this._timer); - this._timer = undefined; - } + const activeDocument = window.activeTextEditor?.document; - if (editor == null) { - this._timer = setTimeout(() => { - this._timer = undefined; + const docs = workspace.textDocuments + .filter(d => this.container.git.supportedSchemes.has(d.uri.scheme)) + .map<[TextDocument, visible: boolean, active: boolean]>(d => [ + d, + isVisibleDocument(d), + activeDocument === d, + ]); - void setContext(ContextKeys.ActiveFileStatus, undefined); - }, 250); + // Sort by active and then by visible + docs.sort(([, aVisible, aActive], [, bVisible, bActive]) => { + if (aActive === bActive) { + return aVisible === bVisible ? 0 : aVisible ? -1 : 1; + } + return aActive ? -1 : 1; + }); - return; + for (const [doc, visible, active] of docs) { + this.onTextDocumentOpened(doc, visible || active); } + } - const doc = this._documentMap.get(editor.document); - if (doc != null) { - void doc.then( - d => d.activate(), - () => {}, - ); - - return; - } + private onActiveTextEditorChanged(_editor: TextEditor | undefined) { + this._dirtyIdleTriggeredDebounced?.flush(); + this._dirtyIdleTriggeredDebounced?.cancel(); + this._dirtyIdleTriggeredDebounced = undefined; - // No need to activate this, as it is implicit in initialization if currently active - void this.addCore(editor.document); + this._dirtyStateChangedDebounced?.flush(); + this._dirtyStateChangedDebounced?.cancel(); + this._dirtyStateChangedDebounced = undefined; } private onConfigurationChanged(e?: ConfigurationChangeEvent) { @@ -138,21 +141,24 @@ export class DocumentTracker implements Disposable { e != null && (configuration.changed(e, 'blame.ignoreWhitespace') || configuration.changed(e, 'advanced.caching.enabled')) ) { - this.reset('config'); + void this.refreshDocuments(); } if (configuration.changed(e, 'advanced.blame.delayAfterEdit')) { this._dirtyIdleTriggerDelay = configuration.get('advanced.blame.delayAfterEdit'); + this._dirtyIdleTriggeredDebounced?.flush(); + this._dirtyIdleTriggeredDebounced?.cancel(); this._dirtyIdleTriggeredDebounced = undefined; } } private onRepositoriesChanged(e: RepositoriesChangeEvent) { - this.reset( - 'repository', - e.added.length ? new Set(e.added.map(r => r.path)) : undefined, - e.removed.length ? new Set(e.removed.map(r => r.path)) : undefined, - ); + void this.refreshDocuments({ + addedOrChangedRepoPaths: e.added.length + ? new Set(e.added.map(r => r.path.toLowerCase())) + : undefined, + removedRepoPaths: e.removed.length ? new Set(e.removed.map(r => r.path.toLowerCase())) : undefined, + }); } private onRepositoryChanged(e: RepositoryChangeEvent) { @@ -165,16 +171,54 @@ export class DocumentTracker implements Disposable { RepositoryChangeComparisonMode.Any, ) ) { - this.reset('repository', new Set([e.repository.path])); + void this.refreshDocuments({ addedOrChangedRepoPaths: new Set([e.repository.path]) }); } } - private async onTextDocumentChanged(e: TextDocumentChangeEvent) { - const { scheme } = e.document.uri; - if (!this.container.git.supportedSchemes.has(scheme)) return; + private onTextDocumentOpened(document: TextDocument, visible?: boolean) { + if (!this.container.git.supportedSchemes.has(document.uri.scheme)) return; - const doc = await (this._documentMap.get(e.document) ?? this.addCore(e.document)); - doc.reset('document'); + void this.addCore(document, visible); + } + + private debouncedTextDocumentChanges = new WeakMap< + TextDocument, + Deferrable[0]> + >(); + + private onTextDocumentChanged(e: TextDocumentChangeEvent) { + if (!this.container.git.supportedSchemes.has(e.document.uri.scheme)) return; + if (!this._documentMap.has(e.document)) return; + + let debouncedChange = this.debouncedTextDocumentChanges.get(e.document); + if (debouncedChange == null) { + debouncedChange = debounce( + e => this.onTextDocumentChangedCore(e), + 50, + ([prev]: [TextDocumentChangeEvent], [next]: [TextDocumentChangeEvent]) => { + return [ + { + ...next, + // Aggregate content changes + contentChanges: [...prev.contentChanges, ...next.contentChanges], + } satisfies TextDocumentChangeEvent, + ]; + }, + ); + this.debouncedTextDocumentChanges.set(e.document, debouncedChange); + } + + debouncedChange(e); + } + + private async onTextDocumentChangedCore(e: TextDocumentChangeEvent) { + this.debouncedTextDocumentChanges.delete(e.document); + + const docPromise = this._documentMap.get(e.document); + if (docPromise == null) return; + + const doc = await docPromise; + doc.refresh('changed'); const dirty = e.document.isDirty; const editor = window.activeTextEditor; @@ -209,32 +253,32 @@ export class DocumentTracker implements Disposable { } private async onTextDocumentSaved(document: TextDocument) { - const doc = this._documentMap.get(document); - if (doc != null) { - void (await doc).update({ forceBlameChange: true }); - - return; - } + const docPromise = this._documentMap.get(document); + if (docPromise == null) return; - // If we are saving the active document make sure we are tracking it - if (isActiveDocument(document)) { - void this.addCore(document); - } + const doc = await docPromise; + doc.refresh('saved'); } - // private onVisibleEditorsChanged(editors: TextEditor[]) { - // if (this._documentMap.size === 0) return; + private onVisibleTextEditorsChanged(editors: readonly TextEditor[]) { + const docPromises = []; + for (const editor of editors) { + const document = editor.document; + if (!this.container.git.supportedSchemes.has(document.uri.scheme)) continue; - // // If we have no visible editors, or no "real" visible editors reset our cache - // if (editors.length === 0 || editors.every(e => !isTextEditor(e))) { - // this.clear(); - // } - // } + const docPromise = this._documentMap.get(document); + if (docPromise == null) continue; - add(document: TextDocument): Promise>; - add(uri: Uri): Promise>; - add(documentOrUri: TextDocument | Uri): Promise>; - async add(documentOrUri: TextDocument | Uri): Promise> { + docPromises.push(docPromise.then(doc => doc?.refresh('visible'))); + } + + void Promise.allSettled(docPromises); + } + + add(document: TextDocument): Promise; + add(uri: Uri): Promise; + add(documentOrUri: TextDocument | Uri): Promise; + async add(documentOrUri: TextDocument | Uri): Promise { let document; if (isGitUri(documentOrUri)) { try { @@ -276,15 +320,16 @@ export class DocumentTracker implements Disposable { return doc; } - private async addCore(document: TextDocument): Promise> { - const doc = TrackedDocument.create( + @debug() + private async addCore(document: TextDocument, visible?: boolean): Promise { + const doc = createTrackedGitDocument( + this.container, + this, document, + (e: DocumentBlameStateChangeEvent) => this._onDidChangeBlameState.fire(e), + visible ?? isVisibleDocument(document), // Always start out false, so we will fire the event if needed false, - { - onDidBlameStateChange: (e: DocumentBlameStateChangeEvent) => this._onDidChangeBlameState.fire(e), - }, - this.container, ); this._documentMap.set(document, doc); @@ -292,6 +337,7 @@ export class DocumentTracker implements Disposable { return doc; } + @debug() async clear() { for (const d of this._documentMap.values()) { (await d).dispose(); @@ -300,10 +346,11 @@ export class DocumentTracker implements Disposable { this._documentMap.clear(); } - get(document: TextDocument): Promise> | undefined; - get(uri: Uri): Promise> | undefined; - get(documentOrUri: TextDocument | Uri): Promise> | undefined; - get(documentOrUri: TextDocument | Uri): Promise> | undefined { + get(document: TextDocument): Promise | undefined; + get(uri: Uri): Promise | undefined; + get(documentOrUri: TextDocument | Uri): Promise | undefined; + @debug() + get(documentOrUri: TextDocument | Uri): Promise | undefined { if (documentOrUri instanceof Uri) { const document = findTextDocument(documentOrUri); if (document == null) return undefined; @@ -315,11 +362,7 @@ export class DocumentTracker implements Disposable { return doc; } - async getOrAdd(documentOrUri: TextDocument | Uri): Promise> { - if (documentOrUri instanceof Uri) { - documentOrUri = findTextDocument(documentOrUri) ?? documentOrUri; - } - + async getOrAdd(documentOrUri: TextDocument | Uri): Promise { const doc = this.get(documentOrUri) ?? this.add(documentOrUri); return doc; } @@ -337,20 +380,88 @@ export class DocumentTracker implements Disposable { return this._documentMap.has(documentOrUri); } - private async remove(document: TextDocument, tracked?: TrackedDocument): Promise { - let promise; + resetCache(document: TextDocument, affects: 'blame' | 'diff' | 'log'): Promise; + resetCache(uri: Uri, affects: 'blame' | 'diff' | 'log'): Promise; + @debug() + async resetCache(documentOrUri: TextDocument | Uri, affects: 'blame' | 'diff' | 'log'): Promise { + const doc = this.get(documentOrUri); + if (doc == null) return; + + switch (affects) { + case 'blame': + (await doc).state?.clearBlame(); + break; + case 'diff': + (await doc).state?.clearDiff(); + break; + case 'log': + (await doc).state?.clearLog(); + break; + } + } + + @debug({ args: { 1: false } }) + private async remove(document: TextDocument, tracked?: TrackedGitDocument): Promise { + let docPromise; if (tracked != null) { - promise = this._documentMap.get(document); + docPromise = this._documentMap.get(document); } this._documentMap.delete(document); - (tracked ?? (await promise))?.dispose(); + this.updateContext(document.uri, false, false); + + (tracked ?? (await docPromise))?.dispose(); + } + + private readonly _openUrisBlameable = new Set(); + private readonly _openUrisTracked = new Set(); + private _updateContextDebounced: Deferrable<() => void> | undefined; + + updateContext(uri: Uri, blameable: boolean, tracked: boolean) { + let changed = false; + + function updateContextCore(this: GitDocumentTracker, key: string, blameable: boolean, tracked: boolean) { + if (tracked) { + if (!this._openUrisTracked.has(key)) { + changed = true; + this._openUrisTracked.add(key); + } + } else if (this._openUrisTracked.has(key)) { + changed = true; + this._openUrisTracked.delete(key); + } + + if (blameable) { + if (!this._openUrisBlameable.has(key)) { + changed = true; + + this._openUrisBlameable.add(key); + } + } else if (this._openUrisBlameable.has(key)) { + changed = true; + this._openUrisBlameable.delete(key); + } + + if (!changed) return; + + this._updateContextDebounced ??= debounce(() => { + void setContext('gitlens:tabs:tracked', [...this._openUrisTracked]); + void setContext('gitlens:tabs:blameable', [...this._openUrisBlameable]); + }, 100); + this._updateContextDebounced(); + } + + const key = getResourceContextKeyValue(uri); + if (typeof key !== 'string') { + void key.then(u => updateContextCore.call(this, u, blameable, tracked)); + return; + } + + updateContextCore.call(this, key, blameable, tracked); } - private _dirtyIdleTriggeredDebounced: Deferrable<(e: DocumentDirtyIdleTriggerEvent) => void> | undefined; - private _dirtyStateChangedDebounced: Deferrable<(e: DocumentDirtyStateChangeEvent) => void> | undefined; - private fireDocumentDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { + private fireDocumentDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { if (e.dirty) { queueMicrotask(() => { this._dirtyStateChangedDebounced?.cancel(); @@ -360,18 +471,13 @@ export class DocumentTracker implements Disposable { }); if (this._dirtyIdleTriggerDelay > 0) { - if (this._dirtyIdleTriggeredDebounced == null) { - this._dirtyIdleTriggeredDebounced = debounce( - (e: DocumentDirtyIdleTriggerEvent) => { - if (this._dirtyIdleTriggeredDebounced?.pending!()) return; - - e.document.isDirtyIdle = true; - this._onDidTriggerDirtyIdle.fire(e); - }, - this._dirtyIdleTriggerDelay, - { track: true }, - ); - } + this._dirtyIdleTriggeredDebounced ??= debounce((e: DocumentDirtyIdleTriggerEvent) => { + if (this._dirtyIdleTriggeredDebounced?.pending()) return; + + if (e.document.setDirtyIdle()) { + this._onDidTriggerDirtyIdle.fire(e); + } + }, this._dirtyIdleTriggerDelay); this._dirtyIdleTriggeredDebounced({ editor: e.editor, document: e.document }); } @@ -379,41 +485,31 @@ export class DocumentTracker implements Disposable { return; } - if (this._dirtyStateChangedDebounced == null) { - this._dirtyStateChangedDebounced = debounce((e: DocumentDirtyStateChangeEvent) => { - if (window.activeTextEditor !== e.editor) return; + this._dirtyStateChangedDebounced ??= debounce((e: DocumentDirtyStateChangeEvent) => { + if (window.activeTextEditor !== e.editor) return; - this._onDidChangeDirtyState.fire(e); - }, 250); - } + this._onDidChangeDirtyState.fire(e); + }, 250); this._dirtyStateChangedDebounced(e); } - @debug['reset']>({ - args: { - 1: c => (c != null ? join(c, ',') : ''), - 2: r => (r != null ? join(r, ',') : ''), - }, - }) - private reset(reason: 'config' | 'repository', changedRepoPaths?: Set, removedRepoPaths?: Set) { - void Promise.allSettled( - map( - filter(this._documentMap, ([key]) => typeof key === 'string'), - async ([, promise]) => { - const doc = await promise; - - if (removedRepoPaths?.has(doc.uri.repoPath!)) { - void this.remove(doc.document, doc); - return; - } + private async refreshDocuments(changed?: { + addedOrChangedRepoPaths?: Set; + removedRepoPaths?: Set; + }) { + if (this._documentMap.size === 0) return; - if (changedRepoPaths == null || changedRepoPaths.has(doc.uri.repoPath!)) { - doc.reset(reason); - } - }, - ), - ); + for await (const doc of this._documentMap.values()) { + const repoPath = doc.uri.repoPath?.toLocaleLowerCase(); + if (repoPath == null) continue; + + if (changed?.removedRepoPaths?.has(repoPath)) { + void this.remove(doc.document, doc); + } else if (changed == null || changed?.addedOrChangedRepoPaths?.has(repoPath)) { + doc.refresh('repositoryChanged'); + } + } } } diff --git a/src/trackers/gitDocumentTracker.ts b/src/trackers/gitDocumentTracker.ts deleted file mode 100644 index 8288e5a74991f..0000000000000 --- a/src/trackers/gitDocumentTracker.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { TextDocument, Uri } from 'vscode'; -import type { GitBlame } from '../git/models/blame'; -import type { GitDiff } from '../git/models/diff'; -import type { GitLog } from '../git/models/log'; -import { DocumentTracker } from './documentTracker'; - -export * from './documentTracker'; - -interface CachedItem { - item: Promise; - errorMessage?: string; -} - -export type CachedBlame = CachedItem; -export type CachedDiff = CachedItem; -export type CachedLog = CachedItem; - -export class GitDocumentState { - private readonly blameCache = new Map(); - private readonly diffCache = new Map(); - private readonly logCache = new Map(); - - clearBlame(key?: string): void { - if (key == null) { - this.blameCache.clear(); - return; - } - this.blameCache.delete(key); - } - - clearDiff(key?: string): void { - if (key == null) { - this.diffCache.clear(); - return; - } - this.diffCache.delete(key); - } - - clearLog(key?: string): void { - if (key == null) { - this.logCache.clear(); - return; - } - this.logCache.delete(key); - } - - getBlame(key: string): CachedBlame | undefined { - return this.blameCache.get(key); - } - - getDiff(key: string): CachedDiff | undefined { - return this.diffCache.get(key); - } - - getLog(key: string): CachedLog | undefined { - return this.logCache.get(key); - } - - setBlame(key: string, value: CachedBlame | undefined) { - if (value == null) { - this.blameCache.delete(key); - return; - } - this.blameCache.set(key, value); - } - - setDiff(key: string, value: CachedDiff | undefined) { - if (value == null) { - this.diffCache.delete(key); - return; - } - this.diffCache.set(key, value); - } - - setLog(key: string, value: CachedLog | undefined) { - if (value == null) { - this.logCache.delete(key); - return; - } - this.logCache.set(key, value); - } -} - -export class GitDocumentTracker extends DocumentTracker { - resetCache(document: TextDocument, affects: 'blame' | 'diff' | 'log'): Promise; - resetCache(uri: Uri, affects: 'blame' | 'diff' | 'log'): Promise; - async resetCache(documentOrUri: TextDocument | Uri, affects: 'blame' | 'diff' | 'log'): Promise { - const doc = this.get(documentOrUri); - if (doc == null) return; - - switch (affects) { - case 'blame': - (await doc).state?.clearBlame(); - break; - case 'diff': - (await doc).state?.clearDiff(); - break; - case 'log': - (await doc).state?.clearLog(); - break; - } - } -} diff --git a/src/trackers/gitLineTracker.ts b/src/trackers/gitLineTracker.ts deleted file mode 100644 index e039dcb4c6b7f..0000000000000 --- a/src/trackers/gitLineTracker.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { TextEditor } from 'vscode'; -import { Disposable } from 'vscode'; -import { configuration } from '../configuration'; -import { GlyphChars } from '../constants'; -import type { Container } from '../container'; -import type { GitCommit } from '../git/models/commit'; -import { getLogScope } from '../logScope'; -import { debug } from '../system/decorators/log'; -import type { - DocumentBlameStateChangeEvent, - DocumentContentChangeEvent, - DocumentDirtyIdleTriggerEvent, - DocumentDirtyStateChangeEvent, - GitDocumentState, -} from './gitDocumentTracker'; -import type { LinesChangeEvent, LineSelection } from './lineTracker'; -import { LineTracker } from './lineTracker'; - -export * from './lineTracker'; - -export class GitLineState { - constructor(public readonly commit: GitCommit | undefined) { - if (commit != null && commit.file == null) { - debugger; - } - } -} - -export class GitLineTracker extends LineTracker { - constructor(private readonly container: Container) { - super(); - } - - protected override async fireLinesChanged(e: LinesChangeEvent) { - this.reset(); - - let updated = false; - if (!this.suspended && !e.pending && e.selections != null && e.editor != null) { - updated = await this.updateState(e.selections, e.editor); - } - - return super.fireLinesChanged(updated ? e : { ...e, selections: undefined }); - } - - private _subscriptionOnlyWhenActive: Disposable | undefined; - - protected override onStart(): Disposable | undefined { - this.onResume(); - - return Disposable.from( - { dispose: () => this.onSuspend() }, - this.container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this), - this.container.tracker.onDidChangeDirtyState(this.onDirtyStateChanged, this), - this.container.tracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this), - ); - } - - protected override onResume(): void { - if (this._subscriptionOnlyWhenActive == null) { - this._subscriptionOnlyWhenActive = this.container.tracker.onDidChangeContent(this.onContentChanged, this); - } - } - - protected override onSuspend(): void { - this._subscriptionOnlyWhenActive?.dispose(); - this._subscriptionOnlyWhenActive = undefined; - } - - @debug({ - args: { - 0: e => - `editor=${e.editor.document.uri.toString(true)}, doc=${e.document.uri.toString(true)}, blameable=${ - e.blameable - }`, - }, - }) - private onBlameStateChanged(_e: DocumentBlameStateChangeEvent) { - this.trigger('editor'); - } - - @debug({ - args: { - 0: e => `editor=${e.editor.document.uri.toString(true)}, doc=${e.document.uri.toString(true)}`, - }, - }) - private onContentChanged(e: DocumentContentChangeEvent) { - if ( - e.contentChanges.some(scope => - this.selections?.some( - selection => - (scope.range.end.line >= selection.active && selection.active >= scope.range.start.line) || - (scope.range.start.line >= selection.active && selection.active >= scope.range.end.line), - ), - ) - ) { - this.trigger('editor'); - } - } - - @debug({ - args: { - 0: e => `editor=${e.editor.document.uri.toString(true)}, doc=${e.document.uri.toString(true)}`, - }, - }) - private onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent) { - const maxLines = configuration.get('advanced.blame.sizeThresholdAfterEdit'); - if (maxLines > 0 && e.document.lineCount > maxLines) return; - - this.resume(); - } - - @debug({ - args: { - 0: e => - `editor=${e.editor.document.uri.toString(true)}, doc=${e.document.uri.toString(true)}, dirty=${ - e.dirty - }`, - }, - }) - private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { - if (e.dirty) { - this.suspend(); - } else { - this.resume({ force: true }); - } - } - - @debug({ - args: { 0: selections => selections?.map(s => s.active).join(','), 1: e => e.document.uri.toString(true) }, - exit: updated => `returned ${updated}`, - }) - private async updateState(selections: LineSelection[], editor: TextEditor): Promise { - const scope = getLogScope(); - - if (!this.includes(selections)) { - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} lines no longer match`; - } - - return false; - } - - const trackedDocument = await this.container.tracker.getOrAdd(editor.document); - if (!trackedDocument.isBlameable) { - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} document is not blameable`; - } - - return false; - } - - if (selections.length === 1) { - const blameLine = await this.container.git.getBlameForLine( - trackedDocument.uri, - selections[0].active, - editor?.document, - ); - if (blameLine == null) { - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} blame failed`; - } - - return false; - } - - this.setState(blameLine.line.line - 1, new GitLineState(blameLine.commit)); - } else { - const blame = await this.container.git.getBlame(trackedDocument.uri, editor.document); - if (blame == null) { - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} blame failed`; - } - - return false; - } - - for (const selection of selections) { - const commitLine = blame.lines[selection.active]; - this.setState(selection.active, new GitLineState(blame.commits.get(commitLine.sha))); - } - } - - // Check again because of the awaits above - - if (!this.includes(selections)) { - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} lines no longer match`; - } - - return false; - } - - if (!trackedDocument.isBlameable) { - if (scope != null) { - scope.exitDetails = ` ${GlyphChars.Dot} document is not blameable`; - } - - return false; - } - - if (editor.document.isDirty) { - trackedDocument.setForceDirtyStateChangeOnNextDocumentChange(); - } - - return true; - } -} diff --git a/src/trackers/lineTracker.ts b/src/trackers/lineTracker.ts index c0fdb9f6184bf..0b832b24ff1e5 100644 --- a/src/trackers/lineTracker.ts +++ b/src/trackers/lineTracker.ts @@ -1,11 +1,19 @@ import type { Event, Selection, TextEditor, TextEditorSelectionChangeEvent } from 'vscode'; import { Disposable, EventEmitter, window } from 'vscode'; -import { Logger } from '../logger'; -import { getLogScope } from '../logScope'; +import type { Container } from '../container'; +import type { GitCommit } from '../git/models/commit'; import { debug } from '../system/decorators/log'; import type { Deferrable } from '../system/function'; import { debounce } from '../system/function'; -import { isTextEditor } from '../system/utils'; +import { getLogScope, setLogScopeExit } from '../system/logger.scope'; +import { isTrackableTextEditor } from '../system/vscode/utils'; +import type { + DocumentBlameStateChangeEvent, + DocumentContentChangeEvent, + DocumentDirtyIdleTriggerEvent, + DocumentDirtyStateChangeEvent, + GitDocumentTracker, +} from './documentTracker'; export interface LinesChangeEvent { readonly editor: TextEditor | undefined; @@ -13,6 +21,7 @@ export interface LinesChangeEvent { readonly reason: 'editor' | 'selection'; readonly pending?: boolean; + readonly suspended?: boolean; } export interface LineSelection { @@ -20,7 +29,11 @@ export interface LineSelection { active: number; } -export class LineTracker implements Disposable { +export interface LineState { + commit: GitCommit; +} + +export class LineTracker { private _onDidChangeActiveLines = new EventEmitter(); get onDidChangeActiveLines(): Event { return this._onDidChangeActiveLines.event; @@ -28,8 +41,14 @@ export class LineTracker implements Disposable { protected _disposable: Disposable | undefined; private _editor: TextEditor | undefined; + private readonly _state = new Map(); + private _subscriptions = new Map(); + private _subscriptionOnlyWhenTracking: Disposable | undefined; - private readonly _state = new Map(); + constructor( + private readonly container: Container, + private readonly documentTracker: GitDocumentTracker, + ) {} dispose() { for (const subscriber of this._subscriptions.keys()) { @@ -39,47 +58,114 @@ export class LineTracker implements Disposable { private onActiveTextEditorChanged(editor: TextEditor | undefined) { if (editor === this._editor) return; - if (editor != null && !isTextEditor(editor)) return; + if (editor != null && !isTrackableTextEditor(editor)) return; - this.reset(); this._editor = editor; - this._selections = LineTracker.toLineSelections(editor?.selections); + this._selections = toLineSelections(editor?.selections); + + if (this._suspended) { + this.resume({ force: true }); + } else { + this.notifyLinesChanged('editor'); + } + } - this.trigger('editor'); + @debug({ + args: { + 0: e => `editor/doc=${e.editor?.document.uri.toString(true)}, blameable=${e.blameable}`, + }, + }) + private onBlameStateChanged(_e: DocumentBlameStateChangeEvent) { + this.notifyLinesChanged('editor'); + } + + @debug({ + args: { + 0: e => `editor/doc=${e.editor.document.uri.toString(true)}`, + }, + }) + private onContentChanged(e: DocumentContentChangeEvent) { + if ( + this.selections?.length && + e.contentChanges.some(c => + this.selections!.some( + selection => + (c.range.end.line >= selection.active && selection.active >= c.range.start.line) || + (c.range.start.line >= selection.active && selection.active >= c.range.end.line), + ), + ) + ) { + this.notifyLinesChanged('editor'); + } + } + + @debug({ + args: { + 0: e => `editor/doc=${e.editor.document.uri.toString(true)}`, + }, + }) + private onDirtyIdleTriggered(_e: DocumentDirtyIdleTriggerEvent) { + this.resume(); + } + + @debug({ + args: { + 0: e => `editor/doc=${e.editor.document.uri.toString(true)}, dirty=${e.dirty}`, + }, + }) + private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { + if (e.dirty) { + this.suspend(); + } else { + this.resume({ force: true }); + } } private onTextEditorSelectionChanged(e: TextEditorSelectionChangeEvent) { // If this isn't for our cached editor and its not a real editor -- kick out - if (this._editor !== e.textEditor && !isTextEditor(e.textEditor)) return; + if (this._editor !== e.textEditor && !isTrackableTextEditor(e.textEditor)) return; - const selections = LineTracker.toLineSelections(e.selections); + const selections = toLineSelections(e.selections); if (this._editor === e.textEditor && this.includes(selections)) return; - this.reset(); this._editor = e.textEditor; this._selections = selections; - this.trigger(this._editor === e.textEditor ? 'selection' : 'editor'); + this.notifyLinesChanged(this._editor === e.textEditor ? 'selection' : 'editor'); + } + + private _selections: LineSelection[] | undefined; + get selections(): LineSelection[] | undefined { + return this._selections; + } + + private _suspended = false; + get suspended() { + return this._suspended; } - getState(line: number): T | undefined { + getState(line: number): LineState | undefined { return this._state.get(line); } - setState(line: number, state: T | undefined) { - this._state.set(line, state); + resetState(line?: number) { + if (line != null) { + this._state.delete(line); + return; + } + + this._state.clear(); } - private _selections: LineSelection[] | undefined; - get selections(): LineSelection[] | undefined { - return this._selections; + setState(line: number, state: LineState | undefined) { + this._state.set(line, state); } includes(selections: LineSelection[]): boolean; includes(line: number, options?: { activeOnly: boolean }): boolean; includes(lineOrSelections: number | LineSelection[], options?: { activeOnly: boolean }): boolean { if (typeof lineOrSelections !== 'number') { - return LineTracker.includes(lineOrSelections, this._selections); + return isIncluded(lineOrSelections, this._selections); } if (this._selections == null || this._selections.length === 0) return false; @@ -101,22 +187,39 @@ export class LineTracker implements Disposable { } refresh() { - this.trigger('editor'); + this.notifyLinesChanged('editor'); } - reset() { - this._state.clear(); + @debug() + resume(options?: { force?: boolean; silent?: boolean }) { + if (!options?.force && !this._suspended) return; + + this._suspended = false; + this._subscriptionOnlyWhenTracking ??= this.documentTracker.onDidChangeContent(this.onContentChanged, this); + + if (!options?.silent) { + this.notifyLinesChanged('editor'); + } } - private _subscriptions = new Map(); + @debug() + suspend(options?: { force?: boolean; silent?: boolean }) { + if (!options?.force && this._suspended) return; + + this._suspended = true; + this._subscriptionOnlyWhenTracking?.dispose(); + this._subscriptionOnlyWhenTracking = undefined; + + if (!options?.silent) { + this.notifyLinesChanged('editor'); + } + } subscribed(subscriber: unknown) { return this._subscriptions.has(subscriber); } - protected onStart?(): Disposable | undefined; - - @debug({ args: false }) + @debug({ args: false, singleLine: true }) subscribe(subscriber: unknown, subscription: Disposable): Disposable { const scope = getLogScope(); @@ -135,21 +238,28 @@ export class LineTracker implements Disposable { } if (first) { - Logger.debug(scope, 'Starting line tracker...'); + setLogScopeExit(scope, ' \u2022 starting line tracker...'); + + this.resume({ force: true, silent: true }); this._disposable = Disposable.from( + { dispose: () => this.suspend({ force: true, silent: true }) }, window.onDidChangeActiveTextEditor(debounce(this.onActiveTextEditorChanged, 0), this), window.onDidChangeTextEditorSelection(this.onTextEditorSelectionChanged, this), - this.onStart?.() ?? { dispose: () => {} }, + this.documentTracker.onDidChangeBlameState(this.onBlameStateChanged, this), + this.documentTracker.onDidChangeDirtyState(this.onDirtyStateChanged, this), + this.documentTracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this), ); queueMicrotask(() => this.onActiveTextEditorChanged(window.activeTextEditor)); + } else { + setLogScopeExit(scope, ' \u2022 already started...'); } return disposable; } - @debug({ args: false }) + @debug({ args: false, singleLine: true }) unsubscribe(subscriber: unknown) { const subs = this._subscriptions.get(subscriber); if (subs == null) return; @@ -161,105 +271,158 @@ export class LineTracker implements Disposable { if (this._subscriptions.size !== 0) return; - if (this._linesChangedDebounced != null) { - this._linesChangedDebounced.cancel(); - } - + this._fireLinesChangedDebounced?.cancel(); this._disposable?.dispose(); this._disposable = undefined; } - private _suspended = false; - get suspended() { - return this._suspended; + private async fireLinesChanged(e: LinesChangeEvent) { + let updated = false; + if (!this.suspended && !e.pending && e.selections != null && e.editor != null) { + updated = await this.updateState(e.selections, e.editor); + } + + this._onDidChangeActiveLines.fire(updated ? e : { ...e, selections: undefined, suspended: this.suspended }); } - protected onResume?(): void; + private _fireLinesChangedDebounced: Deferrable<(e: LinesChangeEvent) => void> | undefined; + private notifyLinesChanged(reason: 'editor' | 'selection') { + if (reason === 'editor') { + this.resetState(); + } - @debug() - resume(options?: { force?: boolean }) { - if (!options?.force && !this._suspended) return; + const e: LinesChangeEvent = { editor: this._editor, selections: this.selections, reason: reason }; + if (e.selections == null) { + queueMicrotask(() => { + if (e.editor !== window.activeTextEditor) return; - this._suspended = false; - this.onResume?.(); - this.trigger('editor'); - } + this._fireLinesChangedDebounced?.cancel(); - protected onSuspend?(): void; + void this.fireLinesChanged(e); + }); - @debug() - suspend(options?: { force?: boolean }) { - if (!options?.force && this._suspended) return; + return; + } - this._suspended = true; - this.onSuspend?.(); - this.trigger('editor'); - } + if (this._fireLinesChangedDebounced == null) { + this._fireLinesChangedDebounced = debounce((e: LinesChangeEvent) => { + if (e.editor !== window.activeTextEditor) return; - protected fireLinesChanged(e: LinesChangeEvent) { - this._onDidChangeActiveLines.fire(e); - } + // Make sure we are still on the same lines + if (!isIncluded(e.selections, toLineSelections(e.editor?.selections))) { + return; + } + + void this.fireLinesChanged(e); + }, 250); + } - protected trigger(reason: 'editor' | 'selection') { - this.onLinesChanged({ editor: this._editor, selections: this.selections, reason: reason }); + // If we have no pending moves, then fire an immediate pending event, and defer the real event + if (!this._fireLinesChangedDebounced.pending()) { + void this.fireLinesChanged({ ...e, pending: true }); + } + + this._fireLinesChangedDebounced(e); } - private _linesChangedDebounced: Deferrable<(e: LinesChangeEvent) => void> | undefined; + @debug({ + args: { 0: selections => selections?.map(s => s.active).join(','), 1: e => e.document.uri.toString(true) }, + exit: true, + }) + private async updateState(selections: LineSelection[], editor: TextEditor): Promise { + const scope = getLogScope(); - private onLinesChanged(e: LinesChangeEvent) { - if (e.selections == null) { - queueMicrotask(() => { - if (e.editor !== window.activeTextEditor) return; + if (!this.includes(selections)) { + setLogScopeExit(scope, ` \u2022 lines no longer match`); - if (this._linesChangedDebounced != null) { - this._linesChangedDebounced.cancel(); - } + return false; + } - this.fireLinesChanged(e); - }); + const document = await this.documentTracker.getOrAdd(editor.document); + let status = await document.getStatus(); + if (!status.blameable) { + setLogScopeExit(scope, ` \u2022 document is not blameable`); - return; + return false; } - if (this._linesChangedDebounced == null) { - this._linesChangedDebounced = debounce( - (e: LinesChangeEvent) => { - if (e.editor !== window.activeTextEditor) return; - // Make sure we are still on the same lines - if (!LineTracker.includes(e.selections, LineTracker.toLineSelections(e.editor?.selections))) { - return; - } - - this.fireLinesChanged(e); - }, - 250, - { track: true }, + if (selections.length === 1) { + const blameLine = await this.container.git.getBlameForLine( + document.uri, + selections[0].active, + editor?.document, ); + if (blameLine == null) { + setLogScopeExit(scope, ` \u2022 blame failed`); + + return false; + } + + if (blameLine.commit != null && blameLine.commit.file == null) { + debugger; + } + + this.setState(blameLine.line.line - 1, { commit: blameLine.commit }); + } else { + const blame = await this.container.git.getBlame(document.uri, editor.document); + if (blame == null) { + setLogScopeExit(scope, ` \u2022 blame failed`); + + return false; + } + + for (const selection of selections) { + const commitLine = blame.lines[selection.active]; + const commit = blame.commits.get(commitLine.sha); + if (commit != null && commit.file == null) { + debugger; + } + + if (commit == null) { + debugger; + this.resetState(selection.active); + } else { + this.setState(selection.active, { commit: commit }); + } + } } - // If we have no pending moves, then fire an immediate pending event, and defer the real event - if (!this._linesChangedDebounced.pending?.()) { - this.fireLinesChanged({ ...e, pending: true }); + // Check again because of the awaits above + + if (!this.includes(selections)) { + setLogScopeExit(scope, ` \u2022 lines no longer match`); + + return false; } - this._linesChangedDebounced(e); - } + status = await document.getStatus(); + + if (!status.blameable) { + setLogScopeExit(scope, ` \u2022 document is not blameable`); - static includes(selections: LineSelection[] | undefined, inSelections: LineSelection[] | undefined): boolean { - if (selections == null && inSelections == null) return true; - if (selections == null || inSelections == null || selections.length !== inSelections.length) return false; + return false; + } - let match; + if (editor.document.isDirty) { + document.setForceDirtyStateChangeOnNextDocumentChange(); + } - return selections.every((s, i) => { - match = inSelections[i]; - return s.active === match.active && s.anchor === match.anchor; - }); + return true; } +} - static toLineSelections(selections: readonly Selection[]): LineSelection[]; - static toLineSelections(selections: readonly Selection[] | undefined): LineSelection[] | undefined; - static toLineSelections(selections: readonly Selection[] | undefined) { - return selections?.map(s => ({ active: s.active.line, anchor: s.anchor.line })); - } +function isIncluded(selections: LineSelection[] | undefined, within: LineSelection[] | undefined): boolean { + if (selections == null && within == null) return true; + if (selections == null || within == null || selections.length !== within.length) return false; + + return selections.every((s, i) => { + const match = within[i]; + return s.active === match.active && s.anchor === match.anchor; + }); +} + +function toLineSelections(selections: readonly Selection[]): LineSelection[]; +function toLineSelections(selections: readonly Selection[] | undefined): LineSelection[] | undefined; +function toLineSelections(selections: readonly Selection[] | undefined) { + return selections?.map(s => ({ active: s.active.line, anchor: s.anchor.line })); } diff --git a/src/trackers/trackedDocument.ts b/src/trackers/trackedDocument.ts index d00a17cfa09b7..b9b1103aec202 100644 --- a/src/trackers/trackedDocument.ts +++ b/src/trackers/trackedDocument.ts @@ -1,49 +1,130 @@ -import type { Disposable, Event, TextDocument, TextEditor } from 'vscode'; -import { EventEmitter } from 'vscode'; -import { ContextKeys } from '../constants'; +import type { Disposable, TextDocument } from 'vscode'; import type { Container } from '../container'; -import { setContext } from '../context'; import { GitUri } from '../git/gitUri'; -import { GitRevision } from '../git/models/reference'; -import { Logger } from '../logger'; +import type { GitBlame } from '../git/models/blame'; +import type { GitDiffFile } from '../git/models/diff'; +import type { GitLog } from '../git/models/log'; +import { debug, logName } from '../system/decorators/log'; import type { Deferrable } from '../system/function'; import { debounce } from '../system/function'; -import { getEditorIfActive, isActiveDocument } from '../system/utils'; +import { Logger } from '../system/logger'; +import { getLogScope } from '../system/logger.scope'; +import { configuration } from '../system/vscode/configuration'; +import { getEditorIfVisible, isActiveDocument, isVisibleDocument } from '../system/vscode/utils'; +import type { DocumentBlameStateChangeEvent, GitDocumentTracker } from './documentTracker'; + +interface CachedItem { + item: Promise; + errorMessage?: string; +} + +export type CachedBlame = CachedItem; +export type CachedDiff = CachedItem; +export type CachedLog = CachedItem; + +export class GitDocumentState { + private readonly blameCache = new Map(); + private readonly diffCache = new Map(); + private readonly logCache = new Map(); + + clearBlame(key?: string): void { + if (key == null) { + this.blameCache.clear(); + return; + } + this.blameCache.delete(key); + } + + clearDiff(key?: string): void { + if (key == null) { + this.diffCache.clear(); + return; + } + this.diffCache.delete(key); + } + + clearLog(key?: string): void { + if (key == null) { + this.logCache.clear(); + return; + } + this.logCache.delete(key); + } + + getBlame(key: string): CachedBlame | undefined { + return this.blameCache.get(key); + } + + getDiff(key: string): CachedDiff | undefined { + return this.diffCache.get(key); + } + + getLog(key: string): CachedLog | undefined { + return this.logCache.get(key); + } + + setBlame(key: string, value: CachedBlame | undefined) { + if (value == null) { + this.blameCache.delete(key); + return; + } + this.blameCache.set(key, value); + } + + setDiff(key: string, value: CachedDiff | undefined) { + if (value == null) { + this.diffCache.delete(key); + return; + } + this.diffCache.set(key, value); + } -export interface DocumentBlameStateChangeEvent { - readonly editor: TextEditor; - readonly document: TrackedDocument; - readonly blameable: boolean; + setLog(key: string, value: CachedLog | undefined) { + if (value == null) { + this.logCache.delete(key); + return; + } + this.logCache.set(key, value); + } } -export class TrackedDocument implements Disposable { - static async create( +export interface TrackedGitDocumentStatus { + blameable: boolean; + tracked: boolean; + + dirtyIdle?: boolean; +} + +@logName((c, name) => `${name}(${Logger.toLoggable(c.document)})`) +export class TrackedGitDocument implements Disposable { + static async create( + container: Container, + tracker: GitDocumentTracker, document: TextDocument, + onDidBlameStateChange: (e: DocumentBlameStateChangeEvent) => void, + visible: boolean, dirty: boolean, - eventDelegates: { onDidBlameStateChange(e: DocumentBlameStateChangeEvent): void }, - container: Container, ) { - const doc = new TrackedDocument(document, dirty, eventDelegates, container); - await doc.initialize(); + const doc = new TrackedGitDocument(container, tracker, document, onDidBlameStateChange, dirty); + await doc.initialize(visible); return doc; } - private _onDidBlameStateChange = new EventEmitter>(); - get onDidBlameStateChange(): Event> { - return this._onDidBlameStateChange.event; - } - - state: T | undefined; + state: GitDocumentState | undefined; private _disposable: Disposable | undefined; private _disposed: boolean = false; + private _tracked: boolean = false; + private _pendingUpdates: { reason: string; forceBlameChange?: boolean; forceDirtyIdle?: boolean } | undefined; + private _updateDebounced: Deferrable | undefined; private _uri!: GitUri; private constructor( + private readonly container: Container, + private readonly tracker: GitDocumentTracker, readonly document: TextDocument, + private readonly _onDidChangeBlameState: (e: DocumentBlameStateChangeEvent) => void, public dirty: boolean, - private _eventDelegates: { onDidBlameStateChange(e: DocumentBlameStateChangeEvent): void }, - private readonly container: Container, ) {} dispose() { @@ -53,47 +134,40 @@ export class TrackedDocument implements Disposable { this._disposable?.dispose(); } - private initializing = true; - private async initialize(): Promise { - const uri = this.document.uri; + private _loading = false; - this._uri = await GitUri.fromUri(uri); - if (!this._disposed) { - await this.update(); - } - - this.initializing = false; - } + @debug() + private async initialize(visible: boolean): Promise { + this._uri = await GitUri.fromUri(this.document.uri); + if (this._disposed) return; - private _forceDirtyStateChangeOnNextDocumentChange: boolean = false; - get forceDirtyStateChangeOnNextDocumentChange() { - return this._forceDirtyStateChangeOnNextDocumentChange; + this._pendingUpdates = { ...this._pendingUpdates, reason: 'initialize', forceDirtyIdle: true }; + if (visible) { + this._loading = true; + void this.update().finally(() => (this._loading = false)); + } } - private _hasRemotes: boolean = false; - get hasRemotes() { - return this._hasRemotes; + private get blameable() { + return this._blameFailure != null ? false : this._tracked; } - get isBlameable() { - return this._blameFailed ? false : this._isTracked; - } + get canDirtyIdle(): boolean { + if (!this.document.isDirty) return false; - private _isDirtyIdle: boolean = false; - get isDirtyIdle() { - return this._isDirtyIdle; - } - set isDirtyIdle(value: boolean) { - this._isDirtyIdle = value; + const maxLines = configuration.get('advanced.blame.sizeThresholdAfterEdit'); + return !(maxLines > 0 && this.document.lineCount > maxLines); } - get isRevision() { - return this._uri != null ? Boolean(this._uri.sha) && this._uri.sha !== GitRevision.deletedOrMissing : false; + private _dirtyIdle: boolean = false; + setDirtyIdle(): boolean { + this._dirtyIdle = this.canDirtyIdle; + return this._dirtyIdle; } - private _isTracked: boolean = false; - get isTracked() { - return this._isTracked; + private _forceDirtyStateChangeOnNextDocumentChange: boolean = false; + get forceDirtyStateChangeOnNextDocumentChange() { + return this._forceDirtyStateChangeOnNextDocumentChange; } get lineCount(): number { @@ -104,48 +178,69 @@ export class TrackedDocument implements Disposable { return this._uri; } - async activate(): Promise { - if (this._requiresUpdate) { + async getStatus(): Promise { + if (this._pendingUpdates != null) { await this.update(); } - void setContext(ContextKeys.ActiveFileStatus, this.getStatus()); + return { + blameable: this.blameable, + tracked: this._tracked, + + dirtyIdle: this._dirtyIdle, + }; } is(document: TextDocument) { return document === this.document; } - private _updateDebounced: - | Deferrable<({ forceBlameChange }?: { forceBlameChange?: boolean | undefined }) => Promise> - | undefined; + @debug() + refresh(reason: 'changed' | 'saved' | 'visible' | 'repositoryChanged') { + if (this._pendingUpdates == null && reason === 'visible') return; + + const scope = getLogScope(); - reset(reason: 'config' | 'document' | 'repository') { - this._requiresUpdate = true; - this._blameFailed = false; - this._isDirtyIdle = false; + this._blameFailure = undefined; + this._dirtyIdle = false; if (this.state != null) { this.state = undefined; - Logger.log(`Reset state for '${this.document.uri.toString(true)}', reason=${reason}`); + Logger.log(scope, `Reset state, reason=${reason}`); } - if (reason === 'repository' && isActiveDocument(this.document)) { - if (this._updateDebounced == null) { - this._updateDebounced = debounce(this.update.bind(this), 250); - } + switch (reason) { + case 'changed': + // Pending update here? + return; + case 'saved': + this._pendingUpdates = { ...this._pendingUpdates, reason: reason, forceBlameChange: true }; + break; + case 'repositoryChanged': + this._pendingUpdates = { ...this._pendingUpdates, reason: reason }; + break; + } + // Only update the active document immediately if this isn't a "visible" change, since visible changes need to be debounced (vscode fires too many) + if (isActiveDocument(this.document) && reason !== 'visible') { + void this.update(); + } else if (isVisibleDocument(this.document)) { + this._updateDebounced ??= debounce(this.update.bind(this), 100); void this._updateDebounced(); } } - private _blameFailed: boolean = false; - setBlameFailure() { - const wasBlameable = this.isBlameable; + private _blameFailure: Error | undefined; + setBlameFailure(ex: Error) { + const wasBlameable = this.blameable; + + this._blameFailure = ex; - this._blameFailed = true; + if (wasBlameable) { + this._pendingUpdates = { ...this._pendingUpdates, reason: 'blame-failed', forceBlameChange: true }; - if (wasBlameable && isActiveDocument(this.document)) { - void this.update({ forceBlameChange: true }); + if (isActiveDocument(this.document)) { + void this.update(); + } } } @@ -157,62 +252,44 @@ export class TrackedDocument implements Disposable { this._forceDirtyStateChangeOnNextDocumentChange = true; } - private _requiresUpdate: boolean = true; - async update({ forceBlameChange }: { forceBlameChange?: boolean } = {}) { - this._requiresUpdate = false; + @debug() + private async update(): Promise { + const updates = this._pendingUpdates; + this._pendingUpdates = undefined; if (this._disposed || this._uri == null) { - this._hasRemotes = false; - this._isTracked = false; + this._tracked = false; return; } - this._isDirtyIdle = false; - - // Caches these before the awaits - const active = getEditorIfActive(this.document); - const wasBlameable = forceBlameChange ? undefined : this.isBlameable; + this._dirtyIdle = Boolean(this.document.isDirty && updates?.forceDirtyIdle && this.canDirtyIdle); + // Cache before await + const wasBlameable = updates?.forceBlameChange ? undefined : this.blameable; const repo = this.container.git.getRepository(this._uri); - if (repo == null) { - this._isTracked = false; - this._hasRemotes = false; - } else { - [this._isTracked, this._hasRemotes] = await Promise.all([ - this.container.git.isTracked(this._uri), - repo.hasRemotes(), - ]); - } - - if (active != null) { - const blameable = this.isBlameable; + this._tracked = repo != null ? await this.container.git.isTracked(this._uri) : false; - void setContext(ContextKeys.ActiveFileStatus, this.getStatus()); + this.tracker.updateContext(this.document.uri, this.blameable, this._tracked); - if (!this.initializing && wasBlameable !== blameable) { - const e: DocumentBlameStateChangeEvent = { editor: active, document: this, blameable: blameable }; - this._onDidBlameStateChange.fire(e); - this._eventDelegates.onDidBlameStateChange(e); - } + if (!this._loading && wasBlameable !== this.blameable) { + const e: DocumentBlameStateChangeEvent = { + editor: getEditorIfVisible(this.document), + document: this, + blameable: this.blameable, + }; + this._onDidChangeBlameState(e); } } +} - private getStatus() { - let status = ''; - if (this.isTracked) { - status += 'tracked|'; - } - if (this.isBlameable) { - status += 'blameable|'; - } - if (this.isRevision) { - status += 'revision|'; - } - if (this.hasRemotes) { - status += 'remotes|'; - } - - return status ? status : undefined; - } +export async function createTrackedGitDocument( + container: Container, + tracker: GitDocumentTracker, + document: TextDocument, + onDidChangeBlameState: (e: DocumentBlameStateChangeEvent) => void, + visible: boolean, + dirty: boolean, +) { + return TrackedGitDocument.create(container, tracker, document, onDidChangeBlameState, visible, dirty); } diff --git a/src/uris/deepLinks/deepLink.ts b/src/uris/deepLinks/deepLink.ts index 723fde1db1c06..318bf47ac1789 100644 --- a/src/uris/deepLinks/deepLink.ts +++ b/src/uris/deepLinks/deepLink.ts @@ -2,28 +2,49 @@ import type { Uri } from 'vscode'; import type { GitReference } from '../../git/models/reference'; import type { GitRemote } from '../../git/models/remote'; import type { Repository } from '../../git/models/repository'; +import type { OpenWorkspaceLocation } from '../../system/vscode/utils'; -export const enum UriTypes { - DeepLink = 'link', -} +export type UriTypes = 'link'; export enum DeepLinkType { Branch = 'b', Commit = 'c', + Comparison = 'compare', + Draft = 'drafts', + File = 'f', Repository = 'r', Tag = 't', + Workspace = 'workspace', +} + +export enum DeepLinkActionType { + Switch = 'switch', + SwitchToPullRequest = 'switch-to-pr', + SwitchToPullRequestWorktree = 'switch-to-pr-worktree', + SwitchToAndSuggestPullRequest = 'switch-to-and-suggest-pr', } +export const AccountDeepLinkTypes: DeepLinkType[] = [DeepLinkType.Draft, DeepLinkType.Workspace]; +export const PaidDeepLinkTypes: DeepLinkType[] = []; + export function deepLinkTypeToString(type: DeepLinkType): string { switch (type) { case DeepLinkType.Branch: return 'Branch'; case DeepLinkType.Commit: return 'Commit'; + case DeepLinkType.Comparison: + return 'Comparison'; + case DeepLinkType.Draft: + return 'Cloud Patch'; + case DeepLinkType.File: + return 'File'; case DeepLinkType.Repository: return 'Repository'; case DeepLinkType.Tag: return 'Tag'; + case DeepLinkType.Workspace: + return 'Workspace'; default: debugger; return 'Unknown'; @@ -45,127 +66,259 @@ export function refTypeToDeepLinkType(refType: GitReference['refType']): DeepLin export interface DeepLink { type: DeepLinkType; - repoId: string; - remoteUrl: string; + mainId?: string; + remoteUrl?: string; + repoPath?: string; + filePath?: string; targetId?: string; + secondaryTargetId?: string; + secondaryRemoteUrl?: string; + action?: string; + params?: URLSearchParams; } export function parseDeepLinkUri(uri: Uri): DeepLink | undefined { // The link target id is everything after the link target. // For example, if the uri is /link/r/{repoId}/b/{branchName}?url={remoteUrl}, // the link target id is {branchName} - const [, type, prefix, repoId, target, ...targetId] = uri.path.split('/'); - if (type !== UriTypes.DeepLink || prefix !== DeepLinkType.Repository) return undefined; - - const remoteUrl = new URLSearchParams(uri.query).get('url'); - if (!remoteUrl) return undefined; - - if (target == null) { - return { - type: DeepLinkType.Repository, - repoId: repoId, - remoteUrl: remoteUrl, - }; - } + const [, type, prefix, mainId, target, ...rest] = uri.path.split('/'); + if (type !== 'link') return undefined; + + const urlParams = new URLSearchParams(uri.query); + switch (prefix) { + case DeepLinkType.Repository: { + let remoteUrl = urlParams.get('url') ?? undefined; + if (remoteUrl != null) { + remoteUrl = decodeURIComponent(remoteUrl); + } + let repoPath = urlParams.get('path') ?? undefined; + if (repoPath != null) { + repoPath = decodeURIComponent(repoPath); + } + if (!remoteUrl && !repoPath) return undefined; + + const action = urlParams.get('action') ?? undefined; + + if (target == null) { + return { + type: DeepLinkType.Repository, + mainId: mainId, + remoteUrl: remoteUrl, + repoPath: repoPath, + }; + } + + if (rest == null || rest.length === 0) return undefined; + + let targetId: string | undefined; + let secondaryTargetId: string | undefined; + let secondaryRemoteUrl: string | undefined; + let filePath: string | undefined; + const joined = rest.join('/'); - return { - type: target as DeepLinkType, - repoId: repoId, - remoteUrl: remoteUrl, - targetId: targetId.join('/'), - }; + if (target === DeepLinkType.Comparison) { + const split = joined.split(/(\.\.\.|\.\.)/); + if (split.length !== 3) return undefined; + targetId = split[0]; + secondaryTargetId = split[2]; + secondaryRemoteUrl = urlParams.get('prRepoUrl') ?? undefined; + if (secondaryRemoteUrl != null) { + secondaryRemoteUrl = decodeURIComponent(secondaryRemoteUrl); + } + } else if (target === DeepLinkType.File) { + filePath = joined; + let ref = urlParams.get('ref') ?? undefined; + if (ref != null) { + ref = decodeURIComponent(ref); + } + targetId = ref; + let lines = urlParams.get('lines') ?? undefined; + if (lines != null) { + lines = decodeURIComponent(lines); + } + secondaryTargetId = lines; + } else { + targetId = joined; + } + + return { + type: target as DeepLinkType, + mainId: mainId, + remoteUrl: remoteUrl, + repoPath: repoPath, + filePath: filePath, + targetId: targetId, + secondaryTargetId: secondaryTargetId, + secondaryRemoteUrl: secondaryRemoteUrl, + action: action, + params: urlParams, + }; + } + case DeepLinkType.Draft: { + if (mainId == null || mainId.match(/^v\d+$/)) return undefined; + + let patchId = urlParams.get('patch') ?? undefined; + if (patchId != null) { + patchId = decodeURIComponent(patchId); + } + + return { + type: DeepLinkType.Draft, + targetId: mainId, + secondaryTargetId: patchId, + params: urlParams, + }; + } + + case DeepLinkType.Workspace: + return { + type: DeepLinkType.Workspace, + mainId: mainId, + params: urlParams, + }; + + default: + return undefined; + } } export const enum DeepLinkServiceState { Idle, + AccountCheck, + PlanCheck, + TypeMatch, RepoMatch, CloneOrAddRepo, - OpeningRepo, AddedRepoMatch, RemoteMatch, AddRemote, TargetMatch, Fetch, FetchedTargetMatch, + MaybeOpenRepo, + RepoOpening, + EnsureRemoteMatch, + GoToTarget, OpenGraph, + OpenComparison, + OpenDraft, + OpenWorkspace, + OpenFile, + OpenInspect, + SwitchToRef, } export const enum DeepLinkServiceAction { + AccountCheckPassed, DeepLinkEventFired, DeepLinkCancelled, DeepLinkResolved, DeepLinkStored, DeepLinkErrored, - OpenRepo, - RepoMatchedWithId, - RepoMatchedWithRemoteUrl, + LinkIsRepoType, + LinkIsDraftType, + LinkIsWorkspaceType, + PlanCheckPassed, + RepoMatched, + RepoMatchedInLocalMapping, RepoMatchFailed, RepoAdded, - RepoOpened, RemoteMatched, RemoteMatchFailed, + RemoteMatchUnneeded, RemoteAdded, - TargetIsRemote, TargetMatched, TargetMatchFailed, TargetFetched, + RepoOpened, + RepoOpening, + OpenGraph, + OpenComparison, + OpenFile, + OpenInspect, + OpenSwitch, } -export const enum DeepLinkRepoOpenType { - Folder = 'folder', - Workspace = 'workspace', -} +export type DeepLinkRepoOpenType = 'clone' | 'folder' | 'workspace' | 'current'; export interface DeepLinkServiceContext { state: DeepLinkServiceState; url?: string | undefined; - repoId?: string | undefined; + mainId?: string | undefined; repo?: Repository | undefined; remoteUrl?: string | undefined; remote?: GitRemote | undefined; + secondaryRemote?: GitRemote | undefined; + repoPath?: string | undefined; + filePath?: string | undefined; targetId?: string | undefined; + secondaryTargetId?: string | undefined; + secondaryRemoteUrl?: string | undefined; targetType?: DeepLinkType | undefined; targetSha?: string | undefined; + secondaryTargetSha?: string | undefined; + action?: string | undefined; + repoOpenLocation?: OpenWorkspaceLocation | undefined; + repoOpenUri?: Uri | undefined; + params?: URLSearchParams | undefined; + currentBranch?: string | undefined; } -export const deepLinkStateTransitionTable: { [state: string]: { [action: string]: DeepLinkServiceState } } = { +export const deepLinkStateTransitionTable: Record> = { [DeepLinkServiceState.Idle]: { - [DeepLinkServiceAction.DeepLinkEventFired]: DeepLinkServiceState.RepoMatch, + [DeepLinkServiceAction.DeepLinkEventFired]: DeepLinkServiceState.AccountCheck, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, }, - [DeepLinkServiceState.RepoMatch]: { - [DeepLinkServiceAction.RepoMatchedWithId]: DeepLinkServiceState.RemoteMatch, - [DeepLinkServiceAction.RepoMatchedWithRemoteUrl]: DeepLinkServiceState.TargetMatch, - [DeepLinkServiceAction.RepoMatchFailed]: DeepLinkServiceState.CloneOrAddRepo, + [DeepLinkServiceState.AccountCheck]: { + [DeepLinkServiceAction.AccountCheckPassed]: DeepLinkServiceState.PlanCheck, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, }, - [DeepLinkServiceState.CloneOrAddRepo]: { - [DeepLinkServiceAction.OpenRepo]: DeepLinkServiceState.OpeningRepo, - [DeepLinkServiceAction.RepoOpened]: DeepLinkServiceState.RemoteMatch, + [DeepLinkServiceState.PlanCheck]: { + [DeepLinkServiceAction.PlanCheckPassed]: DeepLinkServiceState.TypeMatch, [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, - [DeepLinkServiceAction.DeepLinkStored]: DeepLinkServiceState.Idle, }, - [DeepLinkServiceState.OpeningRepo]: { + [DeepLinkServiceState.TypeMatch]: { + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.LinkIsRepoType]: DeepLinkServiceState.RepoMatch, + [DeepLinkServiceAction.LinkIsDraftType]: DeepLinkServiceState.OpenDraft, + [DeepLinkServiceAction.LinkIsWorkspaceType]: DeepLinkServiceState.OpenWorkspace, + }, + [DeepLinkServiceState.RepoMatch]: { + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.RepoMatched]: DeepLinkServiceState.RemoteMatch, + [DeepLinkServiceAction.RepoMatchedInLocalMapping]: DeepLinkServiceState.CloneOrAddRepo, + [DeepLinkServiceAction.RepoMatchFailed]: DeepLinkServiceState.CloneOrAddRepo, + }, + [DeepLinkServiceState.CloneOrAddRepo]: { [DeepLinkServiceAction.RepoAdded]: DeepLinkServiceState.AddedRepoMatch, [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, }, [DeepLinkServiceState.AddedRepoMatch]: { - [DeepLinkServiceAction.RepoMatchedWithId]: DeepLinkServiceState.RemoteMatch, - [DeepLinkServiceAction.RepoMatchedWithRemoteUrl]: DeepLinkServiceState.TargetMatch, + [DeepLinkServiceAction.RepoMatched]: DeepLinkServiceState.RemoteMatch, [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, }, [DeepLinkServiceState.RemoteMatch]: { + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, [DeepLinkServiceAction.RemoteMatched]: DeepLinkServiceState.TargetMatch, [DeepLinkServiceAction.RemoteMatchFailed]: DeepLinkServiceState.AddRemote, + [DeepLinkServiceAction.RemoteMatchUnneeded]: DeepLinkServiceState.TargetMatch, }, [DeepLinkServiceState.AddRemote]: { - [DeepLinkServiceAction.RemoteAdded]: DeepLinkServiceState.OpenGraph, + [DeepLinkServiceAction.RemoteAdded]: DeepLinkServiceState.TargetMatch, [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, }, [DeepLinkServiceState.TargetMatch]: { - [DeepLinkServiceAction.TargetIsRemote]: DeepLinkServiceState.OpenGraph, - [DeepLinkServiceAction.TargetMatched]: DeepLinkServiceState.OpenGraph, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.TargetMatched]: DeepLinkServiceState.MaybeOpenRepo, [DeepLinkServiceAction.TargetMatchFailed]: DeepLinkServiceState.Fetch, }, [DeepLinkServiceState.Fetch]: { @@ -174,11 +327,99 @@ export const deepLinkStateTransitionTable: { [state: string]: { [action: string] [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, }, [DeepLinkServiceState.FetchedTargetMatch]: { - [DeepLinkServiceAction.TargetMatched]: DeepLinkServiceState.OpenGraph, + [DeepLinkServiceAction.TargetMatched]: DeepLinkServiceState.MaybeOpenRepo, [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, + [DeepLinkServiceState.MaybeOpenRepo]: { + [DeepLinkServiceAction.RepoOpened]: DeepLinkServiceState.EnsureRemoteMatch, + [DeepLinkServiceAction.RepoOpening]: DeepLinkServiceState.RepoOpening, + [DeepLinkServiceAction.DeepLinkStored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, + [DeepLinkServiceState.RepoOpening]: { + [DeepLinkServiceAction.RepoOpened]: DeepLinkServiceState.EnsureRemoteMatch, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, + [DeepLinkServiceState.EnsureRemoteMatch]: { + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.RemoteMatched]: DeepLinkServiceState.GoToTarget, + }, + [DeepLinkServiceState.GoToTarget]: { + [DeepLinkServiceAction.OpenGraph]: DeepLinkServiceState.OpenGraph, + [DeepLinkServiceAction.OpenFile]: DeepLinkServiceState.OpenFile, + [DeepLinkServiceAction.OpenSwitch]: DeepLinkServiceState.SwitchToRef, + [DeepLinkServiceAction.OpenComparison]: DeepLinkServiceState.OpenComparison, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, }, [DeepLinkServiceState.OpenGraph]: { [DeepLinkServiceAction.DeepLinkResolved]: DeepLinkServiceState.Idle, [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, }, + [DeepLinkServiceState.OpenComparison]: { + [DeepLinkServiceAction.DeepLinkResolved]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, + [DeepLinkServiceState.OpenDraft]: { + [DeepLinkServiceAction.DeepLinkResolved]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, + [DeepLinkServiceState.OpenWorkspace]: { + [DeepLinkServiceAction.DeepLinkResolved]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, + [DeepLinkServiceState.OpenFile]: { + [DeepLinkServiceAction.DeepLinkResolved]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, + [DeepLinkServiceState.OpenInspect]: { + [DeepLinkServiceAction.DeepLinkResolved]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, + [DeepLinkServiceState.SwitchToRef]: { + [DeepLinkServiceAction.OpenInspect]: DeepLinkServiceState.OpenInspect, + [DeepLinkServiceAction.DeepLinkResolved]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkErrored]: DeepLinkServiceState.Idle, + [DeepLinkServiceAction.DeepLinkCancelled]: DeepLinkServiceState.Idle, + }, +}; + +export interface DeepLinkProgress { + message: string; + increment: number; +} + +export const deepLinkStateToProgress: Record = { + [DeepLinkServiceState.Idle]: { message: 'Done.', increment: 100 }, + [DeepLinkServiceState.AccountCheck]: { message: 'Checking account...', increment: 1 }, + [DeepLinkServiceState.PlanCheck]: { message: 'Checking plan...', increment: 2 }, + [DeepLinkServiceState.TypeMatch]: { message: 'Matching link type...', increment: 5 }, + [DeepLinkServiceState.RepoMatch]: { message: 'Finding a matching repository...', increment: 10 }, + [DeepLinkServiceState.CloneOrAddRepo]: { message: 'Adding repository...', increment: 20 }, + [DeepLinkServiceState.AddedRepoMatch]: { message: 'Finding a matching repository...', increment: 25 }, + [DeepLinkServiceState.RemoteMatch]: { message: 'Finding a matching remote...', increment: 30 }, + [DeepLinkServiceState.AddRemote]: { message: 'Adding remote...', increment: 40 }, + [DeepLinkServiceState.TargetMatch]: { message: 'finding a matching target...', increment: 50 }, + [DeepLinkServiceState.Fetch]: { message: 'Fetching...', increment: 60 }, + [DeepLinkServiceState.FetchedTargetMatch]: { message: 'Finding a matching target...', increment: 65 }, + [DeepLinkServiceState.MaybeOpenRepo]: { message: 'Opening repository...', increment: 70 }, + [DeepLinkServiceState.RepoOpening]: { message: 'Opening repository...', increment: 75 }, + [DeepLinkServiceState.GoToTarget]: { message: 'Opening target...', increment: 80 }, + [DeepLinkServiceState.OpenGraph]: { message: 'Opening graph...', increment: 90 }, + [DeepLinkServiceState.OpenComparison]: { message: 'Opening comparison...', increment: 90 }, + [DeepLinkServiceState.OpenDraft]: { message: 'Opening cloud patch...', increment: 90 }, + [DeepLinkServiceState.OpenWorkspace]: { message: 'Opening workspace...', increment: 90 }, + [DeepLinkServiceState.OpenFile]: { message: 'Opening file...', increment: 90 }, + [DeepLinkServiceState.OpenInspect]: { message: 'Opening inspect...', increment: 90 }, + [DeepLinkServiceState.SwitchToRef]: { message: 'Switching to ref...', increment: 90 }, }; diff --git a/src/uris/deepLinks/deepLinkService.ts b/src/uris/deepLinks/deepLinkService.ts index dfe44462c1f34..87254fdc353a1 100644 --- a/src/uris/deepLinks/deepLinkService.ts +++ b/src/uris/deepLinks/deepLinkService.ts @@ -1,67 +1,69 @@ -import { Disposable, env, ProgressLocation, Uri, window, workspace } from 'vscode'; -import { configuration } from '../../configuration'; -import { Commands } from '../../constants'; +import type { QuickPickItem } from 'vscode'; +import { Disposable, env, EventEmitter, ProgressLocation, Range, Uri, window, workspace } from 'vscode'; +import { Commands } from '../../constants.commands'; +import type { StoredDeepLinkContext, StoredNamedRef } from '../../constants.storage'; import type { Container } from '../../container'; +import { executeGitCommand } from '../../git/actions'; +import { openFileAtRevision } from '../../git/actions/commit'; +import type { GitBranch } from '../../git/models/branch'; import { getBranchNameWithoutRemote } from '../../git/models/branch'; -import { GitReference } from '../../git/models/reference'; -import type { GitRemote } from '../../git/models/remote'; +import type { GitCommit } from '../../git/models/commit'; +import type { GitReference } from '../../git/models/reference'; +import { createReference, isSha } from '../../git/models/reference'; +import type { RepositoryChangeEvent } from '../../git/models/repository'; +import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; +import type { GitTag } from '../../git/models/tag'; import { parseGitRemoteUrl } from '../../git/parsers/remoteParser'; -import { Logger } from '../../logger'; -import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/graphWebview'; -import type { StoredDeepLinkContext } from '../../storage'; -import { executeCommand } from '../../system/command'; +import type { RepositoryIdentity } from '../../gk/models/repositoryIdentities'; +import { missingRepositoryId } from '../../gk/models/repositoryIdentities'; +import { ensureAccount, ensurePaidPlan } from '../../plus/utils'; +import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/protocol'; +import { createQuickPickSeparator } from '../../quickpicks/items/common'; import { once } from '../../system/event'; -import { openWorkspace, OpenWorkspaceLocation } from '../../system/utils'; -import type { DeepLink, DeepLinkServiceContext } from './deepLink'; +import { Logger } from '../../system/logger'; +import { normalizePath } from '../../system/path'; +import { fromBase64 } from '../../system/string'; +import { executeCommand } from '../../system/vscode/command'; +import { configuration } from '../../system/vscode/configuration'; +import type { OpenWorkspaceLocation } from '../../system/vscode/utils'; +import { findOrOpenEditor, openWorkspace } from '../../system/vscode/utils'; +import { showInspectView } from '../../webviews/commitDetails/actions'; +import type { ShowWipArgs } from '../../webviews/commitDetails/protocol'; +import type { DeepLink, DeepLinkProgress, DeepLinkRepoOpenType, DeepLinkServiceContext, UriTypes } from './deepLink'; import { - DeepLinkRepoOpenType, + AccountDeepLinkTypes, + DeepLinkActionType, DeepLinkServiceAction, DeepLinkServiceState, + deepLinkStateToProgress, deepLinkStateTransitionTable, DeepLinkType, + deepLinkTypeToString, + PaidDeepLinkTypes, parseDeepLinkUri, - UriTypes, } from './deepLink'; +type OpenQuickPickItem = { + label: string; + action?: DeepLinkRepoOpenType; +}; + +type OpenLocationQuickPickItem = { + label: string; + action?: OpenWorkspaceLocation; +}; + export class DeepLinkService implements Disposable { private readonly _disposables: Disposable[] = []; private _context: DeepLinkServiceContext; + private readonly _onDeepLinkProgressUpdated = new EventEmitter(); constructor(private readonly container: Container) { this._context = { state: DeepLinkServiceState.Idle, }; - this._disposables.push( - container.uri.onDidReceiveUri(async (uri: Uri) => { - const link = parseDeepLinkUri(uri); - if (link == null) return; - - if (this._context.state === DeepLinkServiceState.Idle) { - if (!link.repoId || !link.type || !link.remoteUrl) { - void window.showErrorMessage('Unable to resolve link'); - Logger.warn(`Unable to resolve link - missing basic properties: ${uri.toString()}`); - return; - } - - if (!Object.values(DeepLinkType).includes(link.type)) { - void window.showErrorMessage('Unable to resolve link'); - Logger.warn(`Unable to resolve link - unknown link type: ${uri.toString()}`); - return; - } - - if (link.type !== DeepLinkType.Repository && !link.targetId) { - void window.showErrorMessage('Unable to resolve link'); - Logger.warn(`Unable to resolve link - no target id provided: ${uri.toString()}`); - return; - } - - this.setContextFromDeepLink(link, uri.toString()); - - await this.processDeepLink(); - } - }), - ); + this._disposables.push(container.uri.onDidReceiveUri(async (uri: Uri) => this.processDeepLinkUri(uri))); const pendingDeepLink = this.container.storage.get('deepLinks:pending'); if (pendingDeepLink != null) { @@ -78,151 +80,503 @@ export class DeepLinkService implements Disposable { this._context = { state: DeepLinkServiceState.Idle, url: undefined, - repoId: undefined, + mainId: undefined, repo: undefined, remoteUrl: undefined, remote: undefined, + secondaryRemote: undefined, + repoPath: undefined, + filePath: undefined, targetId: undefined, + secondaryTargetId: undefined, + secondaryRemoteUrl: undefined, targetType: undefined, targetSha: undefined, + action: undefined, + repoOpenLocation: undefined, + repoOpenUri: undefined, + params: undefined, + currentBranch: undefined, }; } private setContextFromDeepLink(link: DeepLink, url: string) { this._context = { ...this._context, - repoId: link.repoId, + mainId: link.mainId, targetType: link.type, url: url, remoteUrl: link.remoteUrl, + repoPath: link.repoPath, + filePath: link.filePath, targetId: link.targetId, + secondaryTargetId: link.secondaryTargetId, + secondaryRemoteUrl: link.secondaryRemoteUrl, + action: link.action, + params: link.params, }; } + async processDeepLinkUri(uri: Uri, useProgress: boolean = true) { + const link = parseDeepLinkUri(uri); + if (link == null) return; + + if (this._context.state === DeepLinkServiceState.Idle) { + if (this.container.git.isDiscoveringRepositories) { + await this.container.git.isDiscoveringRepositories; + } + + if (!link.type || (!link.mainId && !link.remoteUrl && !link.repoPath && !link.targetId)) { + void window.showErrorMessage('Unable to resolve link'); + Logger.warn(`Unable to resolve link - missing basic properties: ${uri.toString()}`); + return; + } + + if (!Object.values(DeepLinkType).includes(link.type)) { + void window.showErrorMessage('Unable to resolve link'); + Logger.warn(`Unable to resolve link - unknown link type: ${uri.toString()}`); + return; + } + + if (link.type !== DeepLinkType.Repository && link.targetId == null && link.mainId == null) { + void window.showErrorMessage('Unable to resolve link'); + Logger.warn(`Unable to resolve link - no main/target id provided: ${uri.toString()}`); + return; + } + + if (link.type === DeepLinkType.Comparison && link.secondaryTargetId == null) { + void window.showErrorMessage('Unable to resolve link'); + Logger.warn(`Unable to resolve link - no secondary target id provided: ${uri.toString()}`); + return; + } + + this.setContextFromDeepLink(link, uri.toString()); + + await this.processDeepLink(undefined, useProgress); + } + } + + private getServiceActionFromPendingContext(): DeepLinkServiceAction { + switch (this._context.state) { + case DeepLinkServiceState.MaybeOpenRepo: + return this._context.repo != null + ? DeepLinkServiceAction.RepoOpened + : DeepLinkServiceAction.RepoOpening; + case DeepLinkServiceState.SwitchToRef: { + if (this._context.repo == null) { + return DeepLinkServiceAction.DeepLinkErrored; + } + + switch (this._context.action) { + case DeepLinkActionType.SwitchToPullRequest: + case DeepLinkActionType.SwitchToPullRequestWorktree: + case DeepLinkActionType.SwitchToAndSuggestPullRequest: + return DeepLinkServiceAction.OpenInspect; + default: + return DeepLinkServiceAction.DeepLinkResolved; + } + } + default: + return DeepLinkServiceAction.DeepLinkErrored; + } + } + + private async findMatchingRepositoryFromCurrentWindow( + repoPath: string | undefined, + remoteUrl: string | undefined, + repoId: string | undefined, + isPending?: boolean, + ): Promise { + if (repoPath != null && isPending) { + const repoOpenUri = Uri.parse(repoPath); + try { + const openRepo = await this.container.git.getOrOpenRepository(repoOpenUri, { detectNested: false }); + if (openRepo != null) { + this._context.repo = openRepo; + return; + } + } catch {} + } + + let remoteDomain: string | undefined; + let remotePath: string | undefined; + if (remoteUrl != null) { + [, remoteDomain, remotePath] = parseGitRemoteUrl(remoteUrl); + } + + // Try to match a repo using the remote URL first, since that saves us some steps. + // As a fallback, try to match using the repo id. + for (const repo of this.container.git.repositories) { + if (repoPath != null && normalizePath(repo.path.toLowerCase()) === normalizePath(repoPath.toLowerCase())) { + this._context.repo = repo; + return; + } + + if (remoteDomain != null && remotePath != null) { + const matchingRemotes = await repo.getRemotes({ + filter: r => r.matches(remoteDomain, remotePath), + }); + if (matchingRemotes.length > 0) { + this._context.repo = repo; + this._context.remote = matchingRemotes[0]; + return; + } + } + + if (repoId != null && repoId !== missingRepositoryId) { + // Repo ID can be any valid SHA in the repo, though standard practice is to use the + // first commit SHA. + if (await this.container.git.validateReference(repo.path, repoId)) { + this._context.repo = repo; + return; + } + } + } + } + private async processPendingDeepLink(pendingDeepLink: StoredDeepLinkContext) { if (pendingDeepLink.url == null) return; const link = parseDeepLinkUri(Uri.parse(pendingDeepLink.url)); if (link == null) return; - this._context = { state: DeepLinkServiceState.CloneOrAddRepo }; + this._context = { state: pendingDeepLink.state ?? DeepLinkServiceState.MaybeOpenRepo }; this.setContextFromDeepLink(link, pendingDeepLink.url); + this._context.targetSha = pendingDeepLink.targetSha; + this._context.secondaryTargetSha = pendingDeepLink.secondaryTargetSha; + this._context.repoPath = pendingDeepLink.repoPath; - let action = DeepLinkServiceAction.OpenRepo; - - if (pendingDeepLink.repoPath != null) { - const repoOpenUri = Uri.parse(pendingDeepLink.repoPath); - try { - const repo = await this.container.git.getOrOpenRepository(repoOpenUri, { detectNested: false }); - if (repo != null) { - this._context.repo = repo; - action = DeepLinkServiceAction.RepoOpened; - } - } catch {} + if (this.container.git.isDiscoveringRepositories) { + await this.container.git.isDiscoveringRepositories; } + await this.findMatchingRepositoryFromCurrentWindow( + this._context.repoPath, + this._context.remoteUrl, + this._context.mainId, + true, + ); + + const action = this.getServiceActionFromPendingContext(); queueMicrotask(() => { - void this.processDeepLink(action); + void this.processDeepLink(action, pendingDeepLink.useProgress); }); } - private async getShaForTarget(): Promise { - const { repo, remote, targetType, targetId } = this._context; - if (!repo || !remote || targetType === DeepLinkType.Repository || !targetId) { - return undefined; + private async getBranch(targetId: string): Promise { + const { repo, remote, secondaryRemote } = this._context; + if (!repo) return undefined; + + let branchName: string = targetId; + + // If the branch name doesn't start with a remote name, first try using the primary and secondary remotes + if (remote != null && !branchName.startsWith(`${remote.name}/`)) { + branchName = `${remote.name}/${branchName}`; + } else if (secondaryRemote != null && !branchName.startsWith(`${secondaryRemote.name}/`)) { + branchName = `${secondaryRemote.name}/${branchName}`; } - if (targetType === DeepLinkType.Branch) { - // Form the target branch name using the remote name and branch name - const branchName = `${remote.name}/${targetId}`; - let branch = await repo.getBranch(branchName); - if (branch) { - return branch.sha; - } + let branch = await repo.getBranch(branchName); + if (branch != null) { + return branch; + } - // If it doesn't exist on the target remote, it may still exist locally. - branch = await repo.getBranch(targetId); - if (branch) { - return branch.sha; + // If that fails, try matching to any existing remote using its path. + if (targetId.includes(':')) { + const [providerRepoInfo, branchBaseName] = targetId.split(':'); + if (providerRepoInfo != null && branchName != null) { + const [owner, repoName] = providerRepoInfo.split('/'); + if (owner != null && repoName != null) { + const remotes = await repo.getRemotes(); + for (const remote of remotes) { + if (remote.provider?.owner === owner) { + branchName = `${remote.name}/${branchBaseName}`; + branch = await repo.getBranch(branchName); + if (branch != null) { + return branch; + } + } + } + } } + } - return undefined; + // If the above don't work, it may still exist locally. + return repo.getBranch(targetId); + } + + private async getCommit(targetId: string): Promise { + const { repo } = this._context; + if (!repo) return undefined; + if (await this.container.git.validateReference(repo.path, targetId)) { + return repo.getCommit(targetId); } - if (targetType === DeepLinkType.Tag) { - const tag = await repo.getTag(targetId); - if (tag) { - return tag.sha; - } + return undefined; + } + + private async getTag(targetId: string): Promise { + const { repo } = this._context; + return repo?.getTag(targetId); + } + + private async getShaForBranch(targetId: string): Promise { + return (await this.getBranch(targetId))?.sha; + } - return undefined; + private async getShaForTag(targetId: string): Promise { + return (await this.getTag(targetId))?.sha; + } + + private async getShaForCommit(targetId: string): Promise { + const { repo } = this._context; + if (!repo) return undefined; + if (await this.container.git.validateReference(repo.path, targetId)) { + return targetId; + } + + return undefined; + } + + private async getShasForComparison( + targetId: string, + secondaryTargetId: string, + ): Promise<[string, string] | undefined> { + const sha1 = await this.getRefSha(targetId); + if (sha1 == null) return undefined; + const sha2 = await this.getRefSha(secondaryTargetId); + if (sha2 == null) return undefined; + return [sha1, sha2]; + } + + private async getRefSha(ref: string) { + // try treating each id as a commit sha first, then a branch if that fails, then a tag if that fails. + // Note: a blank target id will be treated as 'Working Tree' and will resolve to a blank Sha. + + if (ref === '') return ref; + + if (isSha(ref)) return this.getShaForCommit(ref); + + const normalized = ref.toLocaleLowerCase(); + if (!normalized.startsWith('refs/tags/') && !normalized.startsWith('tags/')) { + const branchSha = await this.getShaForBranch(ref); + if (branchSha != null) return branchSha; + } + + const tagSha = await this.getShaForTag(ref); + if (tagSha != null) return tagSha; + + return this.getShaForCommit(ref); + } + + private async getTargetRef(ref: string): Promise { + if (ref === '') return undefined; + if (isSha(ref)) return this.getCommit(ref); + + const normalized = ref.toLocaleLowerCase(); + if (!normalized.startsWith('refs/tags/') && !normalized.startsWith('tags/')) { + const branch = await this.getBranch(ref); + if (branch != null) return branch; + } + + const tag = await this.getTag(ref); + if (tag != null) return tag; + + return this.getCommit(ref); + } + + private async getShasForTargets(): Promise { + const { repo, targetType, targetId, secondaryTargetId } = this._context; + if (repo == null || targetType === DeepLinkType.Repository || targetId == null) return undefined; + if (targetType === DeepLinkType.Branch) { + return this.getShaForBranch(targetId); + } + + if (targetType === DeepLinkType.Tag) { + return this.getShaForTag(targetId); } if (targetType === DeepLinkType.Commit) { - if (await this.container.git.validateReference(repo.path, targetId)) { - return targetId; - } + return this.getShaForCommit(targetId); + } - return undefined; + if (targetType === DeepLinkType.File) { + return this.getRefSha(targetId); + } + + if (targetType === DeepLinkType.Comparison) { + if (secondaryTargetId == null) return undefined; + return this.getShasForComparison(targetId, secondaryTargetId); } return undefined; } - private async showOpenTypePrompt(): Promise { - const openTypeResult = await window.showInformationMessage( - 'No matching repository found. Please choose an option.', - { modal: true }, - { title: 'Open Folder', action: DeepLinkRepoOpenType.Folder }, - { title: 'Open Workspace', action: DeepLinkRepoOpenType.Workspace }, - { title: 'Cancel', isCloseAffordance: true }, - ); + private async showOpenTypePrompt(options?: { + includeCurrent?: boolean; + customMessage?: string; + }): Promise { + const openOptions: OpenQuickPickItem[] = [ + { label: 'Choose a Local Folder...', action: 'folder' }, + { label: 'Choose a Workspace File...', action: 'workspace' }, + ]; + + if (this._context.remoteUrl != null) { + openOptions.push({ label: 'Clone Repository...', action: 'clone' }); + } + + if (options?.includeCurrent) { + openOptions.push(createQuickPickSeparator(), { label: 'Use Current Window', action: 'current' }); + } + + openOptions.push(createQuickPickSeparator(), { label: 'Cancel' }); + const openTypeResult = await window.showQuickPick(openOptions, { + title: 'Locating Repository', + placeHolder: + options?.customMessage ?? 'Unable to locate a matching repository, please choose how to locate it', + }); return openTypeResult?.action; } private async showOpenLocationPrompt(openType: DeepLinkRepoOpenType): Promise { - // Only add the "add to workspace" option if openType is DeepLinkRepoOpenType.Folder - const openOptions: { title: string; action?: OpenWorkspaceLocation; isCloseAffordance?: boolean }[] = [ - { title: 'Open', action: OpenWorkspaceLocation.CurrentWindow }, - { title: 'Open in New Window', action: OpenWorkspaceLocation.NewWindow }, + // Only add the "add to workspace" option if openType is 'folder' + const openOptions: OpenLocationQuickPickItem[] = [ + { label: 'Open in Current Window', action: 'currentWindow' }, + { label: 'Open in New Window', action: 'newWindow' }, ]; - if (openType === DeepLinkRepoOpenType.Folder) { - openOptions.push({ title: 'Add to Workspace', action: OpenWorkspaceLocation.AddToWorkspace }); + if (openType !== 'workspace') { + openOptions.push({ label: 'Add Folder to Workspace', action: 'addToWorkspace' }); } - openOptions.push({ title: 'Cancel', isCloseAffordance: true }); - const openLocationResult = await window.showInformationMessage( - `Please choose an option to open the repository ${openType}.`, - { modal: true }, - ...openOptions, - ); + let suffix; + switch (openType) { + case 'clone': + suffix = ' \u00a0\u2022\u00a0 Clone'; + break; + case 'folder': + suffix = ' \u00a0\u2022\u00a0 Folder'; + break; + case 'workspace': + suffix = ' \u00a0\u2022\u00a0 Workspace from File'; + break; + case 'current': + suffix = ''; + break; + } + + openOptions.push(createQuickPickSeparator(), { label: 'Cancel' }); + const openLocationResult = await window.showQuickPick(openOptions, { + title: `Locating Repository${suffix}`, + placeHolder: `Please choose where to open the repository ${ + openType === 'clone' ? 'after cloning' : openType + }`, + }); return openLocationResult?.action; } + private async showFetchPrompt(): Promise { + const fetch: QuickPickItem = { label: 'Fetch' }; + const cancel: QuickPickItem = { label: 'Cancel' }; + const result = await window.showQuickPick([fetch, createQuickPickSeparator(), cancel], { + title: 'Locating Link Target', + placeHolder: 'Unable to find the link target(s), would you like to fetch from the remote?', + }); + + return result === fetch; + } + + private async showAddRemotePrompt(remoteUrl: string, existingRemoteNames: string[]): Promise { + const add: QuickPickItem = { label: 'Add Remote' }; + const cancel: QuickPickItem = { label: 'Cancel' }; + const result = await window.showQuickPick([add, cancel], { + title: `Locating Remote`, + placeHolder: `Unable to find remote for '${remoteUrl}', would you like to add a new remote?`, + }); + if (result !== add) return undefined; + + const remoteName = await window.showInputBox({ + prompt: 'Enter a name for the remote', + value: getMaybeRemoteNameFromRemoteUrl(remoteUrl), + validateInput: value => { + if (!value) return 'A name is required'; + if (existingRemoteNames.includes(value)) return 'A remote with that name already exists'; + return undefined; + }, + }); + + return remoteName; + } + private async processDeepLink( initialAction: DeepLinkServiceAction = DeepLinkServiceAction.DeepLinkEventFired, + useProgress: boolean = true, ): Promise { let message = ''; let action = initialAction; + if (action === DeepLinkServiceAction.DeepLinkCancelled && this._context.state === DeepLinkServiceState.Idle) { + return; + } - // Remote match - let matchingRemotes: GitRemote[] = []; - let remoteDomain = ''; - let remotePath = ''; - - // Repo open - let repoOpenType; - let repoOpenLocation; - let repoOpenUri: Uri | undefined = undefined; + //Repo match + let matchingLocalRepoPaths: string[] = []; + const { targetType } = this._context; + + if (useProgress) { + queueMicrotask( + () => + void window.withProgress( + { + cancellable: true, + location: ProgressLocation.Notification, + title: `Opening ${deepLinkTypeToString(targetType ?? DeepLinkType.Repository)} link...`, + }, + (progress, token) => { + progress.report({ increment: 0 }); + return new Promise(resolve => { + token.onCancellationRequested(() => { + queueMicrotask(() => this.processDeepLink(DeepLinkServiceAction.DeepLinkCancelled)); + resolve(); + }); + + this._onDeepLinkProgressUpdated.event(({ message, increment }) => { + progress.report({ message: message, increment: increment }); + if (increment === 100) { + resolve(); + } + }); + }); + }, + ), + ); + } while (true) { this._context.state = deepLinkStateTransitionTable[this._context.state][action]; - const { state, repoId, repo, url, remoteUrl, remote, targetSha, targetType } = this._context; + const { + state, + mainId, + repo, + url, + remoteUrl, + secondaryRemoteUrl, + remote, + secondaryRemote, + repoPath, + filePath, + targetId, + secondaryTargetId, + targetSha, + secondaryTargetSha, + targetType, + repoOpenLocation, + repoOpenUri, + } = this._context; + this._onDeepLinkProgressUpdated.fire(deepLinkStateToProgress[state]); switch (state) { - case DeepLinkServiceState.Idle: + case DeepLinkServiceState.Idle: { if (action === DeepLinkServiceAction.DeepLinkErrored) { void window.showErrorMessage('Unable to resolve link'); Logger.warn(`Unable to resolve link - ${message}: ${url}`); @@ -231,33 +585,144 @@ export class DeepLinkService implements Disposable { // Deep link processing complete. Reset the context and return. this.resetContext(); return; + } + case DeepLinkServiceState.AccountCheck: { + if (targetType == null) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'No link type provided.'; + break; + } + if (!AccountDeepLinkTypes.includes(targetType)) { + action = DeepLinkServiceAction.AccountCheckPassed; + break; + } + + if ( + !(await ensureAccount( + this.container, + `Opening ${deepLinkTypeToString( + targetType, + )} links is a Preview feature and requires an account.`, + { + source: 'deeplink', + detail: { + action: 'open', + type: targetType, + friendlyType: deepLinkTypeToString(targetType), + }, + }, + )) + ) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Account required to open link'; + break; + } + + action = DeepLinkServiceAction.AccountCheckPassed; + break; + } + case DeepLinkServiceState.PlanCheck: { + if (targetType == null) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'No link type provided.'; + break; + } + if (!PaidDeepLinkTypes.includes(targetType)) { + action = DeepLinkServiceAction.PlanCheckPassed; + break; + } + + if ( + !(await ensurePaidPlan( + this.container, + `Opening ${deepLinkTypeToString(targetType)} links is a Pro feature.`, + { + source: 'deeplink', + detail: { + action: 'open', + type: targetType, + friendlyType: deepLinkTypeToString(targetType), + }, + }, + )) + ) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Paid plan required to open link'; + break; + } + + action = DeepLinkServiceAction.PlanCheckPassed; + break; + } + case DeepLinkServiceState.TypeMatch: { + switch (targetType) { + case DeepLinkType.Draft: + action = DeepLinkServiceAction.LinkIsDraftType; + break; + case DeepLinkType.Workspace: + action = DeepLinkServiceAction.LinkIsWorkspaceType; + break; + default: + action = DeepLinkServiceAction.LinkIsRepoType; + break; + } + + break; + } case DeepLinkServiceState.RepoMatch: - case DeepLinkServiceState.AddedRepoMatch: - if (!repoId || !remoteUrl) { + case DeepLinkServiceState.AddedRepoMatch: { + if (repo != null && repoOpenUri != null && repoOpenLocation != null) { + action = DeepLinkServiceAction.RepoMatched; + break; + } + + if (!mainId && !remoteUrl && !repoPath) { action = DeepLinkServiceAction.DeepLinkErrored; - message = 'No repository id or remote url was provided.'; + message = 'No repository id, remote url or path was provided.'; break; } - [, remoteDomain, remotePath] = parseGitRemoteUrl(remoteUrl); + let repoIdentity: RepositoryIdentity | undefined; + + let mainIdToSearch = mainId; + let remoteUrlToSearch = remoteUrl; + + if (repoIdentity != null) { + this._context.remoteUrl = repoIdentity.remote?.url ?? undefined; + remoteUrlToSearch = repoIdentity.remote?.url; + this._context.mainId = repoIdentity.initialCommitSha ?? undefined; + mainIdToSearch = repoIdentity.initialCommitSha; + } + // Try to match a repo using the remote URL first, since that saves us some steps. // As a fallback, try to match using the repo id. - for (const repo of this.container.git.repositories) { - // eslint-disable-next-line no-loop-func - matchingRemotes = await repo.getRemotes({ filter: r => r.matches(remoteDomain, remotePath) }); - if (matchingRemotes.length > 0) { - this._context.repo = repo; - this._context.remote = matchingRemotes[0]; - action = DeepLinkServiceAction.RepoMatchedWithRemoteUrl; - break; - } + await this.findMatchingRepositoryFromCurrentWindow(repoPath, remoteUrlToSearch, mainIdToSearch); + if (this._context.repo != null) { + action = DeepLinkServiceAction.RepoMatched; + break; + } - // Repo ID can be any valid SHA in the repo, though standard practice is to use the - // first commit SHA. - if (await this.container.git.validateReference(repo.path, repoId)) { - this._context.repo = repo; - action = DeepLinkServiceAction.RepoMatchedWithId; - break; + if (!this._context.repo && state === DeepLinkServiceState.RepoMatch) { + matchingLocalRepoPaths = await this.container.repositoryPathMapping.getLocalRepoPaths({ + remoteUrl: remoteUrlToSearch, + }); + if (matchingLocalRepoPaths.length > 0) { + for (const repo of this.container.git.repositories) { + if ( + matchingLocalRepoPaths.some( + p => normalizePath(repo.path.toLowerCase()) === normalizePath(p.toLowerCase()), + ) + ) { + this._context.repo = repo; + action = DeepLinkServiceAction.RepoMatched; + break; + } + } + + if (this._context.repo == null) { + action = DeepLinkServiceAction.RepoMatchedInLocalMapping; + break; + } } } @@ -271,132 +736,255 @@ export class DeepLinkService implements Disposable { } break; - - case DeepLinkServiceState.CloneOrAddRepo: - if (!repoId || !remoteUrl) { + } + case DeepLinkServiceState.CloneOrAddRepo: { + if (!mainId && !remoteUrl && !repoPath) { action = DeepLinkServiceAction.DeepLinkErrored; - message = 'Missing repository id or remote url.'; + message = 'Missing repository id, remote url and path.'; break; } - repoOpenType = await this.showOpenTypePrompt(); + let chosenRepoPath: string | undefined; + let repoOpenType: DeepLinkRepoOpenType | undefined; + + if (matchingLocalRepoPaths.length > 0) { + chosenRepoPath = await window.showQuickPick( + [...matchingLocalRepoPaths, 'Choose a different location'], + { placeHolder: 'Matching repository found. Choose a location to open it.' }, + ); + + if (chosenRepoPath == null) { + action = DeepLinkServiceAction.DeepLinkCancelled; + break; + } else if (chosenRepoPath !== 'Choose a different location') { + this._context.repoOpenUri = Uri.file(chosenRepoPath); + repoOpenType = 'folder'; + } + } + + if (repoOpenType == null) { + repoOpenType = await this.showOpenTypePrompt({ + customMessage: + chosenRepoPath === 'Choose a different location' + ? 'Please choose an option to open the repository' + : undefined, + }); + } + if (!repoOpenType) { action = DeepLinkServiceAction.DeepLinkCancelled; break; } - repoOpenLocation = await this.showOpenLocationPrompt(repoOpenType); + const repoOpenLocation = await this.showOpenLocationPrompt(repoOpenType); if (!repoOpenLocation) { action = DeepLinkServiceAction.DeepLinkCancelled; break; } + this._context.repoOpenLocation = repoOpenLocation; + + if (this._context.repoOpenUri == null) { + this._context.repoOpenUri = ( + await window.showOpenDialog({ + title: `Choose a ${repoOpenType === 'workspace' ? 'workspace' : 'folder'} to ${ + repoOpenType === 'clone' ? 'clone the repository to' : 'open the repository' + }`, + canSelectFiles: repoOpenType === 'workspace', + canSelectFolders: repoOpenType !== 'workspace', + canSelectMany: false, + ...(repoOpenType === 'workspace' && { + filters: { Workspaces: ['code-workspace'] }, + }), + }) + )?.[0]; + } - // TODO@ramint Add cloning - repoOpenUri = ( - await window.showOpenDialog({ - title: `Open ${repoOpenType} for link`, - canSelectFiles: repoOpenType === DeepLinkRepoOpenType.Workspace, - canSelectFolders: repoOpenType === DeepLinkRepoOpenType.Folder, - canSelectMany: false, - ...(repoOpenType === DeepLinkRepoOpenType.Workspace && { - filters: { Workspaces: ['code-workspace'] }, - }), - }) - )?.[0]; - - if (!repoOpenUri) { + if (!this._context.repoOpenUri) { action = DeepLinkServiceAction.DeepLinkCancelled; break; } - if ( - repoOpenLocation === OpenWorkspaceLocation.AddToWorkspace && - (workspace.workspaceFolders?.length || 0) > 1 - ) { - action = DeepLinkServiceAction.OpenRepo; - } else { - // Deep link will resolve in a different service instance - await this.container.storage.store('deepLinks:pending', { - url: this._context.url, - repoPath: repoOpenUri.toString(), - }); - action = DeepLinkServiceAction.DeepLinkStored; - } - - openWorkspace(repoOpenUri, { location: repoOpenLocation }); - break; - - case DeepLinkServiceState.OpeningRepo: - queueMicrotask( - () => - void window.withProgress( + if (this._context.repoOpenUri != null && remoteUrl != null && repoOpenType === 'clone') { + // clone the repository, then set repoOpenUri to the repo path + let repoClonePath; + try { + repoClonePath = await window.withProgress( { - cancellable: true, location: ProgressLocation.Notification, - title: `Opening repository for link: ${url}`, + title: `Cloning repository for link: ${this._context.url}}`, }, - (progress, token) => { - return new Promise(resolve => { - token.onCancellationRequested(() => { - queueMicrotask(() => - this.processDeepLink(DeepLinkServiceAction.DeepLinkCancelled), - ); - resolve(); - }); - - this._disposables.push( - once(this.container.git.onDidChangeRepositories)(() => { - queueMicrotask(() => - this.processDeepLink(DeepLinkServiceAction.RepoAdded), - ); - resolve(); - }), - ); - }); - }, - ), - ); - return; + async () => + this.container.git.clone(remoteUrl, this._context.repoOpenUri?.fsPath ?? ''), + ); + } catch { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Unable to clone repository'; + break; + } + + if (!repoClonePath) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Unable to clone repository'; + break; + } + + this._context.repoOpenUri = Uri.file(repoClonePath); + } + + // Add the chosen repo as closed + const chosenRepo = await this.container.git.getOrOpenRepository(this._context.repoOpenUri, { + closeOnOpen: true, + detectNested: false, + }); + if (chosenRepo != null) { + this._context.repo = chosenRepo; + // Add the repo to the repo path mapping if it exists + if ( + repoOpenType !== 'current' && + repoOpenType !== 'workspace' && + !matchingLocalRepoPaths.includes(this._context.repoOpenUri.fsPath) + ) { + await this.container.repositoryPathMapping.writeLocalRepoPath( + { remoteUrl: remoteUrl }, + chosenRepo.uri.fsPath, + ); + } + } + + action = DeepLinkServiceAction.RepoAdded; + break; + } case DeepLinkServiceState.RemoteMatch: - if (!repo || !remoteUrl) { + case DeepLinkServiceState.EnsureRemoteMatch: { + if (repoPath && repo && !remoteUrl && !secondaryRemoteUrl) { + action = DeepLinkServiceAction.RemoteMatchUnneeded; + break; + } + + if (!repo || (!remoteUrl && !secondaryRemoteUrl)) { action = DeepLinkServiceAction.DeepLinkErrored; message = 'Missing repository or remote url.'; break; } - matchingRemotes = await repo.getRemotes({ filter: r => r.url === remoteUrl }); - if (matchingRemotes.length > 0) { - this._context.remote = matchingRemotes[0]; + if (remoteUrl && !remote) { + const matchingRemotes = await repo.getRemotes({ filter: r => r.url === remoteUrl }); + if (matchingRemotes.length > 0) { + this._context.remote = matchingRemotes[0]; + } + } + + if (secondaryRemoteUrl && !secondaryRemote) { + const matchingRemotes = await repo.getRemotes({ filter: r => r.url === secondaryRemoteUrl }); + if (matchingRemotes.length > 0) { + this._context.secondaryRemote = matchingRemotes[0]; + } + } + + if ( + (remoteUrl && !this._context.remote) || + (secondaryRemoteUrl && !this._context.secondaryRemote) + ) { + if (state === DeepLinkServiceState.RemoteMatch) { + action = DeepLinkServiceAction.RemoteMatchFailed; + } else { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'No matching remote found.'; + } + } else { action = DeepLinkServiceAction.RemoteMatched; + } + + break; + } + case DeepLinkServiceState.AddRemote: { + if (!repo || (!remoteUrl && !secondaryRemoteUrl)) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing repository or remote url.'; break; } - if (!this._context.remote) { - action = DeepLinkServiceAction.RemoteMatchFailed; + let remoteName: string | undefined; + let secondaryRemoteName: string | undefined; + + if (remoteUrl && !remote) { + remoteName = await this.showAddRemotePrompt( + remoteUrl, + (await repo.getRemotes()).map(r => r.name), + ); + + if (remoteName) { + try { + await repo.addRemote(remoteName, remoteUrl, { fetch: true }); + } catch { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Failed to add remote.'; + break; + } + + [this._context.remote] = await repo.getRemotes({ filter: r => r.url === remoteUrl }); + if (!this._context.remote) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Failed to add remote.'; + break; + } + } else { + action = DeepLinkServiceAction.DeepLinkCancelled; + break; + } } - break; + if (secondaryRemoteUrl && !secondaryRemote) { + secondaryRemoteName = await this.showAddRemotePrompt( + secondaryRemoteUrl, + (await repo.getRemotes()).map(r => r.name), + ); + + if (secondaryRemoteName) { + try { + await repo.addRemote(secondaryRemoteName, secondaryRemoteUrl, { fetch: true }); + } catch { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Failed to add remote.'; + break; + } + + [this._context.secondaryRemote] = await repo.getRemotes({ + filter: r => r.url === secondaryRemoteUrl, + }); + if (!this._context.secondaryRemote) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Failed to add remote.'; + break; + } + } else { + action = DeepLinkServiceAction.DeepLinkCancelled; + break; + } + } - case DeepLinkServiceState.AddRemote: - if (!repo || !remoteUrl) { + if (this._context.secondaryRemote && !this._context.remote) { + this._context.remote = this._context.secondaryRemote; + } + + if (!remoteName && !secondaryRemoteName) { + action = DeepLinkServiceAction.DeepLinkCancelled; + break; + } else if (!this._context.remote) { action = DeepLinkServiceAction.DeepLinkErrored; - message = 'Missing repository or remote url.'; + message = 'Failed to add remote.'; break; } - // TODO@ramint Instead of erroring here, prompt the user to add the remote, wait for the response, - // and then choose an action based on whether the remote is successfully added, of the user - // cancels, or if there is an error. - action = DeepLinkServiceAction.DeepLinkErrored; - message = 'No matching remote found.'; + action = DeepLinkServiceAction.RemoteAdded; break; - + } case DeepLinkServiceState.TargetMatch: - case DeepLinkServiceState.FetchedTargetMatch: - if (!repo || !remote || !targetType) { + case DeepLinkServiceState.FetchedTargetMatch: { + if (!repo || !targetType) { action = DeepLinkServiceAction.DeepLinkErrored; - message = 'Missing repository, remote, or target type.'; + message = 'Missing repository or target type.'; break; } @@ -405,35 +993,130 @@ export class DeepLinkService implements Disposable { break; } - this._context.targetSha = await this.getShaForTarget(); - if (!this._context.targetSha) { - if (state === DeepLinkServiceState.TargetMatch) { + if (targetType === DeepLinkType.Comparison) { + [this._context.targetSha, this._context.secondaryTargetSha] = + (await this.getShasForTargets()) ?? []; + } else if (targetType === DeepLinkType.File && targetId == null) { + action = DeepLinkServiceAction.TargetMatched; + break; + } else { + this._context.targetSha = (await this.getShasForTargets()) as string | undefined; + } + + if ( + this._context.targetSha == null || + (this._context.secondaryTargetSha == null && targetType === DeepLinkType.Comparison) + ) { + if (state === DeepLinkServiceState.TargetMatch && remote != null) { action = DeepLinkServiceAction.TargetMatchFailed; } else { action = DeepLinkServiceAction.DeepLinkErrored; - message = 'No matching target found.'; + message = `No matching ${targetSha == null ? 'target' : 'secondary target'} found.`; } break; } action = DeepLinkServiceAction.TargetMatched; break; - - case DeepLinkServiceState.Fetch: + } + case DeepLinkServiceState.Fetch: { if (!repo || !remote) { action = DeepLinkServiceAction.DeepLinkErrored; message = 'Missing repository or remote.'; break; } - // TODO@ramint Instead of erroring here, prompt the user to fetch, wait for the response, - // and then choose an action based on whether the fetch was successful, of the user - // cancels, or if there is an error. - action = DeepLinkServiceAction.DeepLinkErrored; - message = 'No matching target found.'; + if (!(await this.showFetchPrompt())) { + action = DeepLinkServiceAction.DeepLinkCancelled; + break; + } + + try { + await repo.fetch({ remote: remote.name, progress: true }); + } catch { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Error fetching remote.'; + break; + } + + if (secondaryRemote && secondaryRemote.name !== remote.name) { + try { + await repo.fetch({ remote: secondaryRemote.name, progress: true }); + } catch {} + } + + action = DeepLinkServiceAction.TargetFetched; break; + } + case DeepLinkServiceState.MaybeOpenRepo: { + if (repoOpenLocation != null && repoOpenUri != null) { + action = DeepLinkServiceAction.RepoOpening; + if (!(repoOpenLocation === 'addToWorkspace' && (workspace.workspaceFolders?.length || 0) > 1)) { + // Deep link will resolve in a different service instance + await this.container.storage.store('deepLinks:pending', { + url: this._context.url, + repoPath: repoOpenUri.toString(), + targetSha: this._context.targetSha, + secondaryTargetSha: this._context.secondaryTargetSha, + useProgress: useProgress, + }); + action = DeepLinkServiceAction.DeepLinkStored; + } - case DeepLinkServiceState.OpenGraph: + openWorkspace(repoOpenUri, { location: repoOpenLocation }); + } else { + action = DeepLinkServiceAction.RepoOpened; + } + break; + } + case DeepLinkServiceState.RepoOpening: { + this._disposables.push( + once(this.container.git.onDidChangeRepositories)(() => { + queueMicrotask(() => this.processDeepLink(DeepLinkServiceAction.RepoOpened)); + }), + ); + return; + } + case DeepLinkServiceState.GoToTarget: { + // Need to re-fetch the remotes in case we opened in a new window + + if (targetType === DeepLinkType.Repository) { + if ( + this._context.action === DeepLinkActionType.Switch || + this._context.action === DeepLinkActionType.SwitchToPullRequest || + this._context.action === DeepLinkActionType.SwitchToPullRequestWorktree || + this._context.action === DeepLinkActionType.SwitchToAndSuggestPullRequest + ) { + action = DeepLinkServiceAction.OpenSwitch; + } else { + action = DeepLinkServiceAction.OpenGraph; + } + break; + } + + switch (targetType) { + case DeepLinkType.File: + action = DeepLinkServiceAction.OpenFile; + break; + case DeepLinkType.Comparison: + action = DeepLinkServiceAction.OpenComparison; + break; + default: + if ( + this._context.action === DeepLinkActionType.Switch || + this._context.action === DeepLinkActionType.SwitchToPullRequest || + this._context.action === DeepLinkActionType.SwitchToPullRequestWorktree || + this._context.action === DeepLinkActionType.SwitchToAndSuggestPullRequest + ) { + action = DeepLinkServiceAction.OpenSwitch; + } else { + action = DeepLinkServiceAction.OpenGraph; + } + break; + } + break; + } + case DeepLinkServiceState.OpenGraph: { if (!repo || !targetType) { action = DeepLinkServiceAction.DeepLinkErrored; message = 'Missing repository or target type.'; @@ -453,89 +1136,465 @@ export class DeepLinkService implements Disposable { } void (await executeCommand(Commands.ShowInCommitGraph, { - ref: GitReference.create(targetSha, repo.path), + ref: createReference(targetSha, repo.path), })); action = DeepLinkServiceAction.DeepLinkResolved; break; + } + case DeepLinkServiceState.OpenComparison: { + if (!repo) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing repository.'; + break; + } - default: + if ( + targetId == null || + secondaryTargetId == null || + targetSha == null || + secondaryTargetSha == null + ) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing target or secondary target.'; + break; + } + + await this.container.searchAndCompareView.compare( + repo.path, + secondaryTargetId === '' || isSha(secondaryTargetId) + ? secondaryTargetId + : { label: secondaryTargetId, ref: secondaryTargetSha }, + targetId === '' || isSha(targetId) ? targetId : { label: targetId, ref: targetSha }, + ); + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } + case DeepLinkServiceState.OpenDraft: { + if (!targetId) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing cloud patch id.'; + break; + } + + const type = this._context.params?.get('type'); + let prEntityId = this._context.params?.get('prEntityId'); + if (prEntityId != null) { + prEntityId = fromBase64(prEntityId).toString(); + } + + void (await executeCommand(Commands.OpenCloudPatch, { + type: type === 'suggested_pr_change' ? 'code_suggestion' : 'patch', + id: targetId, + patchId: secondaryTargetId, + prEntityId: prEntityId, + })); + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } + case DeepLinkServiceState.OpenWorkspace: { + if (!mainId) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing workspace id.'; + break; + } + + await this.container.workspacesView.revealWorkspaceNode(mainId, { + select: true, + focus: true, + expand: true, + }); + + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } + case DeepLinkServiceState.OpenFile: { + if (filePath == null || !repo) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing file path.'; + break; + } + + let selection: Range | undefined; + if (secondaryTargetId != null) { + // secondary target id can be a single number or a range separated with a dash. If it's a single number, form a range from it. If it's a range, parse it. + const range = secondaryTargetId.split('-'); + if (range.length === 1) { + const lineNumber = parseInt(range[0]); + if (!isNaN(lineNumber)) { + selection = new Range(lineNumber < 1 ? 0 : lineNumber - 1, 0, lineNumber, 0); + } + } else if (range.length === 2) { + const startLineNumber = parseInt(range[0]); + const endLineNumber = parseInt(range[1]); + if (!isNaN(startLineNumber) && !isNaN(endLineNumber)) { + selection = new Range( + startLineNumber < 1 ? 0 : startLineNumber - 1, + 0, + endLineNumber, + 0, + ); + } + } + } + + if (targetSha == null) { + try { + await findOrOpenEditor(Uri.file(`${repo.path}/${filePath}`), { + preview: false, + selection: selection, + throwOnError: true, + }); + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } catch (ex) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = `Unable to open file${ex?.message ? `: ${ex.message}` : ''}`; + break; + } + } + + let revisionUri: Uri | undefined; + try { + revisionUri = this.container.git.getRevisionUri( + targetSha, + filePath, + repoPath ?? repo.uri.fsPath, + ); + } catch {} + if (revisionUri == null) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Unable to get revision uri.'; + break; + } + + try { + await openFileAtRevision(revisionUri, { + preview: false, + selection: selection, + }); + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } catch { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Unable to open file.'; + break; + } + } + case DeepLinkServiceState.SwitchToRef: { + if (!repo || !targetType || !targetId) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing repository or target type.'; + break; + } + + let skipSwitch = false; + if (targetType === DeepLinkType.Branch) { + // Check if the branch is already checked out. If so, we are done. + const currentBranch = await repo.getBranch(); + this._context.currentBranch = currentBranch?.name; + const targetBranch = await this.getBranch(targetId); + if ( + currentBranch != null && + targetBranch != null && + // TODO: When we create a new local branch during switch, it should set its upstream to the original remote branch target. + // Then this can be updated to just check the upstream of `currentBranch`. + currentBranch.getNameWithoutRemote() === targetBranch.getNameWithoutRemote() + ) { + skipSwitch = true; + } + } + + if (!skipSwitch) { + const ref = await this.getTargetRef(targetId); + if (ref == null) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Unable to find link target in the repository.'; + break; + } + + const pendingDeepLink = { + url: this._context.url, + repoPath: repo.path, + targetSha: this._context.targetSha, + secondaryTargetSha: this._context.secondaryTargetSha, + useProgress: useProgress, + state: this._context.state, + }; + + // Storing link info in case the switch causes a new window to open + const onWorkspaceChanging = async () => + this.container.storage.store('deepLinks:pending', pendingDeepLink); + + await executeGitCommand({ + command: 'switch', + state: { + repos: repo, + reference: ref, + onWorkspaceChanging: onWorkspaceChanging, + skipWorktreeConfirmations: + this._context.action === DeepLinkActionType.SwitchToPullRequestWorktree, + }, + }); + + // Only proceed if the branch switch occurred in the current window. This is necessary because the switch flow may + // open a new window, and if it does, we need to end things here. + const didChangeBranch = await Promise.race([ + new Promise(resolve => setTimeout(() => resolve(false), 10000)), + new Promise(resolve => + once(repo.onDidChange)(async (e: RepositoryChangeEvent) => { + if (e.changed(RepositoryChange.Head, RepositoryChangeComparisonMode.Any)) { + if ((await repo.getBranch())?.name !== this._context.currentBranch) { + resolve(true); + } else { + resolve(false); + } + } + }), + ), + ]); + + if (!didChangeBranch) { + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } + } + + if ( + this._context.action === DeepLinkActionType.SwitchToPullRequest || + this._context.action === DeepLinkActionType.SwitchToPullRequestWorktree || + this._context.action === DeepLinkActionType.SwitchToAndSuggestPullRequest + ) { + action = DeepLinkServiceAction.OpenInspect; + } else { + action = DeepLinkServiceAction.DeepLinkResolved; + } + break; + } + case DeepLinkServiceState.OpenInspect: { + // If we arrive at this step, clear any stored data used for the "new window" option + await this.container.storage.delete('deepLinks:pending'); + + if (!repo) { + action = DeepLinkServiceAction.DeepLinkErrored; + message = 'Missing repository.'; + break; + } + + await showInspectView({ + type: 'wip', + inReview: this._context.action === DeepLinkActionType.SwitchToAndSuggestPullRequest, + repository: repo, + source: 'launchpad', + } satisfies ShowWipArgs); + action = DeepLinkServiceAction.DeepLinkResolved; + break; + } + default: { action = DeepLinkServiceAction.DeepLinkErrored; message = 'Unknown state.'; break; + } } } } + async copyDeepLinkUrl(workspaceId: string): Promise; async copyDeepLinkUrl(ref: GitReference, remoteUrl: string): Promise; - async copyDeepLinkUrl(repoPath: string, remoteUrl: string): Promise; async copyDeepLinkUrl( repoPath: string, remoteUrl: string, - targetType: DeepLinkType, - targetId?: string, + compareRef?: StoredNamedRef, + compareWithRef?: StoredNamedRef, ): Promise; async copyDeepLinkUrl( - refOrRepoPath: string | GitReference, + refOrIdOrRepoPath: string | GitReference, + remoteUrl?: string, + compareRef?: StoredNamedRef, + compareWithRef?: StoredNamedRef, + ): Promise { + const url = await (typeof refOrIdOrRepoPath === 'string' + ? remoteUrl != null + ? this.generateDeepLinkUrl(refOrIdOrRepoPath, remoteUrl, compareRef, compareWithRef) + : this.generateDeepLinkUrl(refOrIdOrRepoPath) + : this.generateDeepLinkUrl(refOrIdOrRepoPath, remoteUrl!)); + await env.clipboard.writeText(url.toString()); + } + + async copyFileDeepLinkUrl( + repoPath: string, + filePath: string, remoteUrl: string, - targetType?: DeepLinkType, - targetId?: string, + lines?: number[], + ref?: GitReference, ): Promise { - const url = await (typeof refOrRepoPath !== 'string' - ? this.generateDeepLinkUrl(refOrRepoPath, remoteUrl) - : this.generateDeepLinkUrl(refOrRepoPath, remoteUrl, targetType!, targetId)); + const url = await this.generateFileDeepLinkUr(repoPath, filePath, remoteUrl, lines, ref); await env.clipboard.writeText(url.toString()); } + async generateDeepLinkUrl(workspaceId: string): Promise; async generateDeepLinkUrl(ref: GitReference, remoteUrl: string): Promise; - async generateDeepLinkUrl(repoPath: string, remoteUrl: string): Promise; async generateDeepLinkUrl( repoPath: string, remoteUrl: string, - targetType: DeepLinkType, - targetId?: string, + compareRef?: StoredNamedRef, + compareWithRef?: StoredNamedRef, ): Promise; async generateDeepLinkUrl( - refOrRepoPath: string | GitReference, - remoteUrl: string, - targetType?: DeepLinkType, - targetId?: string, + refOrIdOrRepoPath: string | GitReference, + remoteUrl?: string, + compareRef?: StoredNamedRef, + compareWithRef?: StoredNamedRef, ): Promise { - const repoPath = typeof refOrRepoPath !== 'string' ? refOrRepoPath.repoPath : refOrRepoPath; - const repoId = (await this.container.git.getUniqueRepositoryId(repoPath)) ?? '0'; + let targetType: DeepLinkType | undefined; + let targetId: string | undefined; + let compareWithTargetId: string | undefined; + const schemeOverride = configuration.get('deepLinks.schemeOverride'); + const scheme = !schemeOverride ? 'vscode' : schemeOverride === true ? env.uriScheme : schemeOverride; + let modePrefixString = ''; + if (this.container.env === 'dev') { + modePrefixString = 'dev.'; + } else if (this.container.env === 'staging') { + modePrefixString = 'staging.'; + } + + if (remoteUrl == null && typeof refOrIdOrRepoPath === 'string') { + const deepLinkRedirectUrl = new URL( + `https://${modePrefixString}gitkraken.dev/link/workspaces/${refOrIdOrRepoPath}`, + ); + deepLinkRedirectUrl.searchParams.set('origin', 'gitlens'); + return deepLinkRedirectUrl; + } - if (typeof refOrRepoPath !== 'string') { - switch (refOrRepoPath.refType) { + const repoPath = typeof refOrIdOrRepoPath !== 'string' ? refOrIdOrRepoPath.repoPath : refOrIdOrRepoPath; + const repoId = (await this.container.git.getUniqueRepositoryId(repoPath)) ?? missingRepositoryId; + + if (typeof refOrIdOrRepoPath !== 'string') { + switch (refOrIdOrRepoPath.refType) { case 'branch': targetType = DeepLinkType.Branch; - targetId = refOrRepoPath.remote - ? getBranchNameWithoutRemote(refOrRepoPath.name) - : refOrRepoPath.name; + targetId = refOrIdOrRepoPath.remote + ? getBranchNameWithoutRemote(refOrIdOrRepoPath.name) + : refOrIdOrRepoPath.name; break; case 'revision': targetType = DeepLinkType.Commit; - targetId = refOrRepoPath.ref; + targetId = refOrIdOrRepoPath.ref; break; case 'tag': targetType = DeepLinkType.Tag; - targetId = refOrRepoPath.name; + targetId = refOrIdOrRepoPath.name; break; } } + if (compareRef != null && compareWithRef != null) { + targetType = DeepLinkType.Comparison; + targetId = compareRef.label ?? compareRef.ref; + compareWithTargetId = compareWithRef.label ?? compareWithRef.ref; + } + + let target; + if (targetType === DeepLinkType.Comparison) { + target = `/${targetType}/${compareWithTargetId}...${targetId}`; + } else if (targetType != null && targetType !== DeepLinkType.Repository) { + target = `/${targetType}/${targetId}`; + } else { + target = ''; + } + + // Start with the prefix, add the repo prefix and the repo ID to the URL, and then add the target tag and target ID to the URL (if applicable) + const deepLink = new URL( + `${scheme}://${this.container.context.extension.id}/${'link' satisfies UriTypes}/${ + DeepLinkType.Repository + }/${repoId}${target}`, + ); + + if (remoteUrl != null) { + // Add the remote URL as a query parameter + deepLink.searchParams.set('url', remoteUrl); + } + + const deepLinkRedirectUrl = new URL( + `https://${modePrefixString}gitkraken.dev/link/${encodeURIComponent( + Buffer.from(deepLink.href).toString('base64'), + )}`, + ); + + deepLinkRedirectUrl.searchParams.set('origin', 'gitlens'); + return deepLinkRedirectUrl; + } + + async generateFileDeepLinkUr( + repoPath: string, + filePath: string, + remoteUrl: string, + lines?: number[], + ref?: GitReference, + ): Promise { + const targetType = DeepLinkType.File; + const targetId = filePath; const schemeOverride = configuration.get('deepLinks.schemeOverride'); const scheme = !schemeOverride ? 'vscode' : schemeOverride === true ? env.uriScheme : schemeOverride; - const target = targetType != null && targetType !== DeepLinkType.Repository ? `/${targetType}/${targetId}` : ''; + let modePrefixString = ''; + if (this.container.env === 'dev') { + modePrefixString = 'dev.'; + } else if (this.container.env === 'staging') { + modePrefixString = 'staging.'; + } - // Start with the prefix, add the repo prefix and the repo ID to the URL, and then add the target tag and target ID to the URL (if applicable) - const url = new URL( - `${scheme}://${this.container.context.extension.id}/${UriTypes.DeepLink}/${DeepLinkType.Repository}/${repoId}${target}`, + const repoId = (await this.container.git.getUniqueRepositoryId(repoPath)) ?? missingRepositoryId; + let linesString = ''; + if (lines != null) { + if (lines.length === 1) { + linesString = `${lines[0]}`; + } else if (lines.length === 2) { + if (lines[0] === lines[1]) { + linesString = `${lines[0]}`; + } else if (lines[0] < lines[1]) { + linesString = `${lines[0]}-${lines[1]}`; + } + } + } + + const deepLink = new URL( + `${scheme}://${this.container.context.extension.id}/${'link' satisfies UriTypes}/${ + DeepLinkType.Repository + }/${repoId}/${targetType}/${targetId}`, + ); + + deepLink.searchParams.set('url', remoteUrl); + if (linesString !== '') { + deepLink.searchParams.set('lines', linesString); + } + + if (ref != null) { + switch (ref.refType) { + case 'branch': + deepLink.searchParams.set('ref', ref.name); + break; + case 'revision': + deepLink.searchParams.set('ref', ref.ref); + break; + case 'tag': + deepLink.searchParams.set('ref', ref.name); + break; + } + } + + const deepLinkRedirectUrl = new URL( + `https://${modePrefixString}gitkraken.dev/link/${encodeURIComponent( + Buffer.from(deepLink.href).toString('base64'), + )}`, ); - // Add the remote URL as a query parameter - url.searchParams.set('url', remoteUrl); - const params = new URLSearchParams(); - params.set('url', remoteUrl); - return url; + deepLinkRedirectUrl.searchParams.set('origin', 'gitlens'); + return deepLinkRedirectUrl; } } + +export function getMaybeRemoteNameFromRemoteUrl(remoteUrl: string): string | undefined { + const remoteUrlParts = remoteUrl.split('/'); + if (remoteUrlParts.length < 3) return undefined; + return remoteUrlParts[remoteUrlParts.length - 2]; +} diff --git a/src/uris/uriService.ts b/src/uris/uriService.ts index a932738dee82f..e43d2c92f784c 100644 --- a/src/uris/uriService.ts +++ b/src/uris/uriService.ts @@ -1,7 +1,9 @@ import type { Disposable, Event, Uri, UriHandler } from 'vscode'; import { EventEmitter, window } from 'vscode'; import type { Container } from '../container'; -import { AuthenticationUriPathPrefix } from '../plus/subscription/serverConnection'; +import { AuthenticationUriPathPrefix, LoginUriPathPrefix } from '../plus/gk/account/authenticationConnection'; +import { SubscriptionUpdatedUriPathPrefix } from '../plus/gk/account/subscription'; +import { CloudIntegrationAuthenticationUriPathPrefix } from '../plus/integrations/authentication/models'; import { log } from '../system/decorators/log'; // This service is in charge of registering a URI handler and handling/emitting URI events received by GitLens. @@ -11,10 +13,26 @@ export class UriService implements Disposable, UriHandler { private _disposable: Disposable; private _onDidReceiveAuthenticationUri: EventEmitter = new EventEmitter(); + private _onDidReceiveLoginUri: EventEmitter = new EventEmitter(); + private _onDidReceiveCloudIntegrationAuthenticationUri: EventEmitter = new EventEmitter(); + private _onDidReceiveSubscriptionUpdatedUri: EventEmitter = new EventEmitter(); + get onDidReceiveAuthenticationUri(): Event { return this._onDidReceiveAuthenticationUri.event; } + get onDidReceiveLoginUri(): Event { + return this._onDidReceiveLoginUri.event; + } + + get onDidReceiveCloudIntegrationAuthenticationUri(): Event { + return this._onDidReceiveCloudIntegrationAuthenticationUri.event; + } + + get onDidReceiveSubscriptionUpdatedUri(): Event { + return this._onDidReceiveSubscriptionUpdatedUri.event; + } + private _onDidReceiveUri: EventEmitter = new EventEmitter(); get onDidReceiveUri(): Event { return this._onDidReceiveUri.event; @@ -34,6 +52,15 @@ export class UriService implements Disposable, UriHandler { if (type === AuthenticationUriPathPrefix) { this._onDidReceiveAuthenticationUri.fire(uri); return; + } else if (type === CloudIntegrationAuthenticationUriPathPrefix) { + this._onDidReceiveCloudIntegrationAuthenticationUri.fire(uri); + return; + } else if (type === SubscriptionUpdatedUriPathPrefix) { + this._onDidReceiveSubscriptionUpdatedUri.fire(uri); + return; + } else if (type === LoginUriPathPrefix) { + this._onDidReceiveLoginUri.fire(uri); + return; } this._onDidReceiveUri.fire(uri); diff --git a/src/views/branchesView.ts b/src/views/branchesView.ts index e40f9ecfa8319..48357a5cb2cfc 100644 --- a/src/views/branchesView.ts +++ b/src/views/branchesView.ts @@ -1,24 +1,25 @@ import type { CancellationToken, ConfigurationChangeEvent, Disposable } from 'vscode'; import { ProgressLocation, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; -import type { BranchesViewConfig } from '../configuration'; -import { configuration, ViewBranchesLayout, ViewFilesLayout, ViewShowBranchComparison } from '../configuration'; -import { Commands } from '../constants'; +import type { BranchesViewConfig, ViewBranchesLayout, ViewFilesLayout } from '../config'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import type { GitCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; import type { GitBranchReference, GitRevisionReference } from '../git/models/reference'; -import { GitReference } from '../git/models/reference'; -import type { RepositoryChangeEvent } from '../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; -import { executeCommand } from '../system/command'; +import { getReferenceLabel } from '../git/models/reference'; +import type { Repository, RepositoryChangeEvent } from '../git/models/repository'; +import { groupRepositories, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; +import { getWorktreesByBranch } from '../git/models/worktree'; import { gate } from '../system/decorators/gate'; +import { executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { BranchesNode } from './nodes/branchesNode'; import { BranchNode } from './nodes/branchNode'; import { BranchOrTagFolderNode } from './nodes/branchOrTagFolderNode'; -import { RepositoryNode } from './nodes/repositoryNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; @@ -48,15 +49,30 @@ export class BranchesRepositoryNode extends RepositoryFolderNode { async getChildren(): Promise { if (this.children == null) { - const repositories = this.view.container.git.openRepositories; + let grouped: Map> | undefined; + + let repositories = this.view.container.git.openRepositories; + if (configuration.get('views.collapseWorktreesWhenPossible')) { + grouped = await groupRepositories(repositories); + repositories = [...grouped.keys()]; + } + if (repositories.length === 0) { - this.view.message = 'No branches could be found.'; + this.view.message = this.view.container.git.isDiscoveringRepositories + ? 'Loading branches...' + : 'No branches could be found.'; return []; } this.view.message = undefined; + // Get all the worktree branches (and track if they are opened) to pass along downstream, e.g. in the BranchNode to display an indicator + const worktreesByBranch = await getWorktreesByBranch(repositories, { includeDefault: true }); + this.updateContext({ + worktreesByBranch: worktreesByBranch?.size ? worktreesByBranch : undefined, + }); + const splat = repositories.length === 1; this.children = repositories.map( r => new BranchesRepositoryNode(GitUri.fromRepoPath(r.path), this.view, this, r, splat), @@ -93,17 +109,21 @@ export class BranchesViewNode extends RepositoriesSubscribeableNode { +export class BranchesView extends ViewBase<'branches', BranchesViewNode, BranchesViewConfig> { protected readonly configKey = 'branches'; constructor(container: Container) { - super(container, 'gitlens.views.branches', 'Branches', 'branchesView'); + super(container, 'branches', 'Branches', 'branchesView'); } override get canReveal(): boolean { return this.config.reveal || !configuration.get('views.repositories.showBranches'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + protected getRoot() { return new BranchesViewNode(this); } @@ -125,29 +145,21 @@ export class BranchesView extends ViewBase }, this, ), - registerViewCommand( - this.getQualifiedCommand('setLayoutToList'), - () => this.setLayout(ViewBranchesLayout.List), - this, - ), - registerViewCommand( - this.getQualifiedCommand('setLayoutToTree'), - () => this.setLayout(ViewBranchesLayout.Tree), - this, - ), + registerViewCommand(this.getQualifiedCommand('setLayoutToList'), () => this.setLayout('list'), this), + registerViewCommand(this.getQualifiedCommand('setLayoutToTree'), () => this.setLayout('tree'), this), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToAuto'), - () => this.setFilesLayout(ViewFilesLayout.Auto), + () => this.setFilesLayout('auto'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToList'), - () => this.setFilesLayout(ViewFilesLayout.List), + () => this.setFilesLayout('list'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToTree'), - () => this.setFilesLayout(ViewFilesLayout.Tree), + () => this.setFilesLayout('tree'), this, ), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), @@ -186,7 +198,8 @@ export class BranchesView extends ViewBase !configuration.changed(e, 'defaultDateStyle') && !configuration.changed(e, 'defaultGravatarsStyle') && !configuration.changed(e, 'defaultTimeFormat') && - !configuration.changed(e, 'sortBranchesBy') + !configuration.changed(e, 'sortBranchesBy') && + !configuration.changed(e, 'sortRepositoriesBy') ) { return false; } @@ -197,7 +210,7 @@ export class BranchesView extends ViewBase findBranch(branch: GitBranchReference, token?: CancellationToken) { if (branch.remote) return undefined; - const repoNodeId = RepositoryNode.getId(branch.repoPath); + const { repoPath } = branch; return this.findNode((n: any) => n.branch?.ref === branch.ref, { allowPaging: true, @@ -206,7 +219,7 @@ export class BranchesView extends ViewBase if (n instanceof BranchesViewNode) return true; if (n instanceof BranchesRepositoryNode || n instanceof BranchOrTagFolderNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -216,12 +229,13 @@ export class BranchesView extends ViewBase } async findCommit(commit: GitCommit | { repoPath: string; ref: string }, token?: CancellationToken) { - const repoNodeId = RepositoryNode.getId(commit.repoPath); + const { repoPath } = commit; // Get all the branches the commit is on const branches = await this.container.git.getCommitBranches( commit.repoPath, commit.ref, + undefined, isCommit(commit) ? { commitDate: commit.committer.date } : undefined, ); if (branches.length === 0) return undefined; @@ -233,10 +247,10 @@ export class BranchesView extends ViewBase if (n instanceof BranchesViewNode) return true; if (n instanceof BranchesRepositoryNode || n instanceof BranchOrTagFolderNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } - if (n instanceof BranchNode && branches.includes(n.branch.name)) { + if (n instanceof BranchNode && n.repoPath === repoPath && branches.includes(n.branch.name)) { await n.loadMore({ until: commit.ref }); return true; } @@ -259,10 +273,13 @@ export class BranchesView extends ViewBase return window.withProgress( { location: ProgressLocation.Notification, - title: `Revealing ${GitReference.toString(branch, { icon: false, quoted: true })} in the side bar...`, + title: `Revealing ${getReferenceLabel(branch, { + icon: false, + quoted: true, + })} in the side bar...`, cancellable: true, }, - async (progress, token) => { + async (_progress, token) => { const node = await this.findBranch(branch, token); if (node == null) return undefined; @@ -285,10 +302,13 @@ export class BranchesView extends ViewBase return window.withProgress( { location: ProgressLocation.Notification, - title: `Revealing ${GitReference.toString(commit, { icon: false, quoted: true })} in the side bar...`, + title: `Revealing ${getReferenceLabel(commit, { + icon: false, + quoted: true, + })} in the side bar...`, cancellable: true, }, - async (progress, token) => { + async (_progress, token) => { const node = await this.findCommit(commit, token); if (node == null) return undefined; @@ -304,7 +324,7 @@ export class BranchesView extends ViewBase repoPath: string, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, ) { - const node = await this.findNode(RepositoryFolderNode.getId(repoPath), { + const node = await this.findNode(n => n instanceof RepositoryFolderNode && n.repoPath === repoPath, { maxDepth: 1, canTraverse: n => n instanceof BranchesViewNode || n instanceof RepositoryFolderNode, }); @@ -331,7 +351,7 @@ export class BranchesView extends ViewBase private setShowBranchComparison(enabled: boolean) { return configuration.updateEffective( `views.${this.configKey}.showBranchComparison` as const, - enabled ? ViewShowBranchComparison.Branch : false, + enabled ? 'branch' : false, ); } diff --git a/src/views/commitsView.ts b/src/views/commitsView.ts index f53c1feed0861..7d17bbd9af2c7 100644 --- a/src/views/commitsView.ts +++ b/src/views/commitsView.ts @@ -1,36 +1,33 @@ -import type { - CancellationToken, - ConfigurationChangeEvent, - TreeViewSelectionChangeEvent, - TreeViewVisibilityChangeEvent, -} from 'vscode'; +import type { CancellationToken, ConfigurationChangeEvent } from 'vscode'; import { Disposable, ProgressLocation, ThemeIcon, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; -import type { CommitsViewConfig } from '../configuration'; -import { configuration, ViewFilesLayout, ViewShowBranchComparison } from '../configuration'; -import { Commands, ContextKeys, GlyphChars } from '../constants'; +import type { CommitsViewConfig, ViewFilesLayout } from '../config'; +import { GlyphChars } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { setContext } from '../context'; import { GitUri } from '../git/gitUri'; import type { GitCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; +import { matchContributor } from '../git/models/contributor'; import type { GitRevisionReference } from '../git/models/reference'; -import { GitReference } from '../git/models/reference'; +import { getReferenceLabel } from '../git/models/reference'; import type { RepositoryChangeEvent } from '../git/models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; -import { executeCommand } from '../system/command'; +import type { GitUser } from '../git/models/user'; +import { showContributorsPicker } from '../quickpicks/contributorsPicker'; +import { getRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; import { gate } from '../system/decorators/gate'; import { debug } from '../system/decorators/log'; import { disposableInterval } from '../system/function'; -import type { UsageChangeEvent } from '../usageTracker'; +import { createCommand, executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; +import type { UsageChangeEvent } from '../telemetry/usageTracker'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { BranchNode } from './nodes/branchNode'; import { BranchTrackingStatusNode } from './nodes/branchTrackingStatusNode'; -import { CommitFileNode } from './nodes/commitFileNode'; -import { CommitNode } from './nodes/commitNode'; import { CommandMessageNode } from './nodes/common'; -import { FileRevisionAsCommitNode } from './nodes/fileRevisionAsCommitNode'; -import { RepositoryNode } from './nodes/repositoryNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; @@ -46,22 +43,24 @@ export class CommitsRepositoryNode extends RepositoryFolderNode; + hideMergeCommits?: boolean; } -export class CommitsView extends ViewBase { +export class CommitsView extends ViewBase<'commits', CommitsViewNode, CommitsViewConfig> { protected readonly configKey = 'commits'; constructor(container: Container) { - super(container, 'gitlens.views.commits', 'Commits', 'commitsView'); + super(container, 'commits', 'Commits', 'commitsView'); this.disposables.push(container.usage.onDidChange(this.onUsageChanged, this)); } private onUsageChanged(e: UsageChangeEvent | void) { // Refresh the view if the graph usage state has changed, since we render a node for it before the first use - if (e == null || e.key === 'graphWebview:shown') { + if (e == null || e.key === 'graphView:shown' || e.key === 'graphWebview:shown') { void this.refresh(); } } @@ -201,7 +205,11 @@ export class CommitsView extends ViewBase { return this.config.reveal || !configuration.get('views.repositories.showCommits'); } - private readonly _state: CommitsViewState = {}; + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + + private readonly _state: CommitsViewState = { filterCommits: new Map() }; get state(): CommitsViewState { return this._state; } @@ -229,29 +237,40 @@ export class CommitsView extends ViewBase { ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToAuto'), - () => this.setFilesLayout(ViewFilesLayout.Auto), + () => this.setFilesLayout('auto'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToList'), - () => this.setFilesLayout(ViewFilesLayout.List), + () => this.setFilesLayout('list'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToTree'), - () => this.setFilesLayout(ViewFilesLayout.Tree), + () => this.setFilesLayout('tree'), this, ), registerViewCommand( - this.getQualifiedCommand('setMyCommitsOnlyOn'), - () => this.setMyCommitsOnly(true), + this.getQualifiedCommand('setCommitsFilterAuthors'), + n => this.setCommitsFilter(n, true), this, ), registerViewCommand( - this.getQualifiedCommand('setMyCommitsOnlyOff'), - () => this.setMyCommitsOnly(false), + this.getQualifiedCommand('setCommitsFilterOff'), + n => this.setCommitsFilter(n, false), this, ), + registerViewCommand( + this.getQualifiedCommand('setShowMergeCommitsOn'), + () => this.setShowMergeCommits(true), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setShowMergeCommitsOff'), + () => this.setShowMergeCommits(false), + this, + ), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), registerViewCommand( @@ -288,7 +307,8 @@ export class CommitsView extends ViewBase { !configuration.changed(e, 'defaultDateStyle') && !configuration.changed(e, 'defaultGravatarsStyle') && !configuration.changed(e, 'defaultTimeFormat') && - !configuration.changed(e, 'plusFeatures.enabled') + !configuration.changed(e, 'plusFeatures.enabled') && + !configuration.changed(e, 'sortRepositoriesBy') ) { return false; } @@ -296,58 +316,14 @@ export class CommitsView extends ViewBase { return true; } - protected override onSelectionChanged(e: TreeViewSelectionChangeEvent) { - super.onSelectionChanged(e); - this.notifySelections(); - } - - protected override onVisibilityChanged(e: TreeViewVisibilityChangeEvent) { - super.onVisibilityChanged(e); - - if (e.visible) { - this.notifySelections(); - } - } - - private notifySelections() { - const node = this.selection?.[0]; - if (node == null) return; - - if (node instanceof CommitNode || node instanceof FileRevisionAsCommitNode || node instanceof CommitFileNode) { - this.container.events.fire( - 'commit:selected', - { - commit: node.commit, - pin: false, - preserveFocus: true, - preserveVisibility: true, - }, - { source: this.id }, - ); - } - - if (node instanceof FileRevisionAsCommitNode || node instanceof CommitFileNode) { - this.container.events.fire( - 'file:selected', - { - uri: node.uri, - preserveFocus: true, - preserveVisibility: true, - }, - { source: this.id }, - ); - } - } - async findCommit(commit: GitCommit | { repoPath: string; ref: string }, token?: CancellationToken) { - const repoNodeId = RepositoryNode.getId(commit.repoPath); + const { repoPath } = commit; const branch = await this.container.git.getBranch(commit.repoPath); if (branch == null) return undefined; // Check if the commit exists on the current branch - const branches = await this.container.git.getCommitBranches(commit.repoPath, commit.ref, { - branch: branch.name, + const branches = await this.container.git.getCommitBranches(commit.repoPath, commit.ref, branch.name, { commitDate: isCommit(commit) ? commit.committer.date : undefined, }); if (!branches.length) return undefined; @@ -369,7 +345,7 @@ export class CommitsView extends ViewBase { } if (n instanceof CommitsRepositoryNode) { - if (n.id.startsWith(repoNodeId)) { + if (n.repoPath === repoPath) { const node = await n.getSplattedChild?.(); if (node instanceof BranchNode) { await node.loadMore({ until: commit.ref }); @@ -379,7 +355,7 @@ export class CommitsView extends ViewBase { } if (n instanceof BranchTrackingStatusNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -400,10 +376,13 @@ export class CommitsView extends ViewBase { return window.withProgress( { location: ProgressLocation.Notification, - title: `Revealing ${GitReference.toString(commit, { icon: false, quoted: true })} in the side bar...`, + title: `Revealing ${getReferenceLabel(commit, { + icon: false, + quoted: true, + })} in the side bar...`, cancellable: true, }, - async (progress, token) => { + async (_progress, token) => { const node = await this.findCommit(commit, token); if (node == null) return undefined; @@ -419,7 +398,7 @@ export class CommitsView extends ViewBase { repoPath: string, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, ) { - const node = await this.findNode(RepositoryFolderNode.getId(repoPath), { + const node = await this.findNode(n => n instanceof RepositoryFolderNode && n.repoPath === repoPath, { maxDepth: 1, canTraverse: n => n instanceof CommitsViewNode || n instanceof RepositoryFolderNode, }); @@ -435,9 +414,66 @@ export class CommitsView extends ViewBase { return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout); } - private setMyCommitsOnly(enabled: boolean) { - void setContext(ContextKeys.ViewsCommitsMyCommitsOnly, enabled); - this.state.myCommitsOnly = enabled; + private async setCommitsFilter(node: ViewNode, filter: boolean) { + let repo; + if (node != null) { + if (node.is('repo-folder')) { + repo = node.repo; + } else { + let parent: ViewNode | undefined = node; + do { + parent = parent.getParent(); + if (parent?.is('repo-folder')) { + repo = parent.repo; + break; + } + } while (parent != null); + } + } + + if (filter) { + repo ??= await getRepositoryOrShowPicker('Filter Commits', 'Choose a repository'); + if (repo == null) return; + + let authors = this.state.filterCommits.get(repo.id); + if (authors == null) { + const current = await this.container.git.getCurrentUser(repo.uri); + authors = current != null ? [current] : undefined; + } + + const result = await showContributorsPicker( + this.container, + repo, + 'Filter Commits', + repo.virtual ? 'Choose a contributor to show commits from' : 'Choose contributors to show commits from', + { + appendReposToTitle: true, + clearButton: true, + multiselect: !repo.virtual, + picked: c => authors?.some(u => matchContributor(c, u)) ?? false, + }, + ); + if (result == null) return; + + if (result.length === 0) { + filter = false; + this.state.filterCommits.delete(repo.id); + } else { + this.state.filterCommits.set(repo.id, result); + } + } else if (repo != null) { + this.state.filterCommits.delete(repo.id); + } else { + this.state.filterCommits.clear(); + } + + void setContext('gitlens:views:commits:filtered', this.state.filterCommits.size !== 0); + void this.refresh(true); + } + + private setShowMergeCommits(on: boolean) { + void setContext('gitlens:views:commits:hideMergeCommits', !on); + this.state.hideMergeCommits = !on; void this.refresh(true); } @@ -448,7 +484,7 @@ export class CommitsView extends ViewBase { private setShowBranchComparison(enabled: boolean) { return configuration.updateEffective( `views.${this.configKey}.showBranchComparison` as const, - enabled ? ViewShowBranchComparison.Working : false, + enabled ? 'working' : false, ); } diff --git a/src/views/contributorsView.ts b/src/views/contributorsView.ts index 30d424ceeb2c6..409e6e2d8f264 100644 --- a/src/views/contributorsView.ts +++ b/src/views/contributorsView.ts @@ -1,29 +1,32 @@ import type { CancellationToken, ConfigurationChangeEvent } from 'vscode'; import { Disposable, ProgressLocation, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; -import { Avatars } from '../avatars'; -import type { ContributorsViewConfig } from '../configuration'; -import { configuration, ViewFilesLayout } from '../configuration'; -import { Commands } from '../constants'; +import { onDidFetchAvatar } from '../avatars'; +import type { ContributorsViewConfig, ViewFilesLayout } from '../config'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import type { GitContributor } from '../git/models/contributor'; import type { RepositoryChangeEvent } from '../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; -import { executeCommand } from '../system/command'; +import { groupRepositories, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import { gate } from '../system/decorators/gate'; import { debug } from '../system/decorators/log'; +import { executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { ContributorNode } from './nodes/contributorNode'; import { ContributorsNode } from './nodes/contributorsNode'; -import { RepositoryNode } from './nodes/repositoryNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; export class ContributorsRepositoryNode extends RepositoryFolderNode { async getChildren(): Promise { if (this.child == null) { - this.child = new ContributorsNode(this.uri, this.view, this, this.repo); + this.child = new ContributorsNode(this.uri, this.view, this, this.repo, { + showMergeCommits: !this.view.state.hideMergeCommits, + }); } return this.child.getChildren(); @@ -33,7 +36,7 @@ export class ContributorsRepositoryNode extends RepositoryFolderNode this.child?.updateAvatar(e.email)), + onDidFetchAvatar(e => this.child?.updateAvatar(e.email)), ); } @@ -51,9 +54,19 @@ export class ContributorsRepositoryNode extends RepositoryFolderNode { async getChildren(): Promise { if (this.children == null) { - const repositories = this.view.container.git.openRepositories; + let repositories = this.view.container.git.openRepositories; + if ( + configuration.get('views.collapseWorktreesWhenPossible') && + configuration.get('views.contributors.showAllBranches') + ) { + const grouped = await groupRepositories(repositories); + repositories = [...grouped.keys()]; + } + if (repositories.length === 0) { - this.view.message = 'No contributors could be found.'; + this.view.message = this.view.container.git.isDiscoveringRepositories + ? 'Loading contributors...' + : 'No contributors could be found.'; return []; } @@ -111,17 +124,32 @@ export class ContributorsViewNode extends RepositoriesSubscribeableNode { +interface ContributorsViewState { + hideMergeCommits?: boolean; +} + +export class ContributorsView extends ViewBase<'contributors', ContributorsViewNode, ContributorsViewConfig> { protected readonly configKey = 'contributors'; constructor(container: Container) { - super(container, 'gitlens.views.contributors', 'Contributors', 'contributorsView'); + super(container, 'contributors', 'Contributors', 'contributorsView'); + + void setContext('gitlens:views:contributors:hideMergeCommits', true); } override get canReveal(): boolean { return this.config.reveal || !configuration.get('views.repositories.showContributors'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + + private readonly _state: ContributorsViewState = { hideMergeCommits: true }; + get state(): ContributorsViewState { + return this._state; + } + protected getRoot() { return new ContributorsViewNode(this); } @@ -145,17 +173,17 @@ export class ContributorsView extends ViewBase this.setFilesLayout(ViewFilesLayout.Auto), + () => this.setFilesLayout('auto'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToList'), - () => this.setFilesLayout(ViewFilesLayout.List), + () => this.setFilesLayout('list'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToTree'), - () => this.setFilesLayout(ViewFilesLayout.Tree), + () => this.setFilesLayout('tree'), this, ), @@ -170,6 +198,17 @@ export class ContributorsView extends ViewBase this.setShowMergeCommits(true), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setShowMergeCommitsOff'), + () => this.setShowMergeCommits(false), + this, + ), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), @@ -197,7 +236,9 @@ export class ContributorsView extends ViewBase + n instanceof ContributorNode && + n.contributor.username === username && + n.contributor.email === email && + n.contributor.name === name, { maxDepth: 2, canTraverse: n => { if (n instanceof ContributorsViewNode) return true; if (n instanceof ContributorsRepositoryNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -231,7 +276,7 @@ export class ContributorsView extends ViewBase n instanceof RepositoryFolderNode && n.repoPath === repoPath, { maxDepth: 1, canTraverse: n => n instanceof ContributorsViewNode || n instanceof RepositoryFolderNode, }); @@ -258,7 +303,7 @@ export class ContributorsView extends ViewBase { + async (_progress, token) => { const node = await this.findContributor(contributor, token); if (node == null) return undefined; @@ -277,6 +322,12 @@ export class ContributorsView extends ViewBase { + constructor(view: DraftsView) { + super('drafts', unknownGitUri, view); + } + + async getChildren(): Promise<(GroupingNode | DraftNode)[]> { + if (this.children == null) { + const children: (GroupingNode | DraftNode)[] = []; + + try { + const drafts = await this.view.container.drafts.getDrafts(); + drafts?.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + + const groups = groupByFilterMap( + drafts, + this.calcDraftGroupKey.bind(this), + d => new DraftNode(this.uri, this.view, this, d), + ); + + const mine = groups.get('mine'); + const shared = groups.get('shared'); + const isFlat = mine?.length && !shared?.length; + + if (!isFlat) { + if (mine?.length) { + children.push(new GroupingNode(this.view, 'Created by Me', mine)); + } + if (shared?.length) { + children.push(new GroupingNode(this.view, 'Shared with Me', shared)); + } + } else { + children.push(...mine); + } + } catch (ex) { + if (!(ex instanceof AuthenticationRequiredError)) throw ex; + } + + this.children = children; + } + + return this.children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Drafts', TreeItemCollapsibleState.Expanded); + return item; + } + + private calcDraftGroupKey(d: Draft): DraftGroupKey { + if (d.type === 'suggested_pr_change') { + return 'pr_suggestion'; + } + return d.isMine ? 'mine' : 'shared'; + } +} + +type DraftGroupKey = 'pr_suggestion' | 'mine' | 'shared'; + +export class DraftsView extends ViewBase<'drafts', DraftsViewNode, DraftsViewConfig> { + protected readonly configKey = 'drafts'; + private _disposable: Disposable | undefined; + + constructor(container: Container) { + super(container, 'drafts', 'Cloud Patches', 'draftsView'); + + this.description = previewBadge; + } + + override dispose() { + this._disposable?.dispose(); + super.dispose(); + } + + protected getRoot() { + return new DraftsViewNode(this); + } + + protected override onVisibilityChanged(e: TreeViewVisibilityChangeEvent): void { + if (this._disposable == null) { + this._disposable = Disposable.from(this.container.subscription.onDidChange(() => this.refresh(true), this)); + } + + super.onVisibilityChanged(e); + } + + override async show(options?: { preserveFocus?: boolean | undefined }): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + + return super.show(options); + } + + override get canReveal(): boolean { + return false; + } + + protected registerCommands(): Disposable[] { + void this.container.viewCommands; + + return [ + registerViewCommand( + this.getQualifiedCommand('info'), + () => + executeCommand(Commands.OpenWalkthrough, { + step: 'code-collab', + source: 'cloud-patches', + detail: 'info', + }), + this, + ), + registerViewCommand( + this.getQualifiedCommand('copy'), + () => executeCommand(Commands.ViewsCopy, this.activeSelection, this.selection), + this, + ), + registerViewCommand(this.getQualifiedCommand('refresh'), () => this.refresh(true), this), + registerViewCommand( + this.getQualifiedCommand('create'), + async () => { + await executeCommand(Commands.CreateCloudPatch); + void this.ensureRoot().triggerChange(true); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('delete'), + async (node: DraftNode) => { + const confirm = { title: 'Delete' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showInformationMessage( + `Are you sure you want to delete Cloud Patch '${node.draft.title}'?`, + { modal: true }, + confirm, + cancel, + ); + + if (result === confirm) { + await this.container.drafts.deleteDraft(node.draft.id); + void node.getParent()?.triggerChange(true); + } + }, + this, + ), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), + ]; + } + + async findDraft(draft: Draft, cancellation?: CancellationToken) { + return this.findNode((n: any) => n.draft?.id === draft.id, { + allowPaging: false, + maxDepth: 2, + canTraverse: n => { + if (n instanceof DraftsViewNode || n instanceof GroupingNode) return true; + + return false; + }, + token: cancellation, + }); + } + + @gate(() => '') + async revealDraft( + draft: Draft, + options?: { + select?: boolean; + focus?: boolean; + expand?: boolean | number; + }, + ) { + const node = await this.findDraft(draft); + if (node == null) return undefined; + + await this.ensureRevealNode(node, options); + + return node; + } + + private setShowAvatars(enabled: boolean) { + return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled); + } +} diff --git a/src/views/fileHistoryView.ts b/src/views/fileHistoryView.ts index f06a793fddc59..29e330244ba65 100644 --- a/src/views/fileHistoryView.ts +++ b/src/views/fileHistoryView.ts @@ -1,11 +1,11 @@ import type { ConfigurationChangeEvent, Disposable } from 'vscode'; -import type { FileHistoryViewConfig } from '../configuration'; -import { configuration } from '../configuration'; -import { Commands, ContextKeys } from '../constants'; +import type { FileHistoryViewConfig } from '../config'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { setContext } from '../context'; import type { GitUri } from '../git/gitUri'; -import { executeCommand } from '../system/command'; +import { executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; import { FileHistoryTrackerNode } from './nodes/fileHistoryTrackerNode'; import { LineHistoryTrackerNode } from './nodes/lineHistoryTrackerNode'; import { ViewBase } from './viewBase'; @@ -13,17 +13,25 @@ import { registerViewCommand } from './viewCommands'; const pinnedSuffix = ' (pinned)'; -export class FileHistoryView extends ViewBase { +export class FileHistoryView extends ViewBase< + 'fileHistory', + FileHistoryTrackerNode | LineHistoryTrackerNode, + FileHistoryViewConfig +> { protected readonly configKey = 'fileHistory'; private _followCursor: boolean = false; private _followEditor: boolean = true; constructor(container: Container) { - super(container, 'gitlens.views.fileHistory', 'File History', 'fileHistoryView'); + super(container, 'fileHistory', 'File History', 'fileHistoryView'); - void setContext(ContextKeys.ViewsFileHistoryCursorFollowing, this._followCursor); - void setContext(ContextKeys.ViewsFileHistoryEditorFollowing, this._followEditor); + void setContext('gitlens:views:fileHistory:cursorFollowing', this._followCursor); + void setContext('gitlens:views:fileHistory:editorFollowing', this._followEditor); + } + + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; } protected override get showCollapseAll(): boolean { @@ -85,6 +93,16 @@ export class FileHistoryView extends ViewBase this.setShowAllBranches(false), this, ), + registerViewCommand( + this.getQualifiedCommand('setShowMergeCommitsOn'), + () => this.setShowMergeCommits(true), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setShowMergeCommitsOff'), + () => this.setShowMergeCommits(false), + this, + ), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), ]; @@ -102,7 +120,8 @@ export class FileHistoryView extends ViewBase { + readonly repoPath: string | undefined; + + constructor( + view: LaunchpadView, + protected override readonly parent: ViewNode, + private readonly group: LaunchpadGroup, + public readonly item: LaunchpadItem, + ) { + const repoPath = item.openRepository?.repo?.path; + + super('launchpad-item', repoPath != null ? GitUri.fromRepoPath(repoPath) : unknownGitUri, view, parent); + + this.updateContext({ launchpadGroup: group, launchpadItem: item }); + this._uniqueId = getViewNodeId(this.type, this.context); + this.repoPath = repoPath; + } + + override get id(): string { + return this._uniqueId; + } + + override toClipboard(type?: ClipboardType): string { + const url = this.getUrl(); + switch (type) { + case 'markdown': + return `[${this.item.underlyingPullRequest.id}](${url}) ${this.item.underlyingPullRequest.title}`; + default: + return url; + } + } + + override getUrl(): string { + return this.item.url ?? this.item.underlyingPullRequest.url; + } + + get pullRequest() { + return this.item.type === 'pullrequest' ? this.item.underlyingPullRequest : undefined; + } + + async getChildren(): Promise { + if (this.children == null) { + const children = await getPullRequestChildren( + this.view, + this, + this.item.underlyingPullRequest, + this.item.openRepository?.repo ?? this.repoPath, + ); + this.children = children; + } + + return this.children; + } + + getTreeItem(): TreeItem { + const lpi = this.item; + + const item = new TreeItem( + lpi.title.length > 60 ? `${lpi.title.substring(0, 60)}...` : lpi.title, + TreeItemCollapsibleState.Collapsed, + ); + item.contextValue = ContextValues.LaunchpadItem; + item.description = `\u00a0 ${lpi.repository.owner.login}/${lpi.repository.name}#${lpi.id} \u00a0 ${ + lpi.codeSuggestionsCount > 0 ? ` $(gitlens-code-suggestion) ${lpi.codeSuggestionsCount}` : '' + }`; + item.iconPath = lpi.author?.avatarUrl != null ? Uri.parse(lpi.author.avatarUrl) : undefined; + item.command = createCommand<[Omit]>( + Commands.ShowLaunchpad, + 'Open in Launchpad', + { + source: 'launchpad-view', + state: { + item: { ...this.item, group: this.group }, + }, + } satisfies Omit, + ); + + if (lpi.type === 'pullrequest') { + item.contextValue += '+pr'; + item.tooltip = getPullRequestTooltip(lpi.underlyingPullRequest); + } + + return item; + } +} + +export class LaunchpadViewNode extends CacheableChildrenViewNode< + 'launchpad', + LaunchpadView, + GroupingNode | LaunchpadItemNode +> { + constructor(view: LaunchpadView) { + super('launchpad', unknownGitUri, view); + } + + async getChildren(): Promise<(GroupingNode | LaunchpadItemNode)[]> { + if (this.children == null) { + const children: (GroupingNode | LaunchpadItemNode)[] = []; + + this.view.message = undefined; + + const hasIntegrations = await this.view.container.launchpad.hasConnectedIntegration(); + if (!hasIntegrations) { + return []; + } + + try { + const result = await this.view.container.launchpad.getCategorizedItems(); + if (!result.items?.length) { + this.view.message = 'All done! Take a vacation.'; + return []; + } + + const uiGroups = groupAndSortLaunchpadItems(result.items); + for (const [ui, groupItems] of uiGroups) { + if (!groupItems.length) continue; + + const icon = launchpadGroupIconMap.get(ui)!; + + children.push( + new GroupingNode( + this.view, + launchpadGroupLabelMap.get(ui)!, + groupItems.map(i => new LaunchpadItemNode(this.view, this, ui, i)), + TreeItemCollapsibleState.Collapsed, + undefined, + undefined, + new ThemeIcon(icon.substring(2, icon.length - 1)), + ), + ); + } + } catch (ex) { + if (!(ex instanceof AuthenticationRequiredError)) throw ex; + } + + this.children = children; + } + + return this.children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Launchpad', TreeItemCollapsibleState.Expanded); + return item; + } +} + +export class LaunchpadView extends ViewBase<'launchpad', LaunchpadViewNode, LaunchpadViewConfig> { + protected readonly configKey = 'launchpad'; + private _disposable: Disposable | undefined; + + constructor(container: Container) { + super(container, 'launchpad', 'Launchpad', 'launchpadView'); + + this.description = experimentalBadge; + } + + override dispose() { + this._disposable?.dispose(); + super.dispose(); + } + + protected getRoot() { + return new LaunchpadViewNode(this); + } + + protected override onVisibilityChanged(e: TreeViewVisibilityChangeEvent): void { + if (this._disposable == null) { + this._disposable = Disposable.from( + this.container.integrations.onDidChangeConnectionState(() => this.refresh(), this), + this.container.launchpad.onDidRefresh(() => this.refresh(), this), + ); + } + + super.onVisibilityChanged(e); + } + + override async show(options?: { preserveFocus?: boolean | undefined }): Promise { + if (!configuration.get('views.launchpad.enabled')) { + const confirm: MessageItem = { title: 'Enable' }; + const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showInformationMessage( + 'Would you like to try the new experimental Launchpad view?', + { + modal: true, + detail: 'Launchpad organizes your pull requests into actionable groups to help you focus and keep your team unblocked.', + }, + confirm, + cancel, + ); + + if (result !== confirm) return; + + await configuration.updateEffective('views.launchpad.enabled', true); + } + + return super.show(options); + } + + override get canReveal(): boolean { + return false; + } + + protected registerCommands(): Disposable[] { + void this.container.viewCommands; + + return [ + registerViewCommand( + this.getQualifiedCommand('info'), + () => + executeCommand(Commands.OpenWalkthrough, { + step: 'launchpad', + source: 'launchpad-view', + detail: 'info', + }), + this, + ), + registerViewCommand( + this.getQualifiedCommand('copy'), + () => executeCommand(Commands.ViewsCopy, this.activeSelection, this.selection), + this, + ), + registerViewCommand( + this.getQualifiedCommand('refresh'), + () => + window.withProgress({ location: { viewId: this.id } }, () => + this.container.launchpad.getCategorizedItems({ force: true }), + ), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setFilesLayoutToAuto'), + () => this.setFilesLayout('auto'), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setFilesLayoutToList'), + () => this.setFilesLayout('list'), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setFilesLayoutToTree'), + () => this.setFilesLayout('tree'), + this, + ), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), + ]; + } + + protected override filterConfigurationChanged(e: ConfigurationChangeEvent) { + const changed = super.filterConfigurationChanged(e); + if ( + !changed && + !configuration.changed(e, 'defaultDateFormat') && + !configuration.changed(e, 'defaultDateLocale') && + !configuration.changed(e, 'defaultDateShortFormat') && + !configuration.changed(e, 'defaultDateSource') && + !configuration.changed(e, 'defaultDateStyle') && + !configuration.changed(e, 'defaultGravatarsStyle') && + !configuration.changed(e, 'defaultTimeFormat') + ) { + return false; + } + + return true; + } + + private setFilesLayout(layout: ViewFilesLayout) { + return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout); + } + + private setShowAvatars(enabled: boolean) { + return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled); + } +} diff --git a/src/views/lineHistoryView.ts b/src/views/lineHistoryView.ts index 3608a8affbd8d..b4838d163222b 100644 --- a/src/views/lineHistoryView.ts +++ b/src/views/lineHistoryView.ts @@ -1,23 +1,27 @@ import type { ConfigurationChangeEvent, Disposable } from 'vscode'; -import type { LineHistoryViewConfig } from '../configuration'; -import { configuration } from '../configuration'; -import { Commands, ContextKeys } from '../constants'; +import type { LineHistoryViewConfig } from '../config'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { setContext } from '../context'; -import { executeCommand } from '../system/command'; +import { executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; import { LineHistoryTrackerNode } from './nodes/lineHistoryTrackerNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; const pinnedSuffix = ' (pinned)'; -export class LineHistoryView extends ViewBase { +export class LineHistoryView extends ViewBase<'lineHistory', LineHistoryTrackerNode, LineHistoryViewConfig> { protected readonly configKey = 'lineHistory'; constructor(container: Container) { - super(container, 'gitlens.views.lineHistory', 'Line History', 'lineHistoryView'); + super(container, 'lineHistory', 'Line History', 'lineHistoryView'); - void setContext(ContextKeys.ViewsLineHistoryEditorFollowing, true); + void setContext('gitlens:views:lineHistory:editorFollowing', true); + } + + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; } protected override get showCollapseAll(): boolean { @@ -80,13 +84,13 @@ export class LineHistoryView extends ViewBase implements FileNode { +export class UncommittedFileNode extends ViewFileNode<'uncommitted-file', ViewsWithCommits> implements FileNode { constructor(view: ViewsWithCommits, parent: ViewNode, repoPath: string, file: GitFile) { - super(GitUri.fromFile(file, repoPath), view, parent, file); + super('uncommitted-file', GitUri.fromFile(file, repoPath), view, parent, file); } override toClipboard(): string { @@ -35,7 +37,7 @@ export class UncommittedFileNode extends ViewFileNode implemen // Use the file icon and decorations item.resourceUri = this.view.container.git.getAbsoluteUri(this.file.path, this.repoPath); - const icon = GitFile.getStatusIcon(this.file.status); + const icon = getGitFileStatusIcon(this.file.status); item.iconPath = { dark: this.view.container.context.asAbsolutePath(joinPaths('images', 'dark', icon)), light: this.view.container.context.asAbsolutePath(joinPaths('images', 'light', icon)), diff --git a/src/views/nodes/UncommittedFilesNode.ts b/src/views/nodes/UncommittedFilesNode.ts index 0ed72400f2b5d..b660e5a412224 100644 --- a/src/views/nodes/UncommittedFilesNode.ts +++ b/src/views/nodes/UncommittedFilesNode.ts @@ -1,35 +1,21 @@ -'use strict'; import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { ViewFilesLayout } from '../../config'; import { GitUri } from '../../git/gitUri'; import type { GitTrackingState } from '../../git/models/branch'; -import { GitCommit, GitCommitIdentity } from '../../git/models/commit'; import type { GitFileWithCommit } from '../../git/models/file'; -import { GitFileChange } from '../../git/models/file'; -import { GitRevision } from '../../git/models/reference'; import type { GitStatus, GitStatusFile } from '../../git/models/status'; -import { groupBy, makeHierarchical } from '../../system/array'; -import { flatMap } from '../../system/iterable'; +import { makeHierarchical } from '../../system/array'; +import { flatMap, groupBy } from '../../system/iterable'; import { joinPaths, normalizePath } from '../../system/path'; -import type { RepositoriesView } from '../repositoriesView'; -import type { WorktreesView } from '../worktreesView'; +import type { ViewsWithWorkingTree } from '../viewBase'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; -import { RepositoryNode } from './repositoryNode'; import { UncommittedFileNode } from './UncommittedFileNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export class UncommittedFilesNode extends ViewNode { - static key = ':uncommitted-files'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; - } - - readonly repoPath: string; +export class UncommittedFilesNode extends ViewNode<'uncommitted-files', ViewsWithWorkingTree> { constructor( - view: RepositoriesView | WorktreesView, - parent: ViewNode, + view: ViewsWithWorkingTree, + protected override readonly parent: ViewNode, public readonly status: | GitStatus | { @@ -40,12 +26,17 @@ export class UncommittedFilesNode extends ViewNode { - if (f.workingTreeStatus != null && f.indexStatus != null) { - // Decrements the date to guarantee this entry will be sorted after the previous entry (most recent first) - const older = new Date(); - older.setMilliseconds(older.getMilliseconds() - 1); - - return [ - this.getFileWithPseudoCommit(f, GitRevision.uncommitted, GitRevision.uncommittedStaged), - this.getFileWithPseudoCommit(f, GitRevision.uncommittedStaged, 'HEAD', older), - ]; - } else if (f.indexStatus != null) { - return [this.getFileWithPseudoCommit(f, GitRevision.uncommittedStaged, 'HEAD')]; - } - - return [this.getFileWithPseudoCommit(f, GitRevision.uncommitted, 'HEAD')]; + const commits = f.getPseudoCommits(this.view.container, undefined); + return commits.map( + c => + ({ + status: f.status, + repoPath: f.repoPath, + indexStatus: f.indexStatus, + workingTreeStatus: f.workingTreeStatus, + path: f.path, + originalPath: f.originalPath, + commit: c, + }) satisfies GitFileWithCommit, + ); }), ]; @@ -78,7 +68,7 @@ export class UncommittedFilesNode extends ViewNode new UncommittedFileNode(this.view, this, repoPath, files[files.length - 1]), ); - if (this.view.config.files.layout !== ViewFilesLayout.List) { + if (this.view.config.files.layout !== 'list') { const hierarchy = makeHierarchical( children, n => n.uri.relativePath.split('/'), @@ -86,7 +76,7 @@ export class UncommittedFilesNode extends ViewNode extends ViewNode { + private _children: TChild[] | undefined; + protected get children(): TChild[] | undefined { + return this._children; + } + protected set children(value: TChild[] | undefined) { + if (this._children === value) return; + + disposeChildren(this._children, value); + this._children = value; + } + + override dispose() { + super.dispose(); + this.children = undefined; + } + + @debug() + override refresh(reset: boolean = false) { + if (reset) { + this.children = undefined; + } + } +} diff --git a/src/views/nodes/abstract/repositoriesSubscribeableNode.ts b/src/views/nodes/abstract/repositoriesSubscribeableNode.ts new file mode 100644 index 0000000000000..eaa9164670a93 --- /dev/null +++ b/src/views/nodes/abstract/repositoriesSubscribeableNode.ts @@ -0,0 +1,51 @@ +import { Disposable } from 'vscode'; +import type { RepositoriesChangeEvent } from '../../../git/gitProviderService'; +import { unknownGitUri } from '../../../git/gitUri'; +import type { SubscriptionChangeEvent } from '../../../plus/gk/account/subscriptionService'; +import { debug } from '../../../system/decorators/log'; +import { weakEvent } from '../../../system/event'; +import { szudzikPairing } from '../../../system/function'; +import type { View } from '../../viewBase'; +import { SubscribeableViewNode } from './subscribeableViewNode'; +import type { ViewNode } from './viewNode'; + +export abstract class RepositoriesSubscribeableNode< + TView extends View = View, + TChild extends ViewNode = ViewNode, +> extends SubscribeableViewNode<'repositories', TView, TChild> { + protected override splatted = true; + + constructor(view: TView) { + super('repositories', unknownGitUri, view); + } + + override async getSplattedChild() { + if (this.children == null) { + await this.getChildren(); + } + + return this.children?.length === 1 ? this.children[0] : undefined; + } + + protected override etag(): number { + return szudzikPairing(this.view.container.git.etag, this.view.container.subscription.etag); + } + + @debug() + protected subscribe(): Disposable | Promise { + return Disposable.from( + weakEvent(this.view.container.git.onDidChangeRepositories, this.onRepositoriesChanged, this), + weakEvent(this.view.container.subscription.onDidChange, this.onSubscriptionChanged, this), + ); + } + + private onRepositoriesChanged(_e: RepositoriesChangeEvent) { + void this.triggerChange(true); + } + + private onSubscriptionChanged(e: SubscriptionChangeEvent) { + if (e.current.plan !== e.previous.plan) { + void this.triggerChange(true); + } + } +} diff --git a/src/views/nodes/abstract/repositoryFolderNode.ts b/src/views/nodes/abstract/repositoryFolderNode.ts new file mode 100644 index 0000000000000..0b39f4c9473c6 --- /dev/null +++ b/src/views/nodes/abstract/repositoryFolderNode.ts @@ -0,0 +1,230 @@ +import type { Disposable } from 'vscode'; +import { MarkdownString, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { GlyphChars } from '../../../constants'; +import type { GitUri } from '../../../git/gitUri'; +import { getHighlanderProviders } from '../../../git/models/remote'; +import type { RepositoryChangeEvent } from '../../../git/models/repository'; +import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; +import { gate } from '../../../system/decorators/gate'; +import { debug, log } from '../../../system/decorators/log'; +import { weakEvent } from '../../../system/event'; +import { basename } from '../../../system/path'; +import { pad } from '../../../system/string'; +import type { View } from '../../viewBase'; +import { SubscribeableViewNode } from './subscribeableViewNode'; +import type { ViewNode } from './viewNode'; +import { ContextValues, getViewNodeId } from './viewNode'; + +export abstract class RepositoryFolderNode< + TView extends View = View, + TChild extends ViewNode = ViewNode, +> extends SubscribeableViewNode<'repo-folder', TView> { + protected override splatted = true; + + constructor( + uri: GitUri, + view: TView, + protected override readonly parent: ViewNode, + public readonly repo: Repository, + splatted: boolean, + private readonly options?: { showBranchAndLastFetched?: boolean }, + ) { + super('repo-folder', uri, view, parent); + + this.updateContext({ repository: this.repo }); + this._uniqueId = getViewNodeId(this.type, this.context); + + this.splatted = splatted; + } + + private _child: TChild | undefined; + protected get child(): TChild | undefined { + return this._child; + } + protected set child(value: TChild | undefined) { + if (this._child === value) return; + + this._child?.dispose(); + this._child = value; + } + + override dispose() { + super.dispose(); + this.child = undefined; + } + + override get id(): string { + return this._uniqueId; + } + + override toClipboard(): string { + return this.repo.path; + } + + get repoPath(): string { + return this.repo.path; + } + + async getTreeItem(): Promise { + this.splatted = false; + + const branch = await this.repo.getBranch(); + const ahead = (branch?.state.ahead ?? 0) > 0; + const behind = (branch?.state.behind ?? 0) > 0; + + const expand = ahead || behind || this.repo.starred || this.view.container.git.isRepositoryForEditor(this.repo); + + let label = this.repo.formattedName ?? this.uri.repoPath ?? ''; + if (this.options?.showBranchAndLastFetched && branch != null) { + const remove = `: ${basename(branch.name)}`; + const suffix = `: ${branch.name}`; + if (label.endsWith(remove)) { + label = label.substring(0, label.length - remove.length) + suffix; + } else if (!label.endsWith(suffix)) { + label += suffix; + } + } + + const item = new TreeItem( + label, + expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, + ); + item.contextValue = `${ContextValues.RepositoryFolder}${this.repo.starred ? '+starred' : ''}`; + if (ahead) { + item.contextValue += '+ahead'; + } + if (behind) { + item.contextValue += '+behind'; + } + if (this.view.type === 'commits' && this.view.state.filterCommits.get(this.repo.id)?.length) { + item.contextValue += '+filtered'; + } + + if (branch != null && this.options?.showBranchAndLastFetched) { + const lastFetched = (await this.repo.getLastFetched()) ?? 0; + + const status = branch.getTrackingStatus(); + if (status) { + item.description = status; + if (lastFetched) { + item.description += pad(GlyphChars.Dot, 1, 1); + } + } + if (lastFetched) { + item.description = `${item.description ?? ''}Last fetched ${Repository.formatLastFetched(lastFetched)}`; + } + + let providerName; + if (branch.upstream != null) { + const providers = getHighlanderProviders( + await this.view.container.git.getRemotesWithProviders(branch.repoPath), + ); + providerName = providers?.length ? providers[0].name : undefined; + } else { + const remote = await branch.getRemote(); + providerName = remote?.provider?.name; + } + + item.tooltip = new MarkdownString( + `${this.repo.formattedName ?? this.uri.repoPath ?? ''}${ + lastFetched + ? `${pad(GlyphChars.Dash, 2, 2)}Last fetched ${Repository.formatLastFetched( + lastFetched, + false, + )}` + : '' + }${this.repo.formattedName ? `\n${this.uri.repoPath}` : ''}\n\nCurrent branch $(git-branch) ${ + branch.name + }${ + branch.upstream != null + ? ` is ${branch.getTrackingStatus({ + empty: branch.upstream.missing + ? `missing upstream $(git-branch) ${branch.upstream.name}` + : `up to date with $(git-branch) ${branch.upstream.name}${ + providerName ? ` on ${providerName}` : '' + }`, + expand: true, + icons: true, + separator: ', ', + suffix: ` $(git-branch) ${branch.upstream.name}${ + providerName ? ` on ${providerName}` : '' + }`, + })}` + : `hasn't been published to ${providerName ?? 'a remote'}` + }`, + true, + ); + } else { + item.tooltip = this.repo.formattedName + ? `${this.repo.formattedName}\n${this.uri.repoPath}` + : this.uri.repoPath ?? ''; + } + + return item; + } + + override async getSplattedChild() { + if (this.child == null) { + await this.getChildren(); + } + + return this.child; + } + + @gate() + @debug() + override async refresh(reset: boolean = false) { + super.refresh(reset); + await this.child?.triggerChange(reset, false, this); + + await this.ensureSubscription(); + } + + @log() + async star() { + await this.repo.star(); + // void this.parent!.triggerChange(); + } + + @log() + async unstar() { + await this.repo.unstar(); + // void this.parent!.triggerChange(); + } + + @debug() + protected subscribe(): Disposable | Promise { + return weakEvent(this.repo.onDidChange, this.onRepositoryChanged, this); + } + + protected override etag(): number { + return this.repo.etag; + } + + protected abstract changed(e: RepositoryChangeEvent): boolean; + + @debug({ args: { 0: e => e.toString() } }) + private onRepositoryChanged(e: RepositoryChangeEvent) { + if (e.changed(RepositoryChange.Closed, RepositoryChangeComparisonMode.Any)) { + this.dispose(); + void this.parent?.triggerChange(true); + + return; + } + + if ( + e.changed(RepositoryChange.Opened, RepositoryChangeComparisonMode.Any) || + e.changed(RepositoryChange.Starred, RepositoryChangeComparisonMode.Any) + ) { + void this.parent?.triggerChange(true); + + return; + } + + if (this.changed(e)) { + // If we are sorting by last fetched, then we need to trigger the parent to resort + const node = !this.loaded || this.repo.orderByLastFetched ? this.parent ?? this : this; + void node.triggerChange(true); + } + } +} diff --git a/src/views/nodes/abstract/subscribeableViewNode.ts b/src/views/nodes/abstract/subscribeableViewNode.ts new file mode 100644 index 0000000000000..1f5d388fea58e --- /dev/null +++ b/src/views/nodes/abstract/subscribeableViewNode.ts @@ -0,0 +1,167 @@ +import type { TreeViewVisibilityChangeEvent } from 'vscode'; +import { Disposable } from 'vscode'; +import type { TreeViewSubscribableNodeTypes } from '../../../constants.views'; +import type { GitUri } from '../../../git/gitUri'; +import { gate } from '../../../system/decorators/gate'; +import { debug } from '../../../system/decorators/log'; +import { weakEvent } from '../../../system/event'; +import type { View } from '../../viewBase'; +import { CacheableChildrenViewNode } from './cacheableChildrenViewNode'; +import type { ViewNode } from './viewNode'; +import { canAutoRefreshView } from './viewNode'; + +export abstract class SubscribeableViewNode< + Type extends TreeViewSubscribableNodeTypes = TreeViewSubscribableNodeTypes, + TView extends View = View, + TChild extends ViewNode = ViewNode, + State extends object = any, +> extends CacheableChildrenViewNode { + protected disposable: Disposable; + protected subscription: Promise | undefined; + + protected loaded: boolean = false; + + constructor(type: Type, uri: GitUri, view: TView, parent?: ViewNode) { + super(type, uri, view, parent); + + const disposables = [ + weakEvent(this.view.onDidChangeVisibility, this.onVisibilityChanged, this), + // weak(this.view.onDidChangeNodeCollapsibleState, this.onNodeCollapsibleStateChanged, this), + ]; + + if (canAutoRefreshView(this.view)) { + disposables.push(weakEvent(this.view.onDidChangeAutoRefresh, this.onAutoRefreshChanged, this)); + } + + const getTreeItem = this.getTreeItem; + this.getTreeItem = function (this: SubscribeableViewNode) { + this.loaded = true; + void this.ensureSubscription(); + return getTreeItem.apply(this); + }; + + const getChildren = this.getChildren; + this.getChildren = function (this: SubscribeableViewNode) { + this.loaded = true; + void this.ensureSubscription(); + return getChildren.apply(this); + }; + + this.disposable = Disposable.from(...disposables); + } + + override dispose() { + super.dispose(); + void this.unsubscribe(); + this.disposable?.dispose(); + } + + @debug() + override async triggerChange(reset: boolean = false, force: boolean = false): Promise { + if (!this.loaded || this._disposed) return; + + if (reset && !this.view.visible) { + this._pendingReset = reset; + } + await super.triggerChange(reset, force); + } + + private _canSubscribe: boolean = true; + protected get canSubscribe(): boolean { + return this._canSubscribe && !this._disposed; + } + protected set canSubscribe(value: boolean) { + if (this._canSubscribe === value) return; + + this._canSubscribe = value; + + void this.ensureSubscription(); + if (value) { + void this.triggerChange(); + } + } + + private _etag: number | undefined; + protected abstract etag(): number; + + private _pendingReset: boolean = false; + private get requiresResetOnVisible(): boolean { + let reset = this._pendingReset; + this._pendingReset = false; + + const etag = this.etag(); + if (etag !== this._etag) { + this._etag = etag; + reset = true; + } + + return reset; + } + + protected abstract subscribe(): Disposable | undefined | Promise; + + @debug() + protected async unsubscribe(): Promise { + this._etag = this.etag(); + + if (this.subscription != null) { + const subscriptionPromise = this.subscription; + this.subscription = undefined; + + (await subscriptionPromise)?.dispose(); + } + } + + @debug() + protected onAutoRefreshChanged() { + this.onVisibilityChanged({ visible: this.view.visible }); + } + + // protected onParentCollapsibleStateChanged?(state: TreeItemCollapsibleState): void; + // protected onCollapsibleStateChanged?(state: TreeItemCollapsibleState): void; + // protected collapsibleState: TreeItemCollapsibleState | undefined; + // protected onNodeCollapsibleStateChanged(e: TreeViewNodeCollapsibleStateChangeEvent) { + // if (e.element === this) { + // this.collapsibleState = e.state; + // if (this.onCollapsibleStateChanged !== undefined) { + // this.onCollapsibleStateChanged(e.state); + // } + // } else if (e.element === this.parent) { + // if (this.onParentCollapsibleStateChanged !== undefined) { + // this.onParentCollapsibleStateChanged(e.state); + // } + // } + // } + @debug() + protected onVisibilityChanged(e: TreeViewVisibilityChangeEvent) { + void this.ensureSubscription(); + + if (e.visible) { + void this.triggerChange(this.requiresResetOnVisible); + } + } + + @gate() + @debug() + async ensureSubscription() { + // We only need to subscribe if we are visible and if auto-refresh enabled (when supported) + if (!this.canSubscribe || !this.view.visible || (canAutoRefreshView(this.view) && !this.view.autoRefresh)) { + await this.unsubscribe(); + + return; + } + + // If we already have a subscription, just kick out + if (this.subscription != null) return; + + this.subscription = Promise.resolve(this.subscribe()); + void (await this.subscription); + } + + @gate() + @debug() + async resetSubscription() { + await this.unsubscribe(); + await this.ensureSubscription(); + } +} diff --git a/src/views/nodes/abstract/viewFileNode.ts b/src/views/nodes/abstract/viewFileNode.ts new file mode 100644 index 0000000000000..7b1ccd6211d1b --- /dev/null +++ b/src/views/nodes/abstract/viewFileNode.ts @@ -0,0 +1,33 @@ +import type { TreeViewFileNodeTypes } from '../../../constants.views'; +import type { GitUri } from '../../../git/gitUri'; +import type { GitFile } from '../../../git/models/file'; +import type { View } from '../../viewBase'; +import { ViewNode } from './viewNode'; + +export abstract class ViewFileNode< + Type extends TreeViewFileNodeTypes = TreeViewFileNodeTypes, + TView extends View = View, + State extends object = any, +> extends ViewNode { + constructor( + type: Type, + uri: GitUri, + view: TView, + public override parent: ViewNode, + public readonly file: GitFile, + ) { + super(type, uri, view, parent); + } + + get repoPath(): string { + return this.uri.repoPath!; + } + + override toString(): string { + return `${super.toString()}:${this.file.path}`; + } +} + +export function isViewFileNode(node: unknown): node is ViewFileNode { + return node instanceof ViewFileNode; +} diff --git a/src/views/nodes/abstract/viewNode.ts b/src/views/nodes/abstract/viewNode.ts new file mode 100644 index 0000000000000..30b3f6a179cbb --- /dev/null +++ b/src/views/nodes/abstract/viewNode.ts @@ -0,0 +1,467 @@ +import type { CancellationToken, Command, Disposable, Event, TreeItem } from 'vscode'; +import type { TreeViewNodeTypes } from '../../../constants.views'; +import type { GitUri } from '../../../git/gitUri'; +import type { GitBranch } from '../../../git/models/branch'; +import type { GitCommit } from '../../../git/models/commit'; +import type { GitContributor } from '../../../git/models/contributor'; +import type { GitFile } from '../../../git/models/file'; +import type { PullRequest } from '../../../git/models/pullRequest'; +import type { GitReflogRecord } from '../../../git/models/reflog'; +import type { GitRemote } from '../../../git/models/remote'; +import type { Repository } from '../../../git/models/repository'; +import type { GitTag } from '../../../git/models/tag'; +import type { GitWorktree } from '../../../git/models/worktree'; +import type { Draft } from '../../../gk/models/drafts'; +import type { LaunchpadGroup, LaunchpadItem } from '../../../plus/launchpad/launchpadProvider'; +import { + launchpadCategoryToGroupMap, + sharedCategoryToLaunchpadActionCategoryMap, +} from '../../../plus/launchpad/launchpadProvider'; +import type { + CloudWorkspace, + CloudWorkspaceRepositoryDescriptor, + LocalWorkspace, + LocalWorkspaceRepositoryDescriptor, +} from '../../../plus/workspaces/models'; +import { gate } from '../../../system/decorators/gate'; +import { debug, logName } from '../../../system/decorators/log'; +import { is as isA } from '../../../system/function'; +import { getLoggableName } from '../../../system/logger'; +import type { LaunchpadItemNode } from '../../launchpadView'; +import type { View } from '../../viewBase'; +import type { BranchNode } from '../branchNode'; +import type { BranchTrackingStatusFilesNode } from '../branchTrackingStatusFilesNode'; +import type { BranchTrackingStatus, BranchTrackingStatusNode } from '../branchTrackingStatusNode'; +import type { CodeSuggestionsNode } from '../codeSuggestionsNode'; +import type { CommitFileNode } from '../commitFileNode'; +import type { CommitNode } from '../commitNode'; +import type { CompareBranchNode } from '../compareBranchNode'; +import type { CompareResultsNode } from '../compareResultsNode'; +import type { FileRevisionAsCommitNode } from '../fileRevisionAsCommitNode'; +import type { FolderNode } from '../folderNode'; +import type { LineHistoryTrackerNode } from '../lineHistoryTrackerNode'; +import type { MergeConflictFileNode } from '../mergeConflictFileNode'; +import type { PullRequestNode } from '../pullRequestNode'; +import type { RepositoryNode } from '../repositoryNode'; +import type { ResultsCommitsNode } from '../resultsCommitsNode'; +import type { ResultsFileNode } from '../resultsFileNode'; +import type { ResultsFilesNode } from '../resultsFilesNode'; +import type { StashFileNode } from '../stashFileNode'; +import type { StashNode } from '../stashNode'; +import type { StatusFileNode } from '../statusFileNode'; +import type { TagNode } from '../tagNode'; +import type { UncommittedFileNode } from '../UncommittedFileNode'; +import type { RepositoryFolderNode } from './repositoryFolderNode'; + +export const enum ContextValues { + ActiveFileHistory = 'gitlens:history:active:file', + ActiveLineHistory = 'gitlens:history:active:line', + AutolinkedItems = 'gitlens:autolinked:items', + AutolinkedItem = 'gitlens:autolinked:item', + Branch = 'gitlens:branch', + Branches = 'gitlens:branches', + BranchStatusAheadOfUpstream = 'gitlens:status-branch:upstream:ahead', + BranchStatusBehindUpstream = 'gitlens:status-branch:upstream:behind', + BranchStatusMissingUpstream = 'gitlens:status-branch:upstream:missing', + BranchStatusNoUpstream = 'gitlens:status-branch:upstream:none', + BranchStatusSameAsUpstream = 'gitlens:status-branch:upstream:same', + BranchStatusFiles = 'gitlens:status-branch:files', + CodeSuggestions = 'gitlens:drafts:code-suggestions', + Commit = 'gitlens:commit', + Commits = 'gitlens:commits', + Compare = 'gitlens:compare', + CompareBranch = 'gitlens:compare:branch', + ComparePicker = 'gitlens:compare:picker', + ComparePickerWithRef = 'gitlens:compare:picker:ref', + CompareResults = 'gitlens:compare:results', + CompareResultsCommits = 'gitlens:compare:results:commits', + Contributor = 'gitlens:contributor', + Contributors = 'gitlens:contributors', + DateMarker = 'gitlens:date-marker', + Draft = 'gitlens:draft', + File = 'gitlens:file', + FileHistory = 'gitlens:history:file', + Folder = 'gitlens:folder', + Grouping = 'gitlens:grouping', + LaunchpadItem = 'gitlens:launchpad:item', + LineHistory = 'gitlens:history:line', + Merge = 'gitlens:merge', + MergeConflictCurrentChanges = 'gitlens:merge-conflict:current', + MergeConflictIncomingChanges = 'gitlens:merge-conflict:incoming', + Message = 'gitlens:message', + MessageSignIn = 'gitlens:message:signin', + Pager = 'gitlens:pager', + PullRequest = 'gitlens:pullrequest', + Rebase = 'gitlens:rebase', + Reflog = 'gitlens:reflog', + ReflogRecord = 'gitlens:reflog-record', + Remote = 'gitlens:remote', + Remotes = 'gitlens:remotes', + Repositories = 'gitlens:repositories', + Repository = 'gitlens:repository', + RepositoryFolder = 'gitlens:repo-folder', + ResultsFile = 'gitlens:file:results', + ResultsFiles = 'gitlens:results:files', + SearchAndCompare = 'gitlens:searchAndCompare', + SearchResults = 'gitlens:search:results', + SearchResultsCommits = 'gitlens:search:results:commits', + Stash = 'gitlens:stash', + Stashes = 'gitlens:stashes', + StatusFileCommits = 'gitlens:status:file:commits', + StatusFiles = 'gitlens:status:files', + StatusAheadOfUpstream = 'gitlens:status:upstream:ahead', + StatusBehindUpstream = 'gitlens:status:upstream:behind', + StatusMissingUpstream = 'gitlens:status:upstream:missing', + StatusNoUpstream = 'gitlens:status:upstream:none', + StatusSameAsUpstream = 'gitlens:status:upstream:same', + Tag = 'gitlens:tag', + Tags = 'gitlens:tags', + UncommittedFiles = 'gitlens:uncommitted:files', + Workspace = 'gitlens:workspace', + WorkspaceMissingRepository = 'gitlens:workspaceMissingRepository', + Workspaces = 'gitlens:workspaces', + Worktree = 'gitlens:worktree', + Worktrees = 'gitlens:worktrees', +} + +export interface AmbientContext { + readonly autolinksId?: string; + readonly branch?: GitBranch; + readonly branchStatus?: BranchTrackingStatus; + readonly branchStatusUpstreamType?: 'ahead' | 'behind' | 'same' | 'missing' | 'none'; + readonly commit?: GitCommit; + readonly comparisonId?: string; + readonly comparisonFiltered?: boolean; + readonly contributor?: GitContributor; + readonly draft?: Draft; + readonly file?: GitFile; + readonly launchpadGroup?: LaunchpadGroup; + readonly launchpadItem?: LaunchpadItem; + readonly pullRequest?: PullRequest; + readonly reflog?: GitReflogRecord; + readonly remote?: GitRemote; + readonly repository?: Repository; + readonly root?: boolean; + readonly searchId?: string; + readonly status?: 'merging' | 'rebasing'; + readonly storedComparisonId?: string; + readonly tag?: GitTag; + readonly workspace?: CloudWorkspace | LocalWorkspace; + readonly wsRepositoryDescriptor?: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor; + readonly worktree?: GitWorktree; + + readonly worktreesByBranch?: Map; +} + +export function getViewNodeId(type: string, context: AmbientContext): string { + let uniqueness = ''; + if (context.root) { + uniqueness += '/root'; + } + if (context.workspace != null) { + uniqueness += `/ws/${context.workspace.id}`; + } + if (context.wsRepositoryDescriptor != null) { + uniqueness += `/wsrepo/${context.wsRepositoryDescriptor.id}`; + } + if (context.repository != null) { + uniqueness += `/repo/${context.repository.id}`; + } + if (context.worktree != null) { + uniqueness += `/worktree/${context.worktree.uri.path}`; + } + if (context.remote != null) { + uniqueness += `/remote/${context.remote.name}`; + } + if (context.tag != null) { + uniqueness += `/tag/${context.tag.id}`; + } + if (context.branch != null) { + uniqueness += `/branch/${context.branch.id}`; + } + if (context.branchStatus != null) { + uniqueness += `/branch-status/${context.branchStatus.upstream?.name ?? '-'}`; + } + if (context.branchStatusUpstreamType != null) { + uniqueness += `/branch-status-direction/${context.branchStatusUpstreamType}`; + } + if (context.launchpadGroup != null) { + uniqueness += `/lp/${context.launchpadGroup}`; + if (context.launchpadItem != null) { + uniqueness += `/${context.launchpadItem.type}/${context.launchpadItem.uuid}`; + } + } else if (context.launchpadItem != null) { + uniqueness += `/lp/${launchpadCategoryToGroupMap.get( + sharedCategoryToLaunchpadActionCategoryMap.get(context.launchpadItem.suggestedActionCategory)!, + )}/${context.launchpadItem.type}/${context.launchpadItem.uuid}`; + } + if (context.pullRequest != null) { + uniqueness += `/pr/${context.pullRequest.id}`; + } + if (context.status != null) { + uniqueness += `/status/${context.status}`; + } + if (context.reflog != null) { + uniqueness += `/reflog/${context.reflog.sha}+${context.reflog.selector}+${context.reflog.command}+${ + context.reflog.commandArgs ?? '' + }+${context.reflog.date.getTime()}`; + } + if (context.contributor != null) { + uniqueness += `/contributor/${ + context.contributor.id ?? + `${context.contributor.username}+${context.contributor.email}+${context.contributor.name}` + }`; + } + if (context.autolinksId != null) { + uniqueness += `/autolinks/${context.autolinksId}`; + } + if (context.comparisonId != null) { + uniqueness += `/comparison/${context.comparisonId}`; + } + if (context.searchId != null) { + uniqueness += `/search/${context.searchId}`; + } + if (context.commit != null) { + uniqueness += `/commit/${context.commit.sha}`; + } + if (context.file != null) { + uniqueness += `/file/${context.file.path}+${context.file.status}`; + } + if (context.draft != null) { + uniqueness += `/draft/${context.draft.id}`; + } + + return `gitlens://viewnode/${type}${uniqueness}`; +} + +export type ClipboardType = 'text' | 'markdown'; + +@logName((c, name) => `${name}${c.id != null ? `(${c.id})` : ''}`) +export abstract class ViewNode< + Type extends TreeViewNodeTypes = TreeViewNodeTypes, + TView extends View = View, + State extends object = any, +> implements Disposable +{ + is(type: T): this is TreeViewNodesByType[T] { + return this.type === (type as unknown as Type); + } + + isAny(...types: T): this is TreeViewNodesByType[T[number]] { + return types.includes(this.type as unknown as T[number]); + } + + protected _uniqueId!: string; + protected splatted = false; + // NOTE: @eamodio uncomment to track node leaks + // readonly uuid = uuid(); + + constructor( + public readonly type: Type, + // public readonly id: string | undefined, + uri: GitUri, + public readonly view: TView, + protected parent?: ViewNode, + ) { + // NOTE: @eamodio uncomment to track node leaks + // queueMicrotask(() => this.view.registerNode(this)); + this._uri = uri; + } + + protected _disposed = false; + // NOTE: @eamodio uncomment to track node leaks + // @debug() + dispose() { + this._disposed = true; + // NOTE: @eamodio uncomment to track node leaks + // this.view.unregisterNode(this); + } + + get id(): string | undefined { + return this._uniqueId; + } + + private _context: AmbientContext | undefined; + protected get context(): AmbientContext { + return this._context ?? this.parent?.context ?? {}; + } + + protected updateContext(context: AmbientContext, reset: boolean = false) { + this._context = this.getNewContext(context, reset); + } + + protected getNewContext(context: AmbientContext, reset: boolean = false) { + return { ...(reset ? this.parent?.context : this.context), ...context }; + } + + getUrl?(): string | Promise | undefined; + toClipboard?(type?: ClipboardType): string | Promise; + + toString(): string { + return getLoggableName(this); + } + + protected _uri: GitUri; + get uri(): GitUri { + return this._uri; + } + + abstract getChildren(): ViewNode[] | Promise; + + getParent(): ViewNode | undefined { + // If this node's parent has been splatted (e.g. not shown itself, but its children are), then return its grandparent + return this.parent?.splatted ? this.parent?.getParent() : this.parent; + } + + abstract getTreeItem(): TreeItem | Promise; + + resolveTreeItem?(item: TreeItem, token: CancellationToken): TreeItem | Promise; + + getCommand(): Command | undefined { + return undefined; + } + + refresh?(reset?: boolean): boolean | void | Promise | Promise; + + @gate() + @debug() + triggerChange(reset: boolean = false, force: boolean = false, avoidSelf?: ViewNode): Promise { + if (this._disposed) return Promise.resolve(); + + // If this node has been splatted (e.g. not shown itself, but its children are), then delegate the change to its parent + if (this.splatted && this.parent != null && this.parent !== avoidSelf) { + return this.parent.triggerChange(reset, force); + } + + return this.view.refreshNode(this, reset, force); + } + + getSplattedChild?(): Promise; + + deleteState = StateKey>(key?: T): void { + if (this.id == null) { + debugger; + throw new Error('Id is required to delete state'); + } + this.view.nodeState.deleteState(this.id, key as string); + } + + getState = StateKey>(key: T): StateValue | undefined { + if (this.id == null) { + debugger; + throw new Error('Id is required to get state'); + } + return this.view.nodeState.getState(this.id, key as string); + } + + storeState = StateKey>( + key: T, + value: StateValue, + sticky?: boolean, + ): void { + if (this.id == null) { + debugger; + throw new Error('Id is required to store state'); + } + this.view.nodeState.storeState(this.id, key as string, value, sticky); + } +} + +type StateKey = keyof T; +type StateValue> = P extends keyof T ? T[P] : never; + +export interface PageableViewNode extends ViewNode { + readonly id: string; + limit?: number; + readonly hasMore: boolean; + loadMore(limit?: number | { until?: string | undefined }, context?: Record): Promise; +} + +export function isPageableViewNode(node: ViewNode): node is ViewNode & PageableViewNode { + return isA(node, 'loadMore'); +} + +interface AutoRefreshableView { + autoRefresh: boolean; + onDidChangeAutoRefresh: Event; +} + +export function canAutoRefreshView(view: View): view is View & AutoRefreshableView { + return isA(view, 'onDidChangeAutoRefresh'); +} + +export function canEditNode(node: ViewNode): node is ViewNode & { edit(): void | Promise } { + return typeof (node as ViewNode & { edit(): void | Promise }).edit === 'function'; +} + +export function canGetNodeRepoPath(node?: ViewNode): node is ViewNode & { repoPath: string | undefined } { + return node != null && 'repoPath' in node && typeof node.repoPath === 'string'; +} + +export function canViewDismissNode(view: View): view is View & { dismissNode(node: ViewNode): void } { + return typeof (view as View & { dismissNode(node: ViewNode): void }).dismissNode === 'function'; +} + +export function getNodeRepoPath(node?: ViewNode): string | undefined { + return canGetNodeRepoPath(node) ? node.repoPath : undefined; +} + +// prettier-ignore +type TreeViewNodesByType = { + [T in TreeViewNodeTypes]: T extends 'branch' + ? BranchNode + : T extends 'commit' + ? CommitNode + : T extends 'commit-file' + ? CommitFileNode + : T extends 'compare-branch' + ? CompareBranchNode + : T extends 'compare-results' + ? CompareResultsNode + : T extends 'conflict-file' + ? MergeConflictFileNode + : T extends 'drafts-code-suggestions' + ? CodeSuggestionsNode + : T extends 'file-commit' + ? FileRevisionAsCommitNode + : T extends 'folder' + ? FolderNode + : T extends 'launchpad-item' + ? LaunchpadItemNode + : T extends 'line-history-tracker' + ? LineHistoryTrackerNode + : T extends 'pullrequest' + ? PullRequestNode + : T extends 'repository' + ? RepositoryNode + : T extends 'repo-folder' + ? RepositoryFolderNode + : T extends 'results-commits' + ? ResultsCommitsNode + : T extends 'results-file' + ? ResultsFileNode + : T extends 'results-files' + ? ResultsFilesNode + : T extends 'stash' + ? StashNode + : T extends 'stash-file' + ? StashFileNode + : T extends 'status-file' + ? StatusFileNode + : T extends 'tag' + ? TagNode + : T extends 'tracking-status' + ? BranchTrackingStatusNode + : T extends 'tracking-status-files' + ? BranchTrackingStatusFilesNode + : T extends 'uncommitted-file' + ? UncommittedFileNode + : ViewNode; +}; + +export function isViewNode(node: unknown): node is ViewNode; +export function isViewNode(node: unknown, type: T): node is TreeViewNodesByType[T]; +export function isViewNode(node: unknown, type?: T): node is ViewNode { + if (node == null) return false; + return node instanceof ViewNode ? type == null || node.type === type : false; +} diff --git a/src/views/nodes/abstract/viewRefNode.ts b/src/views/nodes/abstract/viewRefNode.ts new file mode 100644 index 0000000000000..bd2b1a6f84da2 --- /dev/null +++ b/src/views/nodes/abstract/viewRefNode.ts @@ -0,0 +1,45 @@ +import type { TreeViewRefFileNodeTypes, TreeViewRefNodeTypes } from '../../../constants.views'; +import type { GitUri } from '../../../git/gitUri'; +import type { GitReference, GitRevisionReference } from '../../../git/models/reference'; +import { getReferenceLabel } from '../../../git/models/reference'; +import type { View } from '../../viewBase'; +import { ViewFileNode } from './viewFileNode'; +import { ViewNode } from './viewNode'; + +export abstract class ViewRefNode< + Type extends TreeViewRefNodeTypes = TreeViewRefNodeTypes, + TView extends View = View, + TReference extends GitReference = GitReference, + State extends object = any, +> extends ViewNode { + constructor( + type: Type, + uri: GitUri, + view: TView, + protected override readonly parent: ViewNode, + ) { + super(type, uri, view, parent); + } + + abstract get ref(): TReference; + + get repoPath(): string { + return this.uri.repoPath!; + } + + override toString(): string { + return `${super.toString()}:${getReferenceLabel(this.ref, false)}`; + } +} + +export abstract class ViewRefFileNode< + Type extends TreeViewRefFileNodeTypes = TreeViewRefFileNodeTypes, + TView extends View = View, + State extends object = any, +> extends ViewFileNode { + abstract get ref(): GitRevisionReference; + + override toString(): string { + return `${super.toString()}:${this.file.path}`; + } +} diff --git a/src/views/nodes/autolinkedItemNode.ts b/src/views/nodes/autolinkedItemNode.ts index 79ab33be1478c..d9d3161caed85 100644 --- a/src/views/nodes/autolinkedItemNode.ts +++ b/src/views/nodes/autolinkedItemNode.ts @@ -1,28 +1,46 @@ import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import type { Autolink } from '../../annotations/autolinks'; -import { AutolinkType } from '../../config'; import { GitUri } from '../../git/gitUri'; -import { IssueOrPullRequest, IssueOrPullRequestType } from '../../git/models/issue'; +import type { IssueOrPullRequest } from '../../git/models/issue'; +import { getIssueOrPullRequestMarkdownIcon, getIssueOrPullRequestThemeIcon } from '../../git/models/issue'; import { fromNow } from '../../system/date'; +import { isPromise } from '../../system/promise'; import type { ViewsWithCommits } from '../viewBase'; -import { ContextValues, ViewNode } from './viewNode'; +import type { ClipboardType } from './abstract/viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; -export class AutolinkedItemNode extends ViewNode { +export class AutolinkedItemNode extends ViewNode<'autolink', ViewsWithCommits> { constructor( view: ViewsWithCommits, protected override readonly parent: ViewNode, public readonly repoPath: string, - public readonly item: Autolink | IssueOrPullRequest, + public readonly item: Autolink, + private maybeEnriched: Promise | IssueOrPullRequest | undefined, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); - } + super('autolink', GitUri.fromRepoPath(repoPath), view, parent); - override toClipboard(): string { - return this.item.url; + this._uniqueId = getViewNodeId(`${this.type}+${item.id}`, this.context); } override get id(): string { - return `${this.parent.id}:item(${this.item.id})`; + return this._uniqueId; + } + + override async toClipboard(type?: ClipboardType): Promise { + const enriched = await this.maybeEnriched; + switch (type) { + case 'markdown': { + return `[${this.item.prefix ?? ''}${this.item.id}](${this.item.url})${ + enriched?.title ? ` - ${enriched?.title}` : '' + }`; + } + default: + return `${this.item.id}: ${enriched?.title ?? this.item.url}`; + } + } + + override getUrl(): string { + return this.item.url; } getChildren(): ViewNode[] { @@ -30,53 +48,65 @@ export class AutolinkedItemNode extends ViewNode { } getTreeItem(): TreeItem { - if (!isIssueOrPullRequest(this.item)) { - const { provider } = this.item; + const enriched = this.maybeEnriched; + const pending = isPromise(enriched); + if (pending) { + void enriched.then(item => { + this.maybeEnriched = item; + this.view.triggerNodeChange(this); + }); + } - const item = new TreeItem(`${this.item.prefix}${this.item.id}`, TreeItemCollapsibleState.None); + if (pending || enriched == null) { + const autolink = this.item; + const { provider } = autolink; + + const item = new TreeItem( + autolink.description ?? `Autolink ${autolink.prefix}${autolink.id}`, + TreeItemCollapsibleState.None, + ); item.description = provider?.name ?? 'Custom'; item.iconPath = new ThemeIcon( - this.item.type == null - ? 'link' - : this.item.type === AutolinkType.PullRequest - ? 'git-pull-request' - : 'issues', + pending + ? 'loading~spin' + : autolink.type == null + ? 'link' + : autolink.type === 'pullrequest' + ? 'git-pull-request' + : 'issues', ); item.contextValue = ContextValues.AutolinkedItem; item.tooltip = new MarkdownString( `${ - this.item.description - ? `Autolinked ${this.item.description}` + autolink.description + ? `Autolinked ${autolink.description}` : `${ - this.item.type == null + autolink.type == null ? 'Autolinked' - : this.item.type === AutolinkType.PullRequest - ? 'Autolinked Pull Request' - : 'Autolinked Issue' - } ${this.item.prefix}${this.item.id}` - } \\\n[${this.item.url}](${this.item.url}${this.item.title != null ? ` "${this.item.title}"` : ''})`, + : autolink.type === 'pullrequest' + ? 'Autolinked Pull Request' + : 'Autolinked Issue' + } ${autolink.prefix}${autolink.id}` + } \\\n[${autolink.url}](${autolink.url}${autolink.title != null ? ` "${autolink.title}"` : ''})`, ); return item; } - const relativeTime = fromNow(this.item.closedDate ?? this.item.date); + const relativeTime = fromNow(enriched.closedDate ?? enriched.updatedDate ?? enriched.createdDate); - const item = new TreeItem(`${this.item.id}: ${this.item.title}`, TreeItemCollapsibleState.None); + const item = new TreeItem(`${enriched.id}: ${enriched.title}`, TreeItemCollapsibleState.None); item.description = relativeTime; - item.iconPath = IssueOrPullRequest.getThemeIcon(this.item); - item.contextValue = - this.item.type === IssueOrPullRequestType.PullRequest - ? ContextValues.PullRequest - : ContextValues.AutolinkedIssue; + item.iconPath = getIssueOrPullRequestThemeIcon(enriched); + item.contextValue = `${ContextValues.AutolinkedItem}+${enriched.type === 'pullrequest' ? 'pr' : 'issue'}`; - const linkTitle = ` "Open ${ - this.item.type === IssueOrPullRequestType.PullRequest ? 'Pull Request' : 'Issue' - } \\#${this.item.id} on ${this.item.provider.name}"`; + const linkTitle = ` "Open ${enriched.type === 'pullrequest' ? 'Pull Request' : 'Issue'} \\#${enriched.id} on ${ + enriched.provider.name + }"`; const tooltip = new MarkdownString( - `${IssueOrPullRequest.getMarkdownIcon(this.item)} [**${this.item.title.trim()}**](${ - this.item.url - }${linkTitle}) \\\n[#${this.item.id}](${this.item.url}${linkTitle}) was ${ - this.item.closed ? 'closed' : 'opened' + `${getIssueOrPullRequestMarkdownIcon(enriched)} [**${enriched.title.trim()}**](${ + enriched.url + }${linkTitle}) \\\n[#${enriched.id}](${enriched.url}${linkTitle}) was ${ + enriched.closed ? (enriched.state === 'merged' ? 'merged' : 'closed') : 'opened' } ${relativeTime}`, true, ); @@ -89,6 +119,6 @@ export class AutolinkedItemNode extends ViewNode { } } -function isIssueOrPullRequest(item: Autolink | IssueOrPullRequest): item is IssueOrPullRequest { - return 'closed' in item && typeof item.closed === 'boolean'; -} +// function isIssueOrPullRequest(item: Autolink | IssueOrPullRequest): item is IssueOrPullRequest { +// return 'closed' in item && typeof item.closed === 'boolean'; +// } diff --git a/src/views/nodes/autolinkedItemsNode.ts b/src/views/nodes/autolinkedItemsNode.ts index e4e2b73d51e08..f44c1b162296c 100644 --- a/src/views/nodes/autolinkedItemsNode.ts +++ b/src/views/nodes/autolinkedItemsNode.ts @@ -1,75 +1,59 @@ import { TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { Autolink } from '../../annotations/autolinks'; import { GitUri } from '../../git/gitUri'; -import type { IssueOrPullRequest } from '../../git/models/issue'; import type { GitLog } from '../../git/models/log'; -import { PullRequest } from '../../git/models/pullRequest'; -import { gate } from '../../system/decorators/gate'; -import { debug } from '../../system/decorators/log'; -import { union } from '../../system/iterable'; +import { isPullRequest } from '../../git/models/pullRequest'; +import { getSettledValue, pauseOnCancelOrTimeoutMapTuple } from '../../system/promise'; import type { ViewsWithCommits } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { AutolinkedItemNode } from './autolinkedItemNode'; import { LoadMoreNode, MessageNode } from './common'; import { PullRequestNode } from './pullRequestNode'; -import { ContextValues, ViewNode } from './viewNode'; let instanceId = 0; -export class AutolinkedItemsNode extends ViewNode { - private _children: ViewNode[] | undefined; +export class AutolinkedItemsNode extends CacheableChildrenViewNode<'autolinks', ViewsWithCommits> { private _instanceId: number; constructor( view: ViewsWithCommits, - protected override readonly parent: ViewNode, + protected override readonly parent: PageableViewNode, public readonly repoPath: string, public readonly log: GitLog, private expand: boolean, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('autolinks', GitUri.fromRepoPath(repoPath), view, parent); + this._instanceId = instanceId++; + this.updateContext({ autolinksId: String(this._instanceId) }); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { - return `${this.parent.id}:results:autolinked:${this._instanceId}`; + return this._uniqueId; } async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const commits = [...this.log.commits.values()]; let children: ViewNode[] | undefined; if (commits.length) { + const remote = await this.view.container.git.getBestRemoteWithProvider(this.repoPath); const combineMessages = commits.map(c => c.message).join('\n'); - let items: Map; - - const customAutolinks = this.view.container.autolinks.getAutolinks(combineMessages); + const [enrichedAutolinksResult /*, ...prsResults*/] = await Promise.allSettled([ + this.view.container.autolinks + .getEnrichedAutolinks(combineMessages, remote) + .then(enriched => + enriched != null ? pauseOnCancelOrTimeoutMapTuple(enriched, undefined, 250) : undefined, + ), + // Only get PRs from the first 100 commits to attempt to avoid hitting the api limits + // ...commits.slice(0, 100).map(c => this.remote.provider.getPullRequestForCommit(c.sha)), + ]); - const remote = await this.view.container.git.getBestRemoteWithProvider(this.repoPath); - if (remote != null) { - const providerAutolinks = this.view.container.autolinks.getAutolinks(combineMessages, remote); - - items = providerAutolinks; - - const [autolinkedMapResult /*, ...prsResults*/] = await Promise.allSettled([ - this.view.container.autolinks.getLinkedIssuesAndPullRequests(combineMessages, remote, { - autolinks: providerAutolinks, - }), - // Only get PRs from the first 100 commits to attempt to avoid hitting the api limits - // ...commits.slice(0, 100).map(c => this.remote.provider.getPullRequestForCommit(c.sha)), - ]); - - if (autolinkedMapResult.status === 'fulfilled' && autolinkedMapResult.value != null) { - for (const [id, issue] of autolinkedMapResult.value) { - items.set(id, issue); - } - } - - items = new Map(union(items, customAutolinks)); - } else { - items = customAutolinks; - } + const enrichedAutolinks = getSettledValue(enrichedAutolinksResult); // for (const result of prsResults) { // if (result.status !== 'fulfilled' || result.value == null) continue; @@ -77,29 +61,37 @@ export class AutolinkedItemsNode extends ViewNode { // items.set(result.value.id, result.value); // } - children = [...items.values()].map(item => - PullRequest.is(item) - ? new PullRequestNode(this.view, this, item, this.log.repoPath) - : new AutolinkedItemNode(this.view, this, this.repoPath, item), - ); + if (enrichedAutolinks?.size) { + children = [...enrichedAutolinks.values()].map(([issueOrPullRequest, autolink]) => + issueOrPullRequest != null && isPullRequest(issueOrPullRequest?.value) + ? new PullRequestNode(this.view, this, issueOrPullRequest.value, this.log.repoPath) + : new AutolinkedItemNode( + this.view, + this, + this.repoPath, + autolink, + issueOrPullRequest?.value, + ), + ); + } } - if (children == null || children.length === 0) { + if (!children?.length) { children = [new MessageNode(this.view, this, 'No autolinked issues or pull requests could be found.')]; } if (this.log.hasMore) { children.push( - new LoadMoreNode(this.view, this.parent as any, children[children.length - 1], { + new LoadMoreNode(this.view, this.parent, children[children.length - 1], { context: { expandAutolinks: true }, message: 'Load more commits to search for autolinks', }), ); } - this._children = children; + this.children = children; } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -112,12 +104,4 @@ export class AutolinkedItemsNode extends ViewNode { return item; } - - @gate() - @debug() - override refresh(reset: boolean = false) { - if (!reset) return; - - this._children = undefined; - } } diff --git a/src/views/nodes/branchNode.ts b/src/views/nodes/branchNode.ts index 0c55b43fb0338..e0cc46053c67f 100644 --- a/src/views/nodes/branchNode.ts +++ b/src/views/nodes/branchNode.ts @@ -1,26 +1,33 @@ -import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, window } from 'vscode'; -import type { ViewShowBranchComparison } from '../../configuration'; -import { ViewBranchesLayout } from '../../configuration'; -import { Colors, ContextKeys, GlyphChars } from '../../constants'; -import { getContext } from '../../context'; +import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; +import type { ViewShowBranchComparison } from '../../config'; +import { GlyphChars } from '../../constants'; +import type { Colors } from '../../constants.colors'; import type { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; +import { getTargetBranchName } from '../../git/models/branch'; import type { GitLog } from '../../git/models/log'; -import type { PullRequest } from '../../git/models/pullRequest'; -import { PullRequestState } from '../../git/models/pullRequest'; +import type { PullRequest, PullRequestState } from '../../git/models/pullRequest'; import type { GitBranchReference } from '../../git/models/reference'; -import { GitRemote, GitRemoteType } from '../../git/models/remote'; +import { getHighlanderProviders } from '../../git/models/remote'; +import type { Repository } from '../../git/models/repository'; import type { GitUser } from '../../git/models/user'; +import type { GitWorktree } from '../../git/models/worktree'; +import { getBranchIconPath } from '../../git/utils/branch-utils'; +import { getWorktreeBranchIconPath } from '../../git/utils/worktree-utils'; import { gate } from '../../system/decorators/gate'; -import { debug, log } from '../../system/decorators/log'; +import { log } from '../../system/decorators/log'; +import { memoize } from '../../system/decorators/memoize'; import { map } from '../../system/iterable'; import type { Deferred } from '../../system/promise'; import { defer, getSettledValue } from '../../system/promise'; import { pad } from '../../system/string'; -import type { BranchesView } from '../branchesView'; -import type { CommitsView } from '../commitsView'; -import { RemotesView } from '../remotesView'; -import type { RepositoriesView } from '../repositoriesView'; +import { getContext } from '../../system/vscode/context'; +import type { ViewsWithBranches } from '../viewBase'; +import { disposeChildren } from '../viewBase'; +import { createViewDecorationUri } from '../viewDecorationProvider'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefNode } from './abstract/viewRefNode'; import { BranchTrackingStatusNode } from './branchTrackingStatusNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode, MessageNode } from './common'; @@ -29,64 +36,56 @@ import { insertDateMarkers } from './helpers'; import { MergeStatusNode } from './mergeStatusNode'; import { PullRequestNode } from './pullRequestNode'; import { RebaseStatusNode } from './rebaseStatusNode'; -import { RepositoryNode } from './repositoryNode'; -import type { PageableViewNode, ViewNode } from './viewNode'; -import { ContextValues, ViewRefNode } from './viewNode'; type State = { pullRequest: PullRequest | null | undefined; pendingPullRequest: Promise | undefined; }; +type Options = { + expand: boolean; + limitCommits: boolean; + showAsCommits: boolean; + showComparison: false | ViewShowBranchComparison; + showStatusDecorationOnly: boolean; + showMergeCommits?: boolean; + showStatus: boolean; + showTracking: boolean; + authors?: GitUser[]; +}; + export class BranchNode - extends ViewRefNode + extends ViewRefNode<'branch', ViewsWithBranches, GitBranchReference, State> implements PageableViewNode { - static key = ':branch'; - static getId(repoPath: string, name: string, root: boolean): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${name})${root ? ':root' : ''}`; - } + limit: number | undefined; - private readonly options: { - expanded: boolean; - limitCommits: boolean; - showAsCommits: boolean; - showComparison: false | ViewShowBranchComparison; - showCurrent: boolean; - showStatus: boolean; - showTracking: boolean; - authors?: GitUser[]; - }; + private readonly options: Options; protected override splatted = true; constructor( uri: GitUri, - view: BranchesView | CommitsView | RemotesView | RepositoriesView, - parent: ViewNode, + view: ViewsWithBranches, + public override parent: ViewNode, + public readonly repo: Repository, public readonly branch: GitBranch, // Specifies that the node is shown as a root public readonly root: boolean, - - options?: { - expanded?: boolean; - limitCommits?: boolean; - showAsCommits?: boolean; - showComparison?: false | ViewShowBranchComparison; - showCurrent?: boolean; - showStatus?: boolean; - showTracking?: boolean; - authors?: GitUser[]; - }, + options?: Partial, ) { - super(uri, view, parent); + super('branch', uri, view, parent); + + this.updateContext({ repository: repo, branch: branch, root: root }); + this._uniqueId = getViewNodeId(this.type, this.context); + this.limit = this.view.getNodeLastKnownLimit(this); this.options = { - expanded: false, + expand: false, limitCommits: false, showAsCommits: false, showComparison: false, - // Hide the current branch checkmark when the node is displayed as a root - showCurrent: !this.root, + // Only show status decorations when the node is displayed as a root + showStatusDecorationOnly: this.root, // Don't show merge/rebase status info the node is displayed as a root showStatus: true, //!this.root, // Don't show tracking info the node is displayed as a root @@ -95,12 +94,21 @@ export class BranchNode }; } + override dispose() { + super.dispose(); + this.children = undefined; + } + + override get id(): string { + return this._uniqueId; + } + override toClipboard(): string { return this.branch.name; } - override get id(): string { - return BranchNode.getId(this.branch.repoPath, this.branch.name, this.root); + private get avoidCompacting(): boolean { + return this.root || this.current || this.worktree?.opened || this.branch.detached || this.branch.starred; } compacted: boolean = false; @@ -114,12 +122,7 @@ export class BranchNode const branchName = this.branch.getNameWithoutRemote(); return `${ - this.view.config.branches?.layout !== ViewBranchesLayout.Tree || - this.compacted || - this.root || - this.current || - this.branch.detached || - this.branch.starred + this.view.config.branches?.layout !== 'tree' || this.compacted || this.avoidCompacting ? branchName : this.branch.getBasename() }${this.branch.rebasing ? ' (Rebasing)' : ''}`; @@ -130,32 +133,61 @@ export class BranchNode } get treeHierarchy(): string[] { - return this.root || this.current || this.branch.detached || this.branch.starred - ? [this.branch.name] - : this.branch.getNameWithoutRemote().split('/'); + return this.avoidCompacting ? [this.branch.name] : this.branch.getNameWithoutRemote().split('/'); + } + + @memoize() + get worktree(): GitWorktree | undefined { + const worktree = this.context.worktreesByBranch?.get(this.branch.id); + return worktree?.isDefault ? undefined : worktree; } private _children: ViewNode[] | undefined; + protected get children(): ViewNode[] | undefined { + return this._children; + } + protected set children(value: ViewNode[] | undefined) { + if (this._children === value) return; + + disposeChildren(this._children, value); + this._children = value; + } async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const branch = this.branch; let onCompleted: Deferred | undefined; let pullRequest; + let pullRequestInsertIndex = 0; + + let comparison: CompareBranchNode | undefined; + let loadComparisonDefaultCompareWith = false; + if (this.options.showComparison !== false && this.view.type !== 'remotes') { + comparison = new CompareBranchNode( + this.uri, + this.view, + this, + branch, + this.options.showComparison, + this.splatted, + ); + loadComparisonDefaultCompareWith = comparison.compareWith == null; + } + let prPromise: Promise | undefined; if ( this.view.config.pullRequests.enabled && this.view.config.pullRequests.showForBranches && (branch.upstream != null || branch.remote) && - getContext(ContextKeys.HasConnectedRemotes) + getContext('gitlens:repos:withHostingIntegrationsConnected')?.includes(branch.repoPath) ) { pullRequest = this.getState('pullRequest'); if (pullRequest === undefined && this.getState('pendingPullRequest') === undefined) { onCompleted = defer(); - const prPromise = this.getAssociatedPullRequest( + prPromise = this.getAssociatedPullRequest( branch, - this.root ? { include: [PullRequestState.Open, PullRequestState.Merged] } : undefined, + this.root ? { include: ['opened', 'merged'] } : undefined, ); queueMicrotask(async () => { @@ -172,9 +204,9 @@ export class BranchNode clearTimeout(timeout); // If we found a pull request, insert it into the children cache (if loaded) and refresh the node - if (pr != null && this._children != null) { - this._children.splice( - this._children[0] instanceof CompareBranchNode ? 1 : 0, + if (pr != null && this.children != null) { + this.children.splice( + pullRequestInsertIndex, 0, new PullRequestNode(this.view, this, pr, branch), ); @@ -182,7 +214,7 @@ export class BranchNode // Refresh this node to add the pull request node or remove the spinner if (spinner || pr != null) { - this.view.triggerNodeChange(this); + this.view.triggerNodeChange(this.root ? this.parent ?? this : this); } }); } @@ -195,6 +227,8 @@ export class BranchNode mergeStatusResult, rebaseStatusResult, unpublishedCommitsResult, + baseResult, + targetResult, ] = await Promise.allSettled([ this.getLog(), this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, branch.name), @@ -211,37 +245,35 @@ export class BranchNode ? this.view.container.git.getLogRefsOnly(this.uri.repoPath!, { limit: 0, ref: range, + merges: this.options.showMergeCommits, }) : undefined, ) : undefined, + loadComparisonDefaultCompareWith + ? this.view.container.git.getBaseBranchName(this.branch.repoPath, this.branch.name) + : undefined, + loadComparisonDefaultCompareWith + ? getTargetBranchName(this.view.container, this.branch, { + associatedPullRequest: prPromise, + timeout: 100, + }) + : undefined, ]); const log = getSettledValue(logResult); if (log == null) return [new MessageNode(this.view, this, 'No commits could be found.')]; const children = []; - if (this.options.showComparison !== false && !(this.view instanceof RemotesView)) { - children.push( - new CompareBranchNode( - this.uri, - this.view, - this, - branch, - this.options.showComparison, - this.splatted, - ), - ); - } + const status = getSettledValue(statusResult); + const mergeStatus = getSettledValue(mergeStatusResult); + const rebaseStatus = getSettledValue(rebaseStatusResult); + const unpublishedCommits = getSettledValue(unpublishedCommitsResult); if (pullRequest != null) { children.push(new PullRequestNode(this.view, this, pullRequest, branch)); } - const status = getSettledValue(statusResult); - const mergeStatus = getSettledValue(mergeStatusResult); - const rebaseStatus = getSettledValue(rebaseStatusResult); - if (this.options.showStatus && mergeStatus != null) { children.push( new MergeStatusNode( @@ -273,11 +305,15 @@ export class BranchNode ref: branch.ref, repoPath: branch.repoPath, state: branch.state, - upstream: branch.upstream?.name, + upstream: branch.upstream, }; if (branch.upstream != null) { - if (this.root && !status.state.behind && !status.state.ahead) { + if (this.root && branch.upstream.missing) { + children.push( + new BranchTrackingStatusNode(this.view, this, branch, status, 'missing', this.root), + ); + } else if (this.root && !status.state.behind && !status.state.ahead) { children.push(new BranchTrackingStatusNode(this.view, this, branch, status, 'same', this.root)); } else { if (status.state.behind) { @@ -288,20 +324,63 @@ export class BranchNode if (status.state.ahead) { children.push( - new BranchTrackingStatusNode(this.view, this, branch, status, 'ahead', this.root), + new BranchTrackingStatusNode(this.view, this, branch, status, 'ahead', this.root, { + unpublishedCommits: unpublishedCommits, + }), ); } } - } else { + } else if (!branch.detached) { children.push(new BranchTrackingStatusNode(this.view, this, branch, status, 'none', this.root)); } } + pullRequestInsertIndex = 0; + + if (comparison != null) { + children.push(comparison); + + if (loadComparisonDefaultCompareWith) { + const baseBranchName = getSettledValue(baseResult); + const targetMaybeResult = getSettledValue(targetResult); + + let baseOrTargetBranchName: string | undefined; + if (targetMaybeResult?.paused) { + baseOrTargetBranchName = baseBranchName; + } else { + baseOrTargetBranchName = targetMaybeResult?.value ?? baseBranchName; + } + + if (baseOrTargetBranchName != null) { + void comparison.setDefaultCompareWith({ + ref: baseOrTargetBranchName, + label: baseOrTargetBranchName, + notation: '...', + type: 'branch', + checkedFiles: [], + }); + } + + if (targetMaybeResult?.paused) { + void targetMaybeResult.value.then(target => { + if (target == null) return; + + void comparison.setDefaultCompareWith({ + ref: target, + label: target, + notation: '...', + type: 'branch', + checkedFiles: [], + }); + }); + } + } + } + if (children.length !== 0) { children.push(new MessageNode(this.view, this, '', GlyphChars.Dash.repeat(2), '')); } - const unpublishedCommits = getSettledValue(unpublishedCommitsResult); const getBranchAndTagTips = getSettledValue(getBranchAndTagTipsResult); children.push( @@ -330,19 +409,30 @@ export class BranchNode ); } - this._children = children; + this.children = children; setTimeout(() => onCompleted?.fulfill(), 1); } - return this._children; + return this.children; } async getTreeItem(): Promise { this.splatted = false; - let tooltip: string | MarkdownString = `${ - this.current ? 'Current branch' : 'Branch' - } $(git-branch) ${this.branch.getNameWithoutRemote()}${this.branch.rebasing ? ' (Rebasing)' : ''}`; + const worktree = this.worktree; + const status = this.branch.status; + + let tooltip: string | MarkdownString = `$(git-branch) \`${this.branch.getNameWithoutRemote()}\`${ + this.current + ? this.branch.rebasing + ? ' \u00a0(_current, rebasing_)' + : ' \u00a0(_current_)' + : worktree?.opened + ? ' \u00a0(_worktree, opened_)' + : worktree + ? ' \u00a0(_worktree_)' + : '' + }`; let contextValue: string = ContextValues.Branch; if (this.current) { @@ -360,10 +450,18 @@ export class BranchNode if (this.options.showAsCommits) { contextValue += '+commits'; } + if (worktree != null) { + contextValue += '+worktree'; + } else if (this.context.worktreesByBranch?.get(this.branch.id)?.isDefault) { + contextValue += '+checkedout'; + } + // TODO@axosoft-ramint Temporary workaround, remove when our git commands work on closed repos. + if (this.repo.closed) { + contextValue += '+closed'; + } - let color: ThemeColor | undefined; + let iconColor: ThemeColor | undefined; let description; - let iconSuffix = ''; if (!this.branch.remote) { if (this.branch.upstream != null) { let arrows = GlyphChars.Dash; @@ -374,11 +472,11 @@ export class BranchNode let left; let right; for (const { type } of remote.urls) { - if (type === GitRemoteType.Fetch) { + if (type === 'fetch') { left = true; if (right) break; - } else if (type === GitRemoteType.Push) { + } else if (type === 'push') { right = true; if (left) break; @@ -409,39 +507,48 @@ export class BranchNode GlyphChars.Space } ${this.branch.upstream.name}`; - tooltip += ` is ${this.branch.getTrackingStatus({ - empty: this.branch.upstream.missing - ? `missing upstream $(git-branch) ${this.branch.upstream.name}` - : `up to date with $(git-branch) ${this.branch.upstream.name}${ - remote?.provider?.name ? ` on ${remote.provider.name}` : '' - }`, + tooltip += `\n\nBranch is ${this.branch.getTrackingStatus({ + empty: `${ + this.branch.upstream.missing ? 'missing upstream' : 'up to date with' + } \\\n $(git-branch) \`${this.branch.upstream.name}\`${ + remote?.provider?.name ? ` on ${remote.provider.name}` : '' + }`, expand: true, icons: true, separator: ', ', - suffix: ` $(git-branch) ${this.branch.upstream.name}${ + suffix: `\\\n$(git-branch) \`${this.branch.upstream.name}\`${ remote?.provider?.name ? ` on ${remote.provider.name}` : '' }`, })}`; - if (this.branch.state.ahead || this.branch.state.behind) { - if (this.branch.state.ahead) { + switch (status) { + case 'ahead': contextValue += '+ahead'; - color = new ThemeColor(Colors.UnpublishedChangesIconColor); - iconSuffix = '-green'; - } - if (this.branch.state.behind) { + iconColor = new ThemeColor('gitlens.decorations.branchAheadForegroundColor' satisfies Colors); + break; + case 'behind': contextValue += '+behind'; - color = new ThemeColor(Colors.UnpulledChangesIconColor); - iconSuffix = this.branch.state.ahead ? '-yellow' : '-red'; - } + iconColor = new ThemeColor('gitlens.decorations.branchBehindForegroundColor' satisfies Colors); + break; + case 'diverged': + contextValue += '+ahead+behind'; + iconColor = new ThemeColor( + 'gitlens.decorations.branchDivergedForegroundColor' satisfies Colors, + ); + break; + case 'upToDate': + iconColor = new ThemeColor( + 'gitlens.decorations.branchUpToDateForegroundColor' satisfies Colors, + ); + break; } } else { - const providers = GitRemote.getHighlanderProviders( + const providers = getHighlanderProviders( await this.view.container.git.getRemotesWithProviders(this.branch.repoPath), ); const providerName = providers?.length ? providers[0].name : undefined; - tooltip += ` hasn't been published to ${providerName ?? 'a remote'}`; + tooltip += `\n\nLocal branch, hasn't been published to ${providerName ?? 'a remote'}`; } } @@ -470,7 +577,7 @@ export class BranchNode const item = new TreeItem( this.label, - this.options.expanded ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, + this.options.expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, ); item.id = this.id; item.contextValue = contextValue; @@ -479,17 +586,28 @@ export class BranchNode pendingPullRequest != null ? new ThemeIcon('loading~spin') : this.options.showAsCommits - ? new ThemeIcon('git-commit', color) - : { - dark: this.view.container.context.asAbsolutePath(`images/dark/icon-branch${iconSuffix}.svg`), - light: this.view.container.context.asAbsolutePath(`images/light/icon-branch${iconSuffix}.svg`), - }; + ? new ThemeIcon('git-commit', iconColor) + : worktree != null + ? getWorktreeBranchIconPath(this.view.container, this.branch) + : getBranchIconPath(this.view.container, this.branch); item.tooltip = tooltip; - item.resourceUri = Uri.parse( - `gitlens-view://branch/status/${await this.branch.getStatus()}${ - this.options.showCurrent && this.current ? '/current' : '' - }`, - ); + + let localUnpublished = false; + if (status === 'local') { + // If there are any remotes then say this is unpublished, otherwise local + const remotes = await this.view.container.git.getRemotes(this.repoPath); + if (remotes.length) { + localUnpublished = true; + } + } + + item.resourceUri = createViewDecorationUri('branch', { + status: localUnpublished ? 'unpublished' : status, + current: this.current, + worktree: worktree != null ? { opened: worktree.opened } : undefined, + starred: this.branch.starred, + showStatusOnly: this.options.showStatusDecorationOnly, + }); return item; } @@ -506,10 +624,10 @@ export class BranchNode void this.view.refresh(true); } - @gate() - @debug() override refresh(reset?: boolean) { - this._children = undefined; + void super.refresh?.(reset); + + this.children = undefined; if (reset) { this._log = undefined; this.deleteState(); @@ -555,6 +673,7 @@ export class BranchNode limit: limit, ref: this.ref.ref, authors: this.options?.authors, + merges: this.options?.showMergeCommits, }); } @@ -565,7 +684,6 @@ export class BranchNode return this._log?.hasMore ?? true; } - limit: number | undefined = this.view.getNodeLastKnownLimit(this); @gate() async loadMore(limit?: number | { until?: any }) { let log = await window.withProgress( @@ -574,7 +692,7 @@ export class BranchNode }, () => this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; @@ -582,7 +700,7 @@ export class BranchNode this._log = log; this.limit = log?.count; - this._children = undefined; + this.children = undefined; void this.triggerChange(false); } } diff --git a/src/views/nodes/branchOrTagFolderNode.ts b/src/views/nodes/branchOrTagFolderNode.ts index 536037992b39b..61f473419d966 100644 --- a/src/views/nodes/branchOrTagFolderNode.ts +++ b/src/views/nodes/branchOrTagFolderNode.ts @@ -2,38 +2,32 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { GitUri } from '../../git/gitUri'; import type { HierarchicalItem } from '../../system/array'; import type { View } from '../viewBase'; -import { BranchNode } from './branchNode'; -import { RepositoryNode } from './repositoryNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; +import type { BranchNode } from './branchNode'; import type { TagNode } from './tagNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export class BranchOrTagFolderNode extends ViewNode { - static getId(repoPath: string, key: string | undefined, type: string, relativePath: string | undefined): string { - return `${RepositoryNode.getId(repoPath)}:${ - key === undefined ? type : `${key}:${type}` - }-folder(${relativePath})`; - } +export class BranchOrTagFolderNode extends ViewNode<'branch-tag-folder'> { constructor( view: View, - parent: ViewNode, - public readonly type: 'branch' | 'remote-branch' | 'tag', + protected override readonly parent: ViewNode, + public readonly folderType: 'branch' | 'remote-branch' | 'tag', + public readonly root: HierarchicalItem, public readonly repoPath: string, public readonly folderName: string, public readonly relativePath: string | undefined, - public readonly root: HierarchicalItem, - private readonly _key?: string, - private readonly _expanded: boolean = false, + private readonly _expand: boolean = false, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); - } + super('branch-tag-folder', GitUri.fromRepoPath(repoPath), view, parent); - override toClipboard(): string { - return this.folderName; + this._uniqueId = getViewNodeId(`${this.type}+${folderType}+${relativePath ?? folderName}`, this.context); } override get id(): string { - return BranchOrTagFolderNode.getId(this.repoPath, this._key, this.type, this.relativePath); + return this._uniqueId; + } + + override toClipboard(): string { + return this.folderName; } getChildren(): ViewNode[] { @@ -44,25 +38,24 @@ export class BranchOrTagFolderNode extends ViewNode { for (const folder of this.root.children.values()) { if (folder.value === undefined) { // If the folder contains the current branch, expand it by default - const expanded = folder.descendants?.some(n => n instanceof BranchNode && n.current); + const expand = folder.descendants?.some(n => n.is('branch') && (n.current || n.worktree?.opened)); children.push( new BranchOrTagFolderNode( this.view, - this.folderName ? this : this.parent!, - this.type, + this.folderName ? this : this.parent, + this.folderType, + folder, this.repoPath, folder.name, folder.relativePath, - folder, - this._key, - expanded, + expand, ), ); continue; } // Make sure to set the parent - (folder.value as any).parent = this.folderName ? this : this.parent!; + folder.value.parent = this.folderName ? this : this.parent; children.push(folder.value); } @@ -72,7 +65,7 @@ export class BranchOrTagFolderNode extends ViewNode { getTreeItem(): TreeItem { const item = new TreeItem( this.label, - this._expanded ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, + this._expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, ); item.id = this.id; item.contextValue = ContextValues.Folder; diff --git a/src/views/nodes/branchTrackingStatusFilesNode.ts b/src/views/nodes/branchTrackingStatusFilesNode.ts index deb42b6929df4..d0344a6ddc1b3 100644 --- a/src/views/nodes/branchTrackingStatusFilesNode.ts +++ b/src/views/nodes/branchTrackingStatusFilesNode.ts @@ -1,99 +1,103 @@ import { TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { ViewFilesLayout } from '../../configuration'; +import type { FilesComparison } from '../../git/actions/commit'; import { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; import type { GitFileWithCommit } from '../../git/models/file'; -import { GitRevision } from '../../git/models/reference'; -import { groupBy, makeHierarchical } from '../../system/array'; -import { filter, flatMap, map } from '../../system/iterable'; +import { createRevisionRange } from '../../git/models/reference'; +import { makeHierarchical } from '../../system/array'; +import { filter, flatMap, groupByMap, map } from '../../system/iterable'; import { joinPaths, normalizePath } from '../../system/path'; import { pluralize, sortCompare } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; -import { BranchNode } from './branchNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import type { BranchTrackingStatus } from './branchTrackingStatusNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; import { StatusFileNode } from './statusFileNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export class BranchTrackingStatusFilesNode extends ViewNode { - static key = ':status-branch:files'; - static getId(repoPath: string, name: string, root: boolean, upstream: string, direction: string): string { - return `${BranchNode.getId(repoPath, name, root)}${this.key}(${upstream}|${direction})`; - } - - readonly repoPath: string; +export class BranchTrackingStatusFilesNode extends ViewNode<'tracking-status-files', ViewsWithCommits> { constructor( view: ViewsWithCommits, - parent: ViewNode, + protected override readonly parent: ViewNode, public readonly branch: GitBranch, public readonly status: Required, public readonly direction: 'ahead' | 'behind', - // Specifies that the node is shown as a root - private readonly root: boolean = false, ) { - super(GitUri.fromRepoPath(status.repoPath), view, parent); - this.repoPath = status.repoPath; + super('tracking-status-files', GitUri.fromRepoPath(status.repoPath), view, parent); + + this.updateContext({ branch: branch, branchStatus: status, branchStatusUpstreamType: direction }); + this._uniqueId = getViewNodeId(this.type, this.context); } - override get id(): string { - return BranchTrackingStatusFilesNode.getId( - this.status.repoPath, - this.status.ref, - this.root, - this.status.upstream, - this.direction, - ); + get ref1(): string { + return this.branch.ref; } - async getChildren(): Promise { + get ref2(): string { + return this.status.upstream?.name; + } + + get repoPath(): string { + return this.status.repoPath; + } + + async getFilesComparison(): Promise { + const grouped = await this.getGroupedFiles(); + return { + files: [...map(grouped, ([, files]) => files[files.length - 1])], + repoPath: this.repoPath, + ref1: this.ref1, + ref2: this.ref2, + title: this.direction === 'ahead' ? `Changes to push to ${this.ref2}` : `Changes to pull from ${this.ref2}`, + }; + } + + private async getGroupedFiles(): Promise> { const log = await this.view.container.git.getLog(this.repoPath, { limit: 0, - ref: GitRevision.createRange( - this.status.upstream, - this.branch.ref, - this.direction === 'behind' ? '...' : '..', - ), + ref: + this.direction === 'behind' + ? createRevisionRange(this.ref1, this.ref2, '..') + : createRevisionRange(this.ref2, this.ref1, '..'), }); + if (log == null) return new Map(); - let files: GitFileWithCommit[]; - - if (log != null) { - await Promise.allSettled( - map( - filter(log.commits.values(), c => c.files == null), - c => c.ensureFullDetails(), - ), - ); + await Promise.allSettled( + map( + filter(log.commits.values(), c => c.files == null), + c => c.ensureFullDetails(), + ), + ); - files = [ - ...flatMap( - log.commits.values(), - c => c.files?.map(f => ({ ...f, commit: c })) ?? [], - ), - ]; - } else { - files = []; - } + const files = [ + ...flatMap(log.commits.values(), c => c.files?.map(f => ({ ...f, commit: c })) ?? []), + ]; files.sort((a, b) => b.commit.date.getTime() - a.commit.date.getTime()); - const groups = groupBy(files, s => s.path); - - let children: FileNode[] = Object.values(groups).map( - files => - new StatusFileNode( - this.view, - this, - files[files.length - 1], - this.repoPath, - files.map(s => s.commit), - this.direction, - ), - ); + const groups = groupByMap(files, s => s.path); + return groups; + } + + async getChildren(): Promise { + const files = await this.getGroupedFiles(); + + let children: FileNode[] = [ + ...map( + files.values(), + files => + new StatusFileNode( + this.view, + this, + files[files.length - 1], + this.repoPath, + files.map(s => s.commit), + this.direction, + ), + ), + ]; - if (this.view.config.files.layout !== ViewFilesLayout.List) { + if (this.view.config.files.layout !== 'list') { const hierarchy = makeHierarchical( children, n => n.uri.relativePath.split('/'), @@ -101,7 +105,7 @@ export class BranchTrackingStatusFilesNode extends ViewNode { this.view.config.files.compact, ); - const root = new FolderNode(this.view, this, this.repoPath, '', hierarchy, false); + const root = new FolderNode(this.view, this, hierarchy, this.repoPath, '', undefined, false); children = root.getChildren() as FileNode[]; } else { children.sort((a, b) => a.priority - b.priority || sortCompare(a.label!, b.label!)); @@ -113,7 +117,7 @@ export class BranchTrackingStatusFilesNode extends ViewNode { async getTreeItem(): Promise { const stats = await this.view.container.git.getChangedFilesCount( this.repoPath, - `${this.status.upstream}${this.direction === 'behind' ? '..' : '...'}`, + this.direction === 'behind' ? `${this.ref1}...${this.ref2}` : `${this.ref2}...`, ); const files = stats?.changedFiles ?? 0; diff --git a/src/views/nodes/branchTrackingStatusNode.ts b/src/views/nodes/branchTrackingStatusNode.ts index 7af300b299fa0..2a0f44dcc902b 100644 --- a/src/views/nodes/branchTrackingStatusNode.ts +++ b/src/views/nodes/branchTrackingStatusNode.ts @@ -1,81 +1,107 @@ import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; -import { Colors } from '../../constants'; +import type { Colors } from '../../constants.colors'; +import type { FilesComparison } from '../../git/actions/commit'; import { GitUri } from '../../git/gitUri'; import type { GitBranch, GitTrackingState } from '../../git/models/branch'; import { getRemoteNameFromBranchName } from '../../git/models/branch'; import type { GitLog } from '../../git/models/log'; -import { GitRevision } from '../../git/models/reference'; -import { GitRemote } from '../../git/models/remote'; +import { createRevisionRange } from '../../git/models/reference'; +import type { GitRemote } from '../../git/models/remote'; +import { getHighlanderProviders } from '../../git/models/remote'; +import { getUpstreamStatus } from '../../git/models/status'; import { fromNow } from '../../system/date'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; -import { first, map } from '../../system/iterable'; +import { first, last, map } from '../../system/iterable'; import { pluralize } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; -import { BranchNode } from './branchNode'; +import type { PageableViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import { BranchTrackingStatusFilesNode } from './branchTrackingStatusFilesNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode } from './common'; import { insertDateMarkers } from './helpers'; -import type { PageableViewNode } from './viewNode'; -import { ContextValues, ViewNode } from './viewNode'; export interface BranchTrackingStatus { ref: string; repoPath: string; state: GitTrackingState; - upstream?: string; + upstream?: { name: string; missing: boolean }; } -export class BranchTrackingStatusNode extends ViewNode implements PageableViewNode { - static key = ':status-branch:upstream'; - static getId( - repoPath: string, - name: string, - root: boolean, - upstream: string | undefined, - upstreamType: string, - ): string { - return `${BranchNode.getId(repoPath, name, root)}${this.key}(${upstream ?? ''}):${upstreamType}`; - } - - private readonly options: { - showAheadCommits?: boolean; - }; +export class BranchTrackingStatusNode + extends ViewNode<'tracking-status', ViewsWithCommits> + implements PageableViewNode +{ + limit: number | undefined; constructor( view: ViewsWithCommits, - parent: ViewNode, + protected override readonly parent: ViewNode, public readonly branch: GitBranch, public readonly status: BranchTrackingStatus, - public readonly upstreamType: 'ahead' | 'behind' | 'same' | 'none', + public readonly upstreamType: 'ahead' | 'behind' | 'same' | 'missing' | 'none', // Specifies that the node is shown as a root public readonly root: boolean = false, - options?: { + private readonly options?: { showAheadCommits?: boolean; + unpublishedCommits?: Set; }, ) { - super(GitUri.fromRepoPath(status.repoPath), view, parent); - - this.options = { showAheadCommits: false, ...options }; + super('tracking-status', GitUri.fromRepoPath(status.repoPath), view, parent); + + this.updateContext({ + branch: branch, + branchStatus: status, + branchStatusUpstreamType: upstreamType, + root: root, + }); + this._uniqueId = getViewNodeId(this.type, this.context); + this.limit = this.view.getNodeLastKnownLimit(this); } override get id(): string { - return BranchTrackingStatusNode.getId( - this.status.repoPath, - this.status.ref, - this.root, - this.status.upstream, - this.upstreamType, - ); + return this._uniqueId; } get repoPath(): string { return this.uri.repoPath!; } + async getFilesComparison(): Promise { + // if we are ahead we don't actually add the files node, just each of its children individually + if (this.upstreamType === 'ahead') { + const node = new BranchTrackingStatusFilesNode( + this.view, + this, + this.branch, + this.status as Required, + this.upstreamType, + ); + + const comparison = await node?.getFilesComparison(); + if (comparison == null) return undefined; + + // Get the oldest unpublished (unpushed) commit + const ref = this.options?.unpublishedCommits != null ? last(this.options.unpublishedCommits) : undefined; + if (ref == null) return undefined; + + const resolved = await this.view.container.git.resolveReference(this.repoPath, `${ref}^`); + return { + ...comparison, + ref1: resolved, + ref2: comparison.ref1, + title: `Changes to push to ${comparison.ref2}`, + }; + } + + const children = await this.getChildren(); + const node = children.find(c => c.is('tracking-status-files')); + return node?.getFilesComparison(); + } + async getChildren(): Promise { - if (this.upstreamType === 'same' || this.upstreamType === 'none') return []; + if (this.upstreamType === 'same' || this.upstreamType === 'missing' || this.upstreamType === 'none') return []; const log = await this.getLog(); if (log == null) return []; @@ -103,7 +129,7 @@ export class BranchTrackingStatusNode extends ViewNode impleme let showFiles = true; if ( - !this.options.showAheadCommits && + !this.options?.showAheadCommits && this.upstreamType === 'ahead' && this.status.upstream && this.status.state.ahead > 0 @@ -117,7 +143,6 @@ export class BranchTrackingStatusNode extends ViewNode impleme this.branch, this.status as Required, this.upstreamType, - this.root, ).getChildren()), ); } else { @@ -135,16 +160,13 @@ export class BranchTrackingStatusNode extends ViewNode impleme } if (showFiles) { - children.splice( - 0, - 0, + children.unshift( new BranchTrackingStatusFilesNode( this.view, this, this.branch, this.status as Required, this.upstreamType, - this.root, ), ); } @@ -155,11 +177,31 @@ export class BranchTrackingStatusNode extends ViewNode impleme async getTreeItem(): Promise { let lastFetched = 0; - if (this.upstreamType !== 'none') { + if (this.upstreamType !== 'missing' && this.upstreamType !== 'none') { const repo = this.view.container.git.getRepository(this.repoPath); lastFetched = (await repo?.getLastFetched()) ?? 0; } + function getBranchStatus(this: BranchTrackingStatusNode, remote: GitRemote | undefined) { + return `$(git-branch) \`${this.branch.name}\` is ${getUpstreamStatus( + this.status.upstream, + this.status.state, + { + empty: this.status.upstream!.missing + ? `missing upstream $(git-branch) \`${this.status.upstream!.name}\`` + : `up to date with $(git-branch) \`${this.status.upstream!.name}\`${ + remote?.provider?.name ? ` on ${remote.provider.name}` : '' + }`, + expand: true, + icons: true, + separator: ', ', + suffix: ` $(git-branch) \`${this.status.upstream!.name}\`${ + remote?.provider?.name ? ` on ${remote.provider.name}` : '' + }`, + }, + )}`; + } + let label; let description; let collapsibleState; @@ -170,55 +212,61 @@ export class BranchTrackingStatusNode extends ViewNode impleme case 'ahead': { const remote = await this.branch.getRemote(); - label = `Changes to push to ${remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!)}${ - remote?.provider?.name ? ` on ${remote?.provider.name}` : '' + label = `Outgoing changes to ${ + remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!.name) }`; description = pluralize('commit', this.status.state.ahead); - tooltip = `Branch $(git-branch) ${this.branch.name} is ${pluralize('commit', this.status.state.ahead, { - infix: '$(arrow-up) ', - })} ahead of $(git-branch) ${this.status.upstream}${ - remote?.provider?.name ? ` on ${remote.provider.name}` : '' - }`; + tooltip = `${pluralize('commit', this.status.state.ahead)} to push to \`${ + this.status.upstream!.name + }\`${remote?.provider?.name ? ` on ${remote?.provider.name}` : ''}\\\n${getBranchStatus.call( + this, + remote, + )}`; collapsibleState = TreeItemCollapsibleState.Collapsed; contextValue = this.root ? ContextValues.StatusAheadOfUpstream : ContextValues.BranchStatusAheadOfUpstream; - icon = new ThemeIcon('cloud-upload', new ThemeColor(Colors.UnpublishedChangesIconColor)); + icon = new ThemeIcon( + 'cloud-upload', + new ThemeColor('gitlens.unpublishedChangesIconColor' satisfies Colors), + ); break; } case 'behind': { const remote = await this.branch.getRemote(); - label = `Changes to pull from ${remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!)}${ - remote?.provider?.name ? ` on ${remote.provider.name}` : '' + label = `Incoming changes from ${ + remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!.name) }`; description = pluralize('commit', this.status.state.behind); - tooltip = `Branch $(git-branch) ${this.branch.name} is ${pluralize('commit', this.status.state.behind, { - infix: '$(arrow-down) ', - })} behind $(git-branch) ${this.status.upstream}${ - remote?.provider?.name ? ` on ${remote.provider.name}` : '' - }`; + tooltip = `${pluralize('commit', this.status.state.behind)} to pull from \`${ + this.status.upstream!.name + }\`${remote?.provider?.name ? ` on ${remote.provider.name}` : ''}\\\n${getBranchStatus.call( + this, + remote, + )}`; collapsibleState = TreeItemCollapsibleState.Collapsed; contextValue = this.root ? ContextValues.StatusBehindUpstream : ContextValues.BranchStatusBehindUpstream; - icon = new ThemeIcon('cloud-download', new ThemeColor(Colors.UnpulledChangesIconColor)); + icon = new ThemeIcon( + 'cloud-download', + new ThemeColor('gitlens.unpulledChangesIconColor' satisfies Colors), + ); break; } case 'same': { const remote = await this.branch.getRemote(); - label = `Up to date with ${remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!)}${ + label = `Up to date with ${remote?.name ?? getRemoteNameFromBranchName(this.status.upstream!.name)}${ remote?.provider?.name ? ` on ${remote.provider.name}` : '' }`; description = lastFetched ? `Last fetched ${fromNow(new Date(lastFetched))}` : ''; - tooltip = `Branch $(git-branch) ${this.branch.name} is up to date with $(git-branch) ${ - this.status.upstream - }${remote?.provider?.name ? ` on ${remote.provider.name}` : ''}`; + tooltip = getBranchStatus.call(this, remote); collapsibleState = TreeItemCollapsibleState.None; contextValue = this.root @@ -228,21 +276,37 @@ export class BranchTrackingStatusNode extends ViewNode impleme break; } + case 'missing': { + const remote = await this.branch.getRemote(); + + label = `Missing upstream branch${remote?.provider?.name ? ` on ${remote.provider.name}` : ''}`; + description = this.status.upstream!.name; + tooltip = getBranchStatus.call(this, remote); + + collapsibleState = TreeItemCollapsibleState.None; + contextValue = this.root + ? ContextValues.StatusMissingUpstream + : ContextValues.BranchStatusSameAsUpstream; + icon = new ThemeIcon( + 'warning', + new ThemeColor('gitlens.decorations.branchMissingUpstreamForegroundColor' satisfies Colors), + ); + + break; + } case 'none': { const remotes = await this.view.container.git.getRemotesWithProviders(this.branch.repoPath); - const providers = GitRemote.getHighlanderProviders(remotes); + const providers = getHighlanderProviders(remotes); const providerName = providers?.length ? providers[0].name : undefined; label = `Publish ${this.branch.name} to ${providerName ?? 'a remote'}`; - tooltip = `Branch $(git-branch) ${this.branch.name} hasn't been published to ${ - providerName ?? 'a remote' - }`; + tooltip = `\`${this.branch.name}\` hasn't been published to ${providerName ?? 'a remote'}`; collapsibleState = TreeItemCollapsibleState.None; contextValue = this.root ? ContextValues.StatusNoUpstream : ContextValues.BranchStatusNoUpstream; icon = new ThemeIcon( 'cloud-upload', - remotes.length ? new ThemeColor(Colors.UnpublishedChangesIconColor) : undefined, + remotes.length ? new ThemeColor('gitlens.unpublishedChangesIconColor' satisfies Colors) : undefined, ); break; @@ -282,8 +346,8 @@ export class BranchTrackingStatusNode extends ViewNode impleme if (this._log == null) { const range = this.upstreamType === 'ahead' - ? GitRevision.createRange(this.status.upstream, this.status.ref) - : GitRevision.createRange(this.status.ref, this.status.upstream); + ? createRevisionRange(this.status.upstream?.name, this.status.ref, '..') + : createRevisionRange(this.status.ref, this.status.upstream?.name, '..'); this._log = await this.view.container.git.getLog(this.uri.repoPath!, { limit: this.limit ?? this.view.config.defaultItemLimit, @@ -298,7 +362,6 @@ export class BranchTrackingStatusNode extends ViewNode impleme return this._log?.hasMore ?? true; } - limit: number | undefined = this.view.getNodeLastKnownLimit(this); @gate() async loadMore(limit?: number | { until?: any }) { let log = await window.withProgress( @@ -307,7 +370,7 @@ export class BranchTrackingStatusNode extends ViewNode impleme }, () => this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/branchesNode.ts b/src/views/nodes/branchesNode.ts index 55dadf482bde7..0e555992eea13 100644 --- a/src/views/nodes/branchesNode.ts +++ b/src/views/nodes/branchesNode.ts @@ -1,37 +1,32 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { ViewBranchesLayout } from '../../configuration'; import { GitUri } from '../../git/gitUri'; import type { Repository } from '../../git/models/repository'; +import { getOpenedWorktreesByBranch } from '../../git/models/worktree'; import { makeHierarchical } from '../../system/array'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; -import type { BranchesView } from '../branchesView'; -import { RepositoriesView } from '../repositoriesView'; +import type { ViewsWithBranchesNode } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { BranchNode } from './branchNode'; import { BranchOrTagFolderNode } from './branchOrTagFolderNode'; import { MessageNode } from './common'; -import { RepositoryNode } from './repositoryNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export class BranchesNode extends ViewNode { - static key = ':branches'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; - } - - private _children: ViewNode[] | undefined; +export class BranchesNode extends CacheableChildrenViewNode<'branches', ViewsWithBranchesNode> { constructor( uri: GitUri, - view: BranchesView | RepositoriesView, - parent: ViewNode, + view: ViewsWithBranchesNode, + protected override readonly parent: ViewNode, public readonly repo: Repository, ) { - super(uri, view, parent); + super('branches', uri, view, parent); + + this.updateContext({ repository: repo }); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { - return BranchesNode.getId(this.repo.path); + return this._uniqueId; } get repoPath(): string { @@ -39,25 +34,38 @@ export class BranchesNode extends ViewNode { } async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const branches = await this.repo.getBranches({ // only show local branches filter: b => !b.remote, - sort: { current: false }, + sort: this.view.config.showCurrentBranchOnTop + ? { + current: true, + openedWorktreesByBranch: getOpenedWorktreesByBranch(this.context.worktreesByBranch), + } + : { current: false }, }); if (branches.values.length === 0) return [new MessageNode(this.view, this, 'No branches could be found.')]; // TODO@eamodio handle paging const branchNodes = branches.values.map( b => - new BranchNode(GitUri.fromRepoPath(this.uri.repoPath!, b.ref), this.view, this, b, false, { - showComparison: - this.view instanceof RepositoriesView - ? this.view.config.branches.showBranchComparison - : this.view.config.showBranchComparison, - }), + new BranchNode( + GitUri.fromRepoPath(this.uri.repoPath!, b.ref), + this.view, + this, + this.repo, + b, + false, + { + showComparison: + this.view.type === 'repositories' + ? this.view.config.branches.showBranchComparison + : this.view.config.showBranchComparison, + }, + ), ); - if (this.view.config.branches.layout === ViewBranchesLayout.List) return branchNodes; + if (this.view.config.branches.layout === 'list') return branchNodes; const hierarchy = makeHierarchical( branchNodes, @@ -70,20 +78,11 @@ export class BranchesNode extends ViewNode { }, ); - const root = new BranchOrTagFolderNode( - this.view, - this, - 'branch', - this.repo.path, - '', - undefined, - hierarchy, - 'branches', - ); - this._children = root.getChildren(); + const root = new BranchOrTagFolderNode(this.view, this, 'branch', hierarchy, this.repo.path, '', undefined); + this.children = root.getChildren(); } - return this._children; + return this.children; } async getTreeItem(): Promise { @@ -93,14 +92,17 @@ export class BranchesNode extends ViewNode { if (await this.repo.hasRemotes()) { item.contextValue += '+remotes'; } + // TODO@axosoft-ramint Temporary workaround, remove when our git commands work on closed repos. + if (this.repo.closed) { + item.contextValue += '+closed'; + } item.iconPath = new ThemeIcon('git-branch'); return item; } - @gate() @debug() override refresh() { - this._children = undefined; + super.refresh(true); } } diff --git a/src/views/nodes/codeSuggestionsNode.ts b/src/views/nodes/codeSuggestionsNode.ts new file mode 100644 index 0000000000000..860dd3b232fba --- /dev/null +++ b/src/views/nodes/codeSuggestionsNode.ts @@ -0,0 +1,60 @@ +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { GitUri } from '../../git/gitUri'; +import type { PullRequest } from '../../git/models/pullRequest'; +import type { ViewsWithCommits } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { MessageNode } from './common'; +import { DraftNode } from './draftNode'; + +export class CodeSuggestionsNode extends CacheableChildrenViewNode<'drafts-code-suggestions', ViewsWithCommits> { + constructor( + view: ViewsWithCommits, + protected override parent: ViewNode, + public readonly repoPath: string, + private readonly pullRequest: PullRequest, + ) { + super('drafts-code-suggestions', GitUri.fromRepoPath(repoPath), view, parent); + + this._uniqueId = getViewNodeId(this.type, this.context); + } + + override get id(): string { + return this._uniqueId; + } + + async getChildren(): Promise { + if (this.children == null) { + const drafts = await this.getSuggestedChanges(); + + let children: ViewNode[] | undefined; + if (drafts?.length) { + drafts.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + children = drafts.map(d => new DraftNode(this.uri, this.view, this, d)); + } + + if (!children?.length) { + children = [new MessageNode(this.view, this, 'No code suggestions')]; + } + + this.children = children; + } + + return this.children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Code Suggestions', TreeItemCollapsibleState.Collapsed); + item.contextValue = ContextValues.CodeSuggestions; + return item; + } + + private async getSuggestedChanges() { + const repo = this.view.container.git.getRepository(this.repoPath); + if (repo == null) return []; + + const drafts = await this.view.container.drafts.getCodeSuggestions(this.pullRequest, repo); + return drafts; + } +} diff --git a/src/views/nodes/commitFileNode.ts b/src/views/nodes/commitFileNode.ts index 211397d275824..b344b0bb4e8dd 100644 --- a/src/views/nodes/commitFileNode.ts +++ b/src/views/nodes/commitFileNode.ts @@ -1,45 +1,52 @@ import type { Command, Selection } from 'vscode'; import { MarkdownString, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; -import type { DiffWithPreviousCommandArgs } from '../../commands'; -import { Commands } from '../../constants'; +import type { DiffWithPreviousCommandArgs } from '../../commands/diffWithPrevious'; +import { Schemes } from '../../constants'; +import { Commands } from '../../constants.commands'; +import type { TreeViewRefFileNodeTypes } from '../../constants.views'; import { StatusFileFormatter } from '../../git/formatters/statusFormatter'; import { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; import type { GitCommit } from '../../git/models/commit'; -import { GitFile } from '../../git/models/file'; +import type { GitFile } from '../../git/models/file'; +import { getGitFileStatusIcon } from '../../git/models/file'; import type { GitRevisionReference } from '../../git/models/reference'; -import { joinPaths, relativeDir } from '../../system/path'; -import type { FileHistoryView } from '../fileHistoryView'; -import type { View, ViewsWithCommits } from '../viewBase'; -import type { ViewNode } from './viewNode'; -import { ContextValues, ViewRefFileNode } from './viewNode'; - -export class CommitFileNode extends ViewRefFileNode { - static key = ':file'; - static getId(parent: ViewNode, path: string): string { - return `${parent.id}${this.key}(${path})`; - } - +import { joinPaths } from '../../system/path'; +import { relativeDir } from '../../system/vscode/path'; +import type { ViewsWithCommits, ViewsWithStashes } from '../viewBase'; +import { createViewDecorationUri } from '../viewDecorationProvider'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefFileNode } from './abstract/viewRefNode'; + +export abstract class CommitFileNodeBase< + Type extends TreeViewRefFileNodeTypes, + TView extends ViewsWithCommits | ViewsWithStashes, +> extends ViewRefFileNode { constructor( + type: Type, view: TView, parent: ViewNode, file: GitFile, public commit: GitCommit, - private readonly _options: { + private readonly options?: { branch?: GitBranch; selection?: Selection; unpublished?: boolean; - } = {}, + }, ) { - super(GitUri.fromFile(file, commit.repoPath, commit.sha), view, parent, file); - } + super(type, GitUri.fromFile(file, commit.repoPath, commit.sha), view, parent, file); - override toClipboard(): string { - return this.file.path; + this.updateContext({ commit: commit, file: file }); + this._uniqueId = getViewNodeId(type, this.context); } override get id(): string { - return CommitFileNode.getId(this.parent, this.file.path); + return this._uniqueId; + } + + override toClipboard(): string { + return this.file.path; } get priority(): number { @@ -75,15 +82,28 @@ export class CommitFileNode { + constructor( + view: ViewsWithCommits, + parent: ViewNode, + file: GitFile, + commit: GitCommit, + options?: { + branch?: GitBranch; + selection?: Selection; + unpublished?: boolean; + }, + ) { + super('commit-file', view, parent, file, commit, options); + } +} diff --git a/src/views/nodes/commitNode.ts b/src/views/nodes/commitNode.ts index 40cd653f0a9c2..38b0bdb210712 100644 --- a/src/views/nodes/commitNode.ts +++ b/src/views/nodes/commitNode.ts @@ -1,61 +1,65 @@ -import type { Command } from 'vscode'; +import type { CancellationToken, Command } from 'vscode'; import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { DiffWithPreviousCommandArgs } from '../../commands'; -import { configuration, ViewFilesLayout } from '../../configuration'; -import { Colors, Commands, ContextKeys } from '../../constants'; -import { getContext } from '../../context'; +import type { DiffWithPreviousCommandArgs } from '../../commands/diffWithPrevious'; +import type { Colors } from '../../constants.colors'; +import { Commands } from '../../constants.commands'; import { CommitFormatter } from '../../git/formatters/commitFormatter'; import type { GitBranch } from '../../git/models/branch'; import type { GitCommit } from '../../git/models/commit'; import type { PullRequest } from '../../git/models/pullRequest'; import type { GitRevisionReference } from '../../git/models/reference'; import type { GitRemote } from '../../git/models/remote'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +import type { RemoteProvider } from '../../git/remotes/remoteProvider'; import { makeHierarchical } from '../../system/array'; -import { gate } from '../../system/decorators/gate'; import { joinPaths, normalizePath } from '../../system/path'; import type { Deferred } from '../../system/promise'; -import { defer, getSettledValue } from '../../system/promise'; +import { defer, getSettledValue, pauseOnCancelOrTimeoutMapTuplePromise } from '../../system/promise'; import { sortCompare } from '../../system/string'; -import { FileHistoryView } from '../fileHistoryView'; -import { TagsView } from '../tagsView'; +import { configuration } from '../../system/vscode/configuration'; +import { getContext } from '../../system/vscode/context'; +import type { FileHistoryView } from '../fileHistoryView'; import type { ViewsWithCommits } from '../viewBase'; +import { disposeChildren } from '../viewBase'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefNode } from './abstract/viewRefNode'; import { CommitFileNode } from './commitFileNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; import { PullRequestNode } from './pullRequestNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, ViewRefNode } from './viewNode'; type State = { pullRequest: PullRequest | null | undefined; pendingPullRequest: Promise | undefined; }; -export class CommitNode extends ViewRefNode { - static key = ':commit'; - static getId(parent: ViewNode, sha: string): string { - return `${parent.id}${this.key}(${sha})`; - } - +export class CommitNode extends ViewRefNode<'commit', ViewsWithCommits | FileHistoryView, GitRevisionReference, State> { constructor( view: ViewsWithCommits | FileHistoryView, - protected override readonly parent: ViewNode, + parent: ViewNode, public readonly commit: GitCommit, - private readonly unpublished?: boolean, + protected readonly unpublished?: boolean, public readonly branch?: GitBranch, - private readonly getBranchAndTagTips?: (sha: string, options?: { compact?: boolean }) => string | undefined, - private readonly _options: { expand?: boolean } = {}, + protected readonly getBranchAndTagTips?: (sha: string, options?: { compact?: boolean }) => string | undefined, + protected readonly _options: { expand?: boolean } = {}, ) { - super(commit.getGitUri(), view, parent); + super('commit', commit.getGitUri(), view, parent); + + this.updateContext({ commit: commit }); + this._uniqueId = getViewNodeId(this.type, this.context); } - override toClipboard(): string { - return `${this.commit.shortSha}: ${this.commit.summary}`; + override dispose() { + super.dispose(); + this.children = undefined; } override get id(): string { - return CommitNode.getId(this.parent, this.commit.sha); + return this._uniqueId; + } + + override toClipboard(): string { + return `${this.commit.shortSha}: ${this.commit.summary}`; } get isTip(): boolean { @@ -66,23 +70,33 @@ export class CommitNode extends ViewRefNode { - if (this._children == null) { + if (this.children == null) { const commit = this.commit; - let children: (PullRequestNode | FileNode)[] = []; + let children: ViewNode[] = []; let onCompleted: Deferred | undefined; let pullRequest; if ( - !(this.view instanceof TagsView) && - !(this.view instanceof FileHistoryView) && + this.view.type !== 'tags' && !this.unpublished && - getContext(ContextKeys.HasConnectedRemotes) && - this.view.config.pullRequests.enabled && - this.view.config.pullRequests.showForCommits + this.view.config.pullRequests?.enabled && + this.view.config.pullRequests?.showForCommits && + // If we are in the context of a PR node, don't show the pull request node again + this.context.pullRequest == null && + getContext('gitlens:repos:withHostingIntegrationsConnected')?.includes(commit.repoPath) ) { pullRequest = this.getState('pullRequest'); if (pullRequest === undefined && this.getState('pendingPullRequest') === undefined) { @@ -103,12 +117,8 @@ export class CommitNode extends ViewRefNode n.uri.relativePath.split('/'), @@ -132,21 +142,21 @@ export class CommitNode extends ViewRefNode sortCompare(a.label!, b.label!)); } if (pullRequest != null) { - children.splice(0, 0, new PullRequestNode(this.view as ViewsWithCommits, this, pullRequest, commit)); + children.unshift(new PullRequestNode(this.view, this, pullRequest, commit)); } - this._children = children; + this.children = children; setTimeout(() => onCompleted?.fulfill(), 1); } - return this._children; + return this.children; } async getTreeItem(): Promise { @@ -177,10 +187,10 @@ export class CommitNode extends ViewRefNode { + override async resolveTreeItem(item: TreeItem, token: CancellationToken): Promise { if (item.tooltip == null) { - item.tooltip = await this.getTooltip(); + item.tooltip = await this.getTooltip(token); } return item; } private async getAssociatedPullRequest( commit: GitCommit, - remote?: GitRemote, + remote?: GitRemote, ): Promise { let pullRequest = this.getState('pullRequest'); if (pullRequest !== undefined) return Promise.resolve(pullRequest ?? undefined); let pendingPullRequest = this.getState('pendingPullRequest'); if (pendingPullRequest == null) { - pendingPullRequest = commit.getAssociatedPullRequest({ remote: remote }); + pendingPullRequest = commit.getAssociatedPullRequest(remote); this.storeState('pendingPullRequest', pendingPullRequest); pullRequest = await pendingPullRequest; @@ -240,42 +251,42 @@ export class CommitNode extends ViewRefNode { constructor( view: View, - parent: ViewNode, + protected override readonly parent: ViewNode, private readonly _message: string, private readonly _description?: string, private readonly _tooltip?: string, - private readonly _iconPath?: - | string - | Uri - | { - light: string | Uri; - dark: string | Uri; - } - | ThemeIcon, + private readonly _iconPath?: TreeItem['iconPath'], private readonly _contextValue?: string, ) { - super(unknownGitUri, view, parent); + super('message', unknownGitUri, view, parent); } getChildren(): ViewNode[] | Promise { @@ -44,19 +37,12 @@ export class MessageNode extends ViewNode { export class CommandMessageNode extends MessageNode { constructor( view: View, - parent: ViewNode, + protected override readonly parent: ViewNode, private readonly _command: Command, message: string, description?: string, tooltip?: string, - iconPath?: - | string - | Uri - | { - light: string | Uri; - dark: string | Uri; - } - | ThemeIcon, + iconPath?: TreeItem['iconPath'], ) { super(view, parent, message, description, tooltip, iconPath); } @@ -75,74 +61,7 @@ export class CommandMessageNode extends MessageNode { } } -export class UpdateableMessageNode extends ViewNode { - override readonly id: string; - - constructor( - view: View, - parent: ViewNode, - id: string, - private _message: string, - private _tooltip?: string, - private _iconPath?: - | string - | Uri - | { - light: string | Uri; - dark: string | Uri; - } - | ThemeIcon, - ) { - super(unknownGitUri, view, parent); - this.id = id; - } - - getChildren(): ViewNode[] | Promise { - return []; - } - - getTreeItem(): TreeItem | Promise { - const item = new TreeItem(this._message, TreeItemCollapsibleState.None); - item.id = this.id; - item.contextValue = ContextValues.Message; - item.tooltip = this._tooltip; - item.iconPath = this._iconPath; - return item; - } - - update( - changes: { - message?: string; - tooltip?: string | null; - iconPath?: - | string - | null - | Uri - | { - light: string | Uri; - dark: string | Uri; - } - | ThemeIcon; - }, - view: View, - ) { - if (changes.message !== undefined) { - this._message = changes.message; - } - - if (changes.tooltip !== undefined) { - this._tooltip = changes.tooltip === null ? undefined : changes.tooltip; - } - - if (changes.iconPath !== undefined) { - this._iconPath = changes.iconPath === null ? undefined : changes.iconPath; - } - - view.triggerNodeChange(this); - } -} - -export abstract class PagerNode extends ViewNode { +export abstract class PagerNode extends ViewNode<'pager'> { constructor( view: View, parent: ViewNode & PageableViewNode, @@ -154,7 +73,7 @@ export abstract class PagerNode extends ViewNode { getCount?: () => Promise; }, // protected readonly pageSize: number = configuration.get('views.pageItemLimit'), // protected readonly countFn?: () => Promise, // protected readonly context?: Record, // protected readonly beforeLoadCallback?: (mode: 'all' | 'more') => void, ) { - super(unknownGitUri, view, parent); + super('pager', unknownGitUri, view, parent); } async loadAll() { diff --git a/src/views/nodes/compareBranchNode.ts b/src/views/nodes/compareBranchNode.ts index 5db3da62f3476..50aa7f5381c44 100644 --- a/src/views/nodes/compareBranchNode.ts +++ b/src/views/nodes/compareBranchNode.ts @@ -1,50 +1,64 @@ +import type { Disposable, TreeCheckboxChangeEvent } from 'vscode'; import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { ViewShowBranchComparison } from '../../configuration'; +import type { ViewShowBranchComparison } from '../../config'; import { GlyphChars } from '../../constants'; +import type { StoredBranchComparison, StoredBranchComparisons } from '../../constants.storage'; import type { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; -import { GitRevision } from '../../git/models/reference'; +import { createRevisionRange, shortenRevision } from '../../git/models/reference'; +import type { GitUser } from '../../git/models/user'; +import type { CommitsQueryResults, FilesQueryResults } from '../../git/queryResults'; +import { getCommitsQuery, getFilesQuery } from '../../git/queryResults'; import { CommandQuickPickItem } from '../../quickpicks/items/common'; -import { ReferencePicker } from '../../quickpicks/referencePicker'; -import type { StoredBranchComparison, StoredBranchComparisons } from '../../storage'; -import { gate } from '../../system/decorators/gate'; +import { showReferencePicker } from '../../quickpicks/referencePicker'; import { debug, log } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; import { getSettledValue } from '../../system/promise'; import { pluralize } from '../../system/string'; -import type { BranchesView } from '../branchesView'; -import type { CommitsView } from '../commitsView'; -import type { RepositoriesView } from '../repositoriesView'; +import type { ViewsWithBranches } from '../viewBase'; import type { WorktreesView } from '../worktreesView'; -import { RepositoryNode } from './repositoryNode'; -import type { CommitsQueryResults } from './resultsCommitsNode'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { + getComparisonCheckedFiles, + getComparisonStoragePrefix, + resetComparisonCheckedFiles, + restoreComparisonCheckedFiles, +} from './compareResultsNode'; import { ResultsCommitsNode } from './resultsCommitsNode'; -import type { FilesQueryResults } from './resultsFilesNode'; import { ResultsFilesNode } from './resultsFilesNode'; -import { ContextValues, ViewNode } from './viewNode'; -export class CompareBranchNode extends ViewNode { - static key = ':compare-branch'; - static getId(repoPath: string, name: string, root: boolean): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${name})${root ? ':root' : ''}`; - } - - private _children: ViewNode[] | undefined; - private _compareWith: StoredBranchComparison | undefined; +type State = { + filterCommits: GitUser[] | undefined; +}; +export class CompareBranchNode extends SubscribeableViewNode< + 'compare-branch', + ViewsWithBranches | WorktreesView, + ViewNode, + State +> { constructor( uri: GitUri, - view: BranchesView | CommitsView | RepositoriesView | WorktreesView, - parent: ViewNode, + view: ViewsWithBranches | WorktreesView, + protected override readonly parent: ViewNode, public readonly branch: GitBranch, private showComparison: ViewShowBranchComparison, // Specifies that the node is shown as a root public readonly root: boolean = false, ) { - super(uri, view, parent); + super('compare-branch', uri, view, parent); + this.updateContext({ branch: branch, root: root, storedComparisonId: this.getStorageId() }); + this._uniqueId = getViewNodeId(this.type, this.context); this.loadCompareWith(); } + protected override etag(): number { + return 0; + } + get ahead(): { readonly ref1: string; readonly ref2: string } { return { ref1: this._compareWith?.ref || 'HEAD', @@ -59,37 +73,64 @@ export class CompareBranchNode extends ViewNode | undefined { + return weakEvent(this.view.onDidChangeNodesCheckedState, this.onNodesCheckedStateChanged, this); + } + + private onNodesCheckedStateChanged(e: TreeCheckboxChangeEvent) { + const prefix = getComparisonStoragePrefix(this.getStorageId()); + if (e.items.some(([n]) => n.id?.startsWith(prefix))) { + void this.storeCompareWith(false); + } + } + async getChildren(): Promise { if (this._compareWith == null) return []; - if (this._children == null) { + if (this.children == null) { const ahead = this.ahead; const behind = this.behind; - const aheadBehindCounts = await this.view.container.git.getAheadBehindCommitCount(this.branch.repoPath, [ - GitRevision.createRange(behind.ref1, behind.ref2, '...'), - ]); + const counts = await this.view.container.git.getLeftRightCommitCount( + this.branch.repoPath, + createRevisionRange(behind.ref1, behind.ref2, '...'), + { authors: this.filterByAuthors }, + ); const mergeBase = (await this.view.container.git.getMergeBase(this.repoPath, behind.ref1, behind.ref2, { forkPoint: true, })) ?? (await this.view.container.git.getMergeBase(this.repoPath, behind.ref1, behind.ref2)); - this._children = [ + const children: ViewNode[] = [ new ResultsCommitsNode( this.view, this, this.repoPath, 'Behind', { - query: this.getCommitsQuery(GitRevision.createRange(behind.ref1, behind.ref2, '..')), + query: this.getCommitsQuery(createRevisionRange(behind.ref1, behind.ref2, '..')), comparison: behind, direction: 'behind', files: { @@ -99,8 +140,7 @@ export class CompareBranchNode extends ViewNode) { if (this._compareWith != null) { - await this.updateCompareWith({ ...this._compareWith, type: comparisonType }); + await this.updateCompareWith({ ...this._compareWith, type: comparisonType, checkedFiles: undefined }); } else { this.showComparison = comparisonType; } - this._children = undefined; + this.children = undefined; this.view.triggerNodeChange(this); } + @log() + async setDefaultCompareWith(compareWith: StoredBranchComparison) { + if (this._compareWith != null) return; + + await this.updateCompareWith(compareWith); + } + private get comparisonType() { return this._compareWith?.type ?? this.showComparison; } private get compareWithWorkingTree() { - return this.comparisonType === ViewShowBranchComparison.Working; - } - - private async compareWith() { - const pick = await ReferencePicker.show( - this.branch.repoPath, - `Compare ${this.branch.name}${this.compareWithWorkingTree ? ' (working)' : ''} with`, - 'Choose a reference to compare with', - { - allowEnteringRefs: true, - picked: this.branch.ref, - // checkmarks: true, - sort: { branches: { current: true }, tags: {} }, - }, - ); - if (pick == null || pick instanceof CommandQuickPickItem) return; - - await this.updateCompareWith({ - ref: pick.ref, - notation: undefined, - type: this.comparisonType, - }); - - this._children = undefined; - this.view.triggerNodeChange(this); + return this.comparisonType === 'working'; } private async getAheadFilesQuery(): Promise { - const comparison = GitRevision.createRange(this._compareWith?.ref || 'HEAD', this.branch.ref || 'HEAD', '...'); + const comparison = createRevisionRange(this._compareWith?.ref || 'HEAD', this.branch.ref || 'HEAD', '...'); const [filesResult, workingFilesResult, statsResult, workingStatsResult] = await Promise.allSettled([ this.view.container.git.getDiffStatus(this.repoPath, comparison), @@ -307,7 +362,7 @@ export class CompareBranchNode extends ViewNode { - const comparison = GitRevision.createRange(this.branch.ref, this._compareWith?.ref || 'HEAD', '...'); + const comparison = createRevisionRange(this.branch.ref, this._compareWith?.ref || 'HEAD', '...'); const [filesResult, statsResult] = await Promise.allSettled([ this.view.container.git.getDiffStatus(this.repoPath, comparison), @@ -323,56 +378,32 @@ export class CompareBranchNode extends ViewNode Promise { - const repoPath = this.repoPath; - return async (limit: number | undefined) => { - const log = await this.view.container.git.getLog(repoPath, { - limit: limit, - ref: range, - }); - - const results: Mutable> = { - log: log, - hasMore: log?.hasMore ?? true, - }; - if (results.hasMore) { - results.more = async (limit: number | undefined) => { - results.log = (await results.log?.more?.(limit)) ?? results.log; - results.hasMore = results.log?.hasMore ?? true; - }; - } - - return results as CommitsQueryResults; - }; + return getCommitsQuery(this.view.container, this.repoPath, range, this.filterByAuthors); } - private async getFilesQuery(): Promise { - let comparison; - if (!this._compareWith?.ref) { - comparison = this.branch.ref; + private getFilesQuery(): Promise { + let ref1 = this.branch.ref; + let ref2 = this._compareWith?.ref; + + if (!ref2) { + ref2 = ref1; + ref1 = ''; } else if (this.compareWithWorkingTree) { - comparison = this._compareWith.ref; - } else { - comparison = `${this._compareWith.ref}..${this.branch.ref}`; + ref1 = ''; } - const [filesResult, statsResult] = await Promise.allSettled([ - this.view.container.git.getDiffStatus(this.repoPath, comparison), - this.view.container.git.getChangedFilesCount(this.repoPath, comparison), - ]); + return getFilesQuery(this.view.container, this.repoPath, ref1, ref2); + } - const files = getSettledValue(filesResult) ?? []; - return { - label: `${pluralize('file', files.length, { zero: 'No' })} changed`, - files: files, - stats: getSettledValue(statsResult), - }; + private getStorageId() { + return `${this.branch.id}${this.branch.current ? '+current' : ''}`; } private loadCompareWith() { const comparisons = this.view.container.storage.getWorkspace('branch:comparisons'); - const id = `${this.branch.id}${this.branch.current ? '+current' : ''}`; - const compareWith = comparisons?.[id]; + const storageId = this.getStorageId(); + const compareWith = comparisons?.[storageId]; if (compareWith != null && typeof compareWith === 'string') { this._compareWith = { ref: compareWith, @@ -381,29 +412,41 @@ export class CompareBranchNode extends ViewNode { +export class ComparePickerNode extends ViewNode<'compare-picker', SearchAndCompareView> { readonly order: number = Date.now(); - readonly pinned: boolean = false; - constructor(view: SearchAndCompareView, parent: SearchAndCompareViewNode, public readonly selectedRef: RepoRef) { - super(unknownGitUri, view, parent); - } - - get canDismiss(): boolean { - return true; + constructor( + view: SearchAndCompareView, + parent: SearchAndCompareViewNode, + public readonly selectedRef: RepoRef, + ) { + super('compare-picker', unknownGitUri, view, parent); } getChildren(): ViewNode[] { diff --git a/src/views/nodes/compareResultsNode.ts b/src/views/nodes/compareResultsNode.ts index c36269972a7b7..2024cad04585c 100644 --- a/src/views/nodes/compareResultsNode.ts +++ b/src/views/nodes/compareResultsNode.ts @@ -1,45 +1,73 @@ -import { ThemeIcon, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; import { md5 } from '@env/crypto'; +import type { TreeCheckboxChangeEvent } from 'vscode'; +import { Disposable, ThemeIcon, TreeItem, TreeItemCheckboxState, TreeItemCollapsibleState, window } from 'vscode'; +import type { StoredNamedRef } from '../../constants.storage'; +import type { FilesComparison } from '../../git/actions/commit'; import { GitUri } from '../../git/gitUri'; -import { GitRevision } from '../../git/models/reference'; -import type { StoredNamedRef } from '../../storage'; +import { createRevisionRange, shortenRevision } from '../../git/models/reference'; +import type { GitUser } from '../../git/models/user'; +import type { CommitsQueryResults, FilesQueryResults } from '../../git/queryResults'; +import { getAheadBehindFilesQuery, getCommitsQuery, getFilesQuery } from '../../git/queryResults'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; -import { getSettledValue } from '../../system/promise'; +import { weakEvent } from '../../system/event'; import { pluralize } from '../../system/string'; import type { SearchAndCompareView } from '../searchAndCompareView'; -import { RepositoryNode } from './repositoryNode'; -import type { CommitsQueryResults } from './resultsCommitsNode'; +import type { View } from '../viewBase'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { ResultsCommitsNode } from './resultsCommitsNode'; -import type { FilesQueryResults } from './resultsFilesNode'; import { ResultsFilesNode } from './resultsFilesNode'; -import { ContextValues, ViewNode } from './viewNode'; let instanceId = 0; -export class CompareResultsNode extends ViewNode { - static key = ':compare-results'; - static getId(repoPath: string, ref1: string, ref2: string, instanceId: number): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${ref1}|${ref2}):${instanceId}`; - } - - static getPinnableId(repoPath: string, ref1: string, ref2: string) { - return md5(`${repoPath}|${ref1}|${ref2}`, 'base64'); - } +type State = { + filterCommits: GitUser[] | undefined; +}; - private _children: ViewNode[] | undefined; +export class CompareResultsNode extends SubscribeableViewNode< + 'compare-results', + SearchAndCompareView, + ViewNode, + State +> { private _instanceId: number; constructor( view: SearchAndCompareView, - parent: ViewNode, + protected override readonly parent: ViewNode, public readonly repoPath: string, private _ref: StoredNamedRef, private _compareWith: StoredNamedRef, - private _pinned: number = 0, + private _storedAt: number = 0, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('compare-results', GitUri.fromRepoPath(repoPath), view, parent); + this._instanceId = instanceId++; + this.updateContext({ + comparisonId: `${_ref.ref}+${_compareWith.ref}+${this._instanceId}`, + storedComparisonId: this.getStorageId(), + }); + this._uniqueId = getViewNodeId(this.type, this.context); + + // If this is a new comparison, save it + if (this._storedAt === 0) { + this._storedAt = Date.now(); + void this.store(true); + } + } + + override get id(): string { + return this._uniqueId; + } + + protected override etag(): number { + return this._storedAt; + } + + get order(): number { + return this._storedAt; } get ahead(): { readonly ref1: string; readonly ref2: string } { @@ -56,44 +84,77 @@ export class CompareResultsNode extends ViewNode { }; } - override get id(): string { - return CompareResultsNode.getId(this.repoPath, this._ref.ref, this._compareWith.ref, this._instanceId); + get compareRef(): StoredNamedRef { + return this._ref; } - get canDismiss(): boolean { - return !this.pinned; + get compareWithRef(): StoredNamedRef { + return this._compareWith; } - private readonly _order: number = Date.now(); - get order(): number { - return this._pinned || this._order; + private _isFiltered: boolean | undefined; + private get filterByAuthors(): GitUser[] | undefined { + const authors = this.getState('filterCommits'); + + const isFiltered = Boolean(authors?.length); + if (this._isFiltered != null && this._isFiltered !== isFiltered) { + this.updateContext({ comparisonFiltered: isFiltered }); + } + this._isFiltered = isFiltered; + + return authors; } - get pinned(): boolean { - return this._pinned !== 0; + protected override subscribe(): Disposable | Promise | undefined { + return Disposable.from( + weakEvent(this.view.onDidChangeNodesCheckedState, this.onNodesCheckedStateChanged, this), + weakEvent( + this.view.container.integrations.onDidChangeConnectionState, + this.onIntegrationConnectionStateChanged, + this, + ), + ); + } + + private onIntegrationConnectionStateChanged() { + this.view.triggerNodeChange(this.parent); + } + + private onNodesCheckedStateChanged(e: TreeCheckboxChangeEvent) { + const prefix = getComparisonStoragePrefix(this.getStorageId()); + if (e.items.some(([n]) => n.id?.startsWith(prefix))) { + void this.store(true); + } + } + + dismiss() { + void this.remove(true); } async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const ahead = this.ahead; const behind = this.behind; - const aheadBehindCounts = await this.view.container.git.getAheadBehindCommitCount(this.repoPath, [ - GitRevision.createRange(behind.ref1 || 'HEAD', behind.ref2, '...'), - ]); + const counts = await this.view.container.git.getLeftRightCommitCount( + this.repoPath, + createRevisionRange(behind.ref1 || 'HEAD', behind.ref2, '...'), + { authors: this.filterByAuthors }, + ); + const mergeBase = (await this.view.container.git.getMergeBase(this.repoPath, behind.ref1, behind.ref2, { forkPoint: true, })) ?? (await this.view.container.git.getMergeBase(this.repoPath, behind.ref1, behind.ref2)); - this._children = [ + const children: ViewNode[] = [ new ResultsCommitsNode( this.view, this, this.repoPath, 'Behind', { - query: this.getCommitsQuery(GitRevision.createRange(behind.ref1, behind.ref2, '..')), + query: this.getCommitsQuery(createRevisionRange(behind.ref1, behind.ref2, '..')), comparison: behind, direction: 'behind', files: { @@ -103,8 +164,7 @@ export class CompareResultsNode extends ViewNode { }, }, { - id: 'behind', - description: pluralize('commit', aheadBehindCounts?.behind ?? 0), + description: pluralize('commit', counts?.right ?? 0), expand: false, }, ), @@ -114,7 +174,7 @@ export class CompareResultsNode extends ViewNode { this.repoPath, 'Ahead', { - query: this.getCommitsQuery(GitRevision.createRange(ahead.ref1, ahead.ref2, '..')), + query: this.getCommitsQuery(createRevisionRange(ahead.ref1, ahead.ref2, '..')), comparison: ahead, direction: 'ahead', files: { @@ -124,26 +184,31 @@ export class CompareResultsNode extends ViewNode { }, }, { - id: 'ahead', - description: pluralize('commit', aheadBehindCounts?.ahead ?? 0), - expand: false, - }, - ), - new ResultsFilesNode( - this.view, - this, - this.repoPath, - this._compareWith.ref, - this._ref.ref, - this.getFilesQuery.bind(this), - undefined, - { + description: pluralize('commit', counts?.left ?? 0), expand: false, }, ), ]; + + // Can't support showing files when commits are filtered + if (!this.filterByAuthors?.length) { + children.push( + new ResultsFilesNode( + this.view, + this, + this.repoPath, + this._compareWith.ref, + this._ref.ref, + this.getFilesQuery.bind(this), + undefined, + { expand: false }, + ), + ); + } + + this.children = children; } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -155,21 +220,19 @@ export class CompareResultsNode extends ViewNode { const item = new TreeItem( `Comparing ${ - this._ref.label ?? GitRevision.shorten(this._ref.ref, { strings: { working: 'Working Tree' } }) + this._ref.label ?? shortenRevision(this._ref.ref, { strings: { working: 'Working Tree' } }) } with ${ this._compareWith.label ?? - GitRevision.shorten(this._compareWith.ref, { strings: { working: 'Working Tree' } }) + shortenRevision(this._compareWith.ref, { strings: { working: 'Working Tree' } }) }`, TreeItemCollapsibleState.Collapsed, ); item.id = this.id; - item.contextValue = `${ContextValues.CompareResults}${this._pinned ? '+pinned' : ''}${ - this._ref.ref === '' ? '+working' : '' + item.contextValue = `${ContextValues.CompareResults}${this._ref.ref === '' ? '+working' : ''}${ + this.filterByAuthors?.length ? '+filtered' : '' }`; item.description = description; - if (this._pinned) { - item.iconPath = new ThemeIcon('pinned'); - } + item.iconPath = new ThemeIcon('compare-changes'); return item; } @@ -180,22 +243,16 @@ export class CompareResultsNode extends ViewNode { return Promise.resolve<[string, string]>([this._compareWith.ref, this._ref.ref]); } - @log() - async pin() { - if (this.pinned) return; - - this._pinned = Date.now(); - await this.updatePinned(); - - queueMicrotask(() => this.view.reveal(this, { focus: true, select: true })); + async getFilesComparison(): Promise { + const children = await this.getChildren(); + const node = children.find(c => c.is('results-files')); + return node?.getFilesComparison(); } - @gate() - @debug() - override refresh(reset: boolean = false) { - if (!reset) return; - - this._children = undefined; + @log() + clearReviewed() { + resetComparisonCheckedFiles(this.view, this.getStorageId()); + void this.store(); } @log() @@ -206,158 +263,104 @@ export class CompareResultsNode extends ViewNode { } // Save the current id so we can update it later - const currentId = this.getPinnableId(); + const currentId = this.getStorageId(); const ref1 = this._ref; this._ref = this._compareWith; this._compareWith = ref1; - // If we were pinned, remove the existing pin and save a new one - if (this.pinned) { - await this.view.updatePinned(currentId); - await this.updatePinned(); - } + // Remove the existing stored item and save a new one + await this.replace(currentId, true); - this._children = undefined; + this.children = undefined; this.view.triggerNodeChange(this.parent); queueMicrotask(() => this.view.reveal(this, { expand: true, focus: true, select: true })); } - @log() - async unpin() { - if (!this.pinned) return; - - this._pinned = 0; - await this.view.updatePinned(this.getPinnableId()); - - queueMicrotask(() => this.view.reveal(this, { focus: true, select: true })); - } - - private getPinnableId() { - return CompareResultsNode.getPinnableId(this.repoPath, this._ref.ref, this._compareWith.ref); - } - private async getAheadFilesQuery(): Promise { - return this.getAheadBehindFilesQuery( - GitRevision.createRange(this._compareWith?.ref || 'HEAD', this._ref.ref || 'HEAD', '...'), + return getAheadBehindFilesQuery( + this.view.container, + this.repoPath, + createRevisionRange(this._compareWith?.ref || 'HEAD', this._ref.ref || 'HEAD', '...'), this._ref.ref === '', ); } private async getBehindFilesQuery(): Promise { - return this.getAheadBehindFilesQuery( - GitRevision.createRange(this._ref.ref || 'HEAD', this._compareWith.ref || 'HEAD', '...'), + return getAheadBehindFilesQuery( + this.view.container, + this.repoPath, + createRevisionRange(this._ref.ref || 'HEAD', this._compareWith.ref || 'HEAD', '...'), false, ); } - private async getAheadBehindFilesQuery( - comparison: string, - compareWithWorkingTree: boolean, - ): Promise { - const [filesResult, workingFilesResult, statsResult, workingStatsResult] = await Promise.allSettled([ - this.view.container.git.getDiffStatus(this.repoPath, comparison), - compareWithWorkingTree ? this.view.container.git.getDiffStatus(this.repoPath, 'HEAD') : undefined, - this.view.container.git.getChangedFilesCount(this.repoPath, comparison), - compareWithWorkingTree ? this.view.container.git.getChangedFilesCount(this.repoPath, 'HEAD') : undefined, - ]); - - let files = getSettledValue(filesResult) ?? []; - let stats: FilesQueryResults['stats'] = getSettledValue(statsResult); - - if (compareWithWorkingTree) { - const workingFiles = getSettledValue(workingFilesResult); - if (workingFiles != null) { - if (files.length === 0) { - files = workingFiles ?? []; - } else { - for (const wf of workingFiles) { - const index = files.findIndex(f => f.path === wf.path); - if (index !== -1) { - files.splice(index, 1, wf); - } else { - files.push(wf); - } - } - } - } + private getCommitsQuery(range: string): (limit: number | undefined) => Promise { + return getCommitsQuery(this.view.container, this.repoPath, range, this.filterByAuthors); + } - const workingStats = getSettledValue(workingStatsResult); - if (workingStats != null) { - if (stats == null) { - stats = workingStats; - } else { - stats = { - additions: stats.additions + workingStats.additions, - deletions: stats.deletions + workingStats.deletions, - changedFiles: files.length, - approximated: true, - }; - } - } - } + private getFilesQuery(): Promise { + return getFilesQuery(this.view.container, this.repoPath, this._ref.ref, this._compareWith.ref); + } - return { - label: `${pluralize('file', files.length, { zero: 'No' })} changed`, - files: files, - stats: stats, - }; + private getStorageId() { + return md5(`${this.repoPath}|${this._ref.ref}|${this._compareWith.ref}`, 'base64'); } - private getCommitsQuery(range: string): (limit: number | undefined) => Promise { - const repoPath = this.repoPath; - return async (limit: number | undefined) => { - const log = await this.view.container.git.getLog(repoPath, { - limit: limit, - ref: range, - }); - - const results: Mutable> = { - log: log, - hasMore: log?.hasMore ?? true, - }; - if (results.hasMore) { - results.more = async (limit: number | undefined) => { - results.log = (await results.log?.more?.(limit)) ?? results.log; - results.hasMore = results.log?.hasMore ?? true; - }; - } + private remove(silent: boolean = false) { + resetComparisonCheckedFiles(this.view, this.getStorageId()); + return this.view.updateStorage(this.getStorageId(), undefined, silent); + } - return results as CommitsQueryResults; - }; + private async replace(id: string, silent: boolean = false) { + resetComparisonCheckedFiles(this.view, id); + await this.view.updateStorage(id, undefined, silent); + return this.store(silent); } - private async getFilesQuery(): Promise { - let comparison; - if (this._compareWith.ref === '') { - debugger; - throw new Error('Cannot get files for comparisons of a ref with working tree'); - } else if (this._ref.ref === '') { - comparison = this._compareWith.ref; - } else { - comparison = `${this._compareWith.ref}..${this._ref.ref}`; - } + store(silent = false) { + const storageId = this.getStorageId(); + const checkedFiles = getComparisonCheckedFiles(this.view, storageId); + + return this.view.updateStorage( + storageId, + { + type: 'comparison', + timestamp: this._storedAt, + path: this.repoPath, + ref1: { label: this._ref.label, ref: this._ref.ref }, + ref2: { label: this._compareWith.label, ref: this._compareWith.ref }, + checkedFiles: checkedFiles.length > 0 ? checkedFiles : undefined, + }, + silent, + ); + } +} - const [filesResult, statsResult] = await Promise.allSettled([ - this.view.container.git.getDiffStatus(this.repoPath, comparison), - this.view.container.git.getChangedFilesCount(this.repoPath, comparison), - ]); +export function getComparisonStoragePrefix(storageId: string) { + return `${storageId}|`; +} - const files = getSettledValue(filesResult) ?? []; - return { - label: `${pluralize('file', files.length, { zero: 'No' })} changed`, - files: files, - stats: getSettledValue(statsResult), - }; +export function getComparisonCheckedFiles(view: View, storageId: string) { + const checkedFiles = []; + + const checked = view.nodeState.get(getComparisonStoragePrefix(storageId), 'checked'); + for (const [key, value] of checked) { + if (value === TreeItemCheckboxState.Checked) { + checkedFiles.push(key); + } } + return checkedFiles; +} - private updatePinned() { - return this.view.updatePinned(this.getPinnableId(), { - type: 'comparison', - timestamp: this._pinned, - path: this.repoPath, - ref1: { label: this._ref.label, ref: this._ref.ref }, - ref2: { label: this._compareWith.label, ref: this._compareWith.ref }, - }); +export function resetComparisonCheckedFiles(view: View, storageId: string) { + view.nodeState.delete(getComparisonStoragePrefix(storageId), 'checked'); +} + +export function restoreComparisonCheckedFiles(view: View, checkedFiles: string[] | undefined) { + if (checkedFiles?.length) { + for (const id of checkedFiles) { + view.nodeState.storeState(id, 'checked', TreeItemCheckboxState.Checked, true); + } } } diff --git a/src/views/nodes/contributorNode.ts b/src/views/nodes/contributorNode.ts index 939bb0f6f2611..a46f5d7769502 100644 --- a/src/views/nodes/contributorNode.ts +++ b/src/views/nodes/contributorNode.ts @@ -1,6 +1,5 @@ import { MarkdownString, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; import { getPresenceDataUri } from '../../avatars'; -import { configuration } from '../../configuration'; import { GlyphChars } from '../../constants'; import type { GitUri } from '../../git/gitUri'; import type { GitContributor } from '../../git/models/contributor'; @@ -9,52 +8,53 @@ import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; import { pluralize } from '../../system/string'; +import { configuration } from '../../system/vscode/configuration'; import type { ContactPresence } from '../../vsls/vsls'; -import type { ContributorsView } from '../contributorsView'; -import type { RepositoriesView } from '../repositoriesView'; +import type { ViewsWithContributors } from '../viewBase'; +import type { ClipboardType, PageableViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode, MessageNode } from './common'; import { insertDateMarkers } from './helpers'; -import { RepositoryNode } from './repositoryNode'; -import type { PageableViewNode } from './viewNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export class ContributorNode extends ViewNode implements PageableViewNode { - static key = ':contributor'; - static getId( - repoPath: string, - name: string | undefined, - email: string | undefined, - username: string | undefined, - ): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${name}|${email}|${username})`; - } + +export class ContributorNode extends ViewNode<'contributor', ViewsWithContributors> implements PageableViewNode { + limit: number | undefined; constructor( uri: GitUri, - view: ContributorsView | RepositoriesView, - parent: ViewNode, + view: ViewsWithContributors, + protected override readonly parent: ViewNode, public readonly contributor: GitContributor, - private readonly _options?: { + private readonly options?: { all?: boolean; ref?: string; presence: Map | undefined; + showMergeCommits?: boolean; }, ) { - super(uri, view, parent); - } + super('contributor', uri, view, parent); - override toClipboard(): string { - return `${this.contributor.name}${this.contributor.email ? ` <${this.contributor.email}>` : ''}`; + this.updateContext({ contributor: contributor }); + this._uniqueId = getViewNodeId(this.type, this.context); + this.limit = this.view.getNodeLastKnownLimit(this); } override get id(): string { - return ContributorNode.getId( - this.contributor.repoPath, - this.contributor.name, - this.contributor.email, - this.contributor.username, - ); + return this._uniqueId; + } + + override toClipboard(type?: ClipboardType): string { + const text = `${this.contributor.name}${this.contributor.email ? ` <${this.contributor.email}>` : ''}`; + switch (type) { + case 'markdown': + return this.contributor.email ? `[${text}](mailto:${this.contributor.email})` : text; + default: + return text; + } + } + + override getUrl(): string { + return this.contributor.email ? `mailto:${this.contributor.email}` : ''; } get repoPath(): string { @@ -83,7 +83,7 @@ export class ContributorNode extends ViewNode { - const presence = this._options?.presence?.get(this.contributor.email!); + const presence = this.options?.presence?.get(this.contributor.email!); const item = new TreeItem( this.contributor.current ? `${this.contributor.label} (you)` : this.contributor.label, @@ -178,9 +178,10 @@ export class ContributorNode extends ViewNode this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/contributorsNode.ts b/src/views/nodes/contributorsNode.ts index 5823af996ec85..88bb5fccf36ab 100644 --- a/src/views/nodes/contributorsNode.ts +++ b/src/views/nodes/contributorsNode.ts @@ -1,39 +1,39 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { configuration } from '../../configuration'; import type { GitUri } from '../../git/gitUri'; -import { GitContributor } from '../../git/models/contributor'; +import type { GitContributor } from '../../git/models/contributor'; +import { sortContributors } from '../../git/models/contributor'; import type { Repository } from '../../git/models/repository'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; -import { timeout } from '../../system/decorators/timeout'; -import type { ContributorsView } from '../contributorsView'; -import type { RepositoriesView } from '../repositoriesView'; +import { configuration } from '../../system/vscode/configuration'; +import type { ViewsWithContributorsNode } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { MessageNode } from './common'; import { ContributorNode } from './contributorNode'; -import { RepositoryNode } from './repositoryNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export class ContributorsNode extends ViewNode { - static key = ':contributors'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; - } +export class ContributorsNode extends CacheableChildrenViewNode< + 'contributors', + ViewsWithContributorsNode, + ContributorNode +> { protected override splatted = true; - private _children: ContributorNode[] | undefined; - constructor( uri: GitUri, - view: ContributorsView | RepositoriesView, - parent: ViewNode, + view: ViewsWithContributorsNode, + protected override readonly parent: ViewNode, public readonly repo: Repository, + private readonly options?: { all?: boolean; showMergeCommits?: boolean; stats?: boolean }, ) { - super(uri, view, parent); + super('contributors', uri, view, parent); + + this.updateContext({ repository: repo }); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { - return ContributorsNode.getId(this.repo.path); + return this._uniqueId; } get repoPath(): string { @@ -41,8 +41,8 @@ export class ContributorsNode extends ViewNode { - if (this._children == null) { - const all = configuration.get('views.contributors.showAllBranches'); + if (this.children == null) { + const all = this.options?.all ?? configuration.get('views.contributors.showAllBranches'); let ref: string | undefined; // If we aren't getting all branches, get the upstream of the current branch if there is one @@ -55,20 +55,31 @@ export class ContributorsNode extends ViewNode new ContributorNode(this.uri, this.view, this, c, { all: all, ref: ref, presence: presenceMap }), + sortContributors(contributors); + const presenceMap = this.view.container.vsls.active ? await this.getPresenceMap(contributors) : undefined; + + this.children = contributors.map( + c => + new ContributorNode(this.uri, this.view, this, c, { + all: all, + ref: ref, + presence: presenceMap, + showMergeCommits: this.options?.showMergeCommits, + }), ); } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -82,24 +93,22 @@ export class ContributorsNode extends ViewNode c.current)?.email; if (email == null) return undefined; diff --git a/src/views/nodes/draftNode.ts b/src/views/nodes/draftNode.ts new file mode 100644 index 0000000000000..13d721fe96037 --- /dev/null +++ b/src/views/nodes/draftNode.ts @@ -0,0 +1,86 @@ +import type { Uri } from 'vscode'; +import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { getAvatarUri } from '../../avatars'; +import type { GitUri } from '../../git/gitUri'; +import type { Draft } from '../../gk/models/drafts'; +import { formatDate, fromNow } from '../../system/date'; +import { configuration } from '../../system/vscode/configuration'; +import type { DraftsView } from '../draftsView'; +import type { ViewsWithCommits } from '../viewBase'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; + +export class DraftNode extends ViewNode<'draft', ViewsWithCommits | DraftsView> { + constructor( + uri: GitUri, + view: ViewsWithCommits | DraftsView, + protected override parent: ViewNode, + public readonly draft: Draft, + ) { + super('draft', uri, view, parent); + + this.updateContext({ draft: draft }); + this._uniqueId = getViewNodeId(this.type, this.context); + } + + override get id(): string { + return this._uniqueId; + } + + override toClipboard(): string { + return this.getUrl(); + } + + override getUrl(): string { + return this.view.container.drafts.generateWebUrl(this.draft.id); + } + + getChildren(): ViewNode[] { + return []; + } + + getTreeItem(): TreeItem { + const label = this.draft.title ?? `Draft (${this.draft.id})`; + const item = new TreeItem(label, TreeItemCollapsibleState.None); + + const dateFormat = configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma'; + + // Only show updated time if it is more than a 30s after the created time + const showUpdated = this.draft.updatedAt.getTime() - this.draft.createdAt.getTime() >= 30000; + + item.id = this.id; + let contextValue: string = ContextValues.Draft; + if (this.draft.isMine) { + contextValue += '+mine'; + } + item.contextValue = contextValue; + item.description = fromNow(this.draft.updatedAt); + item.command = { + title: 'Open', + command: 'gitlens.views.draft.open', + arguments: [this], + }; + + let avatarUri: Uri | undefined; + if (this.view.config.avatars && this.draft.author != null) { + avatarUri = this.draft.author.avatarUri ?? getAvatarUri(this.draft.author.email); + } + + item.iconPath = + avatarUri ?? new ThemeIcon(this.draft.type == 'suggested_pr_change' ? 'gitlens-code-suggestion' : 'cloud'); + + item.tooltip = new MarkdownString( + `${label}${this.draft.description ? `\\\n${this.draft.description}` : ''}\n\nCreated ${ + this.draft.author?.name ? ` by ${this.draft.author.name}` : '' + } ${fromNow(this.draft.createdAt)}   _(${formatDate(this.draft.createdAt, dateFormat)})_${ + showUpdated + ? ` \\\nLast updated ${fromNow(this.draft.updatedAt)}   _(${formatDate( + this.draft.updatedAt, + dateFormat, + )})_` + : '' + }`, + ); + + return item; + } +} diff --git a/src/views/nodes/fileHistoryNode.ts b/src/views/nodes/fileHistoryNode.ts index 48574e6317df5..f7784dfed0569 100644 --- a/src/views/nodes/fileHistoryNode.ts +++ b/src/views/nodes/fileHistoryNode.ts @@ -1,51 +1,58 @@ import { Disposable, TreeItem, TreeItemCollapsibleState, Uri, window } from 'vscode'; -import { configuration } from '../../configuration'; import type { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; +import { deletedOrMissing } from '../../git/models/constants'; import type { GitLog } from '../../git/models/log'; -import { GitRevision } from '../../git/models/reference'; import type { RepositoryChangeEvent, RepositoryFileSystemChangeEvent } from '../../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; -import { Logger } from '../../logger'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { memoize } from '../../system/decorators/memoize'; +import { weakEvent } from '../../system/event'; import { filterMap, flatMap, map, uniqueBy } from '../../system/iterable'; +import { Logger } from '../../system/logger'; import { basename } from '../../system/path'; +import { configuration } from '../../system/vscode/configuration'; import type { FileHistoryView } from '../fileHistoryView'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode, MessageNode } from './common'; import { FileHistoryTrackerNode } from './fileHistoryTrackerNode'; import { FileRevisionAsCommitNode } from './fileRevisionAsCommitNode'; import { insertDateMarkers } from './helpers'; -import { RepositoryNode } from './repositoryNode'; -import type { PageableViewNode, ViewNode } from './viewNode'; -import { ContextValues, SubscribeableViewNode } from './viewNode'; - -export class FileHistoryNode extends SubscribeableViewNode implements PageableViewNode { - static key = ':history:file'; - static getId(repoPath: string, uri: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${uri})`; - } + +export class FileHistoryNode + extends SubscribeableViewNode<'file-history', FileHistoryView> + implements PageableViewNode +{ + limit: number | undefined; protected override splatted = true; constructor( uri: GitUri, view: FileHistoryView, - parent: ViewNode, + protected override readonly parent: ViewNode, private readonly folder: boolean, private readonly branch: GitBranch | undefined, ) { - super(uri, view, parent); - } + super('file-history', uri, view, parent); - override toClipboard(): string { - return this.uri.fileName; + if (branch != null) { + this.updateContext({ branch: branch }); + } + this._uniqueId = getViewNodeId(`${this.type}+${uri.toString()}`, this.context); + this.limit = this.view.getNodeLastKnownLimit(this); } override get id(): string { - return FileHistoryNode.getId(this.uri.repoPath!, this.uri.toString(true)); + return this._uniqueId; + } + + override toClipboard(): string { + return this.uri.fileName; } async getChildren(): Promise { @@ -54,19 +61,18 @@ export class FileHistoryNode extends SubscribeableViewNode impl }`; const children: ViewNode[] = []; + if (this.uri.repoPath == null) return children; const range = this.branch != null ? await this.view.container.git.getBranchAheadRange(this.branch) : undefined; const [log, fileStatuses, currentUser, getBranchAndTagTips, unpublishedCommits] = await Promise.all([ this.getLog(), this.uri.sha == null - ? this.view.container.git.getStatusForFiles(this.uri.repoPath!, this.getPathOrGlob()) - : undefined, - this.uri.sha == null ? this.view.container.git.getCurrentUser(this.uri.repoPath!) : undefined, - this.branch != null - ? this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, this.branch.name) + ? this.view.container.git.getStatusForFiles(this.uri.repoPath, this.getPathOrGlob()) : undefined, + this.uri.sha == null ? this.view.container.git.getCurrentUser(this.uri.repoPath) : undefined, + this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, this.branch?.name), range - ? this.view.container.git.getLogRefsOnly(this.uri.repoPath!, { + ? this.view.container.git.getLogRefsOnly(this.uri.repoPath, { limit: 0, ref: range, }) @@ -80,7 +86,15 @@ export class FileHistoryNode extends SubscribeableViewNode impl uniqueBy( flatMap(fileStatuses, f => f.getPseudoCommits(this.view.container, currentUser)), c => c.sha, - (original, c) => original.with({ files: { files: [...original.files!, ...c.files!] } }), + (original, c) => + original.with({ + files: { + files: [ + ...(original.files ?? (original.file != null ? [original.file] : [])), + ...(c.files ?? (c.file != null ? [c.file] : [])), + ], + }, + }), ), commit => new CommitNode(this.view, this, commit), ); @@ -107,18 +121,18 @@ export class FileHistoryNode extends SubscribeableViewNode impl c, unpublishedCommits?.has(c.ref), this.branch, - undefined, + getBranchAndTagTips, { expand: false, }, ) : c.file != null - ? new FileRevisionAsCommitNode(this.view, this, c.file, c, { - branch: this.branch, - getBranchAndTagTips: getBranchAndTagTips, - unpublished: unpublishedCommits?.has(c.ref), - }) - : undefined, + ? new FileRevisionAsCommitNode(this.view, this, c.file, c, { + branch: this.branch, + getBranchAndTagTips: getBranchAndTagTips, + unpublished: unpublishedCommits?.has(c.ref), + }) + : undefined, ), this, ), @@ -156,15 +170,13 @@ export class FileHistoryNode extends SubscribeableViewNode impl if (this.folder && this.uri.fileName === '') { return `${basename(this.uri.path)}${ this.uri.sha - ? ` ${this.uri.sha === GitRevision.deletedOrMissing ? this.uri.shortSha : `(${this.uri.shortSha})`}` + ? ` ${this.uri.sha === deletedOrMissing ? this.uri.shortSha : `(${this.uri.shortSha})`}` : '' }`; } return `${this.uri.fileName}${ - this.uri.sha - ? ` ${this.uri.sha === GitRevision.deletedOrMissing ? this.uri.shortSha : `(${this.uri.shortSha})`}` - : '' + this.uri.sha ? ` ${this.uri.sha === deletedOrMissing ? this.uri.shortSha : `(${this.uri.shortSha})`}` : '' }`; } @@ -174,14 +186,17 @@ export class FileHistoryNode extends SubscribeableViewNode impl if (repo == null) return undefined; const subscription = Disposable.from( - repo.onDidChange(this.onRepositoryChanged, this), - repo.onDidChangeFileSystem(this.onFileSystemChanged, this), - repo.startWatchingFileSystem(), - configuration.onDidChange(e => { - if (configuration.changed(e, 'advanced.fileHistoryFollowsRenames')) { - this.view.resetNodeLastKnownLimit(this); - } - }), + weakEvent(repo.onDidChange, this.onRepositoryChanged, this), + weakEvent(repo.onDidChangeFileSystem, this.onFileSystemChanged, this, [repo.watchFileSystem()]), + weakEvent( + configuration.onDidChange, + e => { + if (configuration.changed(e, 'advanced.fileHistoryFollowsRenames')) { + this.view.resetNodeLastKnownLimit(this); + } + }, + this, + ), ); return subscription; @@ -252,7 +267,6 @@ export class FileHistoryNode extends SubscribeableViewNode impl return this._log?.hasMore ?? true; } - limit: number | undefined = this.view.getNodeLastKnownLimit(this); @gate() async loadMore(limit?: number | { until?: any }) { let log = await window.withProgress( @@ -261,7 +275,7 @@ export class FileHistoryNode extends SubscribeableViewNode impl }, () => this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/fileHistoryTrackerNode.ts b/src/views/nodes/fileHistoryTrackerNode.ts index 98e5990c3fa84..ab9e3d371b371 100644 --- a/src/views/nodes/fileHistoryTrackerNode.ts +++ b/src/views/nodes/fileHistoryTrackerNode.ts @@ -1,49 +1,51 @@ import type { TextEditor } from 'vscode'; import { Disposable, FileType, TreeItem, TreeItemCollapsibleState, window, workspace } from 'vscode'; -import { UriComparer } from '../../comparers'; -import { ContextKeys } from '../../constants'; -import { setContext } from '../../context'; import type { GitCommitish } from '../../git/gitUri'; import { GitUri, unknownGitUri } from '../../git/gitUri'; -import { GitReference, GitRevision } from '../../git/models/reference'; -import { Logger } from '../../logger'; -import { getLogScope } from '../../logScope'; -import { ReferencePicker } from '../../quickpicks/referencePicker'; +import { isBranchReference, isSha } from '../../git/models/reference'; +import { showReferencePicker } from '../../quickpicks/referencePicker'; +import { UriComparer } from '../../system/comparers'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; import type { Deferrable } from '../../system/function'; import { debounce } from '../../system/function'; -import { isVirtualUri } from '../../system/utils'; +import { Logger } from '../../system/logger'; +import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; +import { setContext } from '../../system/vscode/context'; +import { isVirtualUri } from '../../system/vscode/utils'; import type { FileHistoryView } from '../fileHistoryView'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import { FileHistoryNode } from './fileHistoryNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, SubscribeableViewNode } from './viewNode'; -export class FileHistoryTrackerNode extends SubscribeableViewNode { +export class FileHistoryTrackerNode extends SubscribeableViewNode<'file-history-tracker', FileHistoryView> { private _base: string | undefined; - private _child: FileHistoryNode | undefined; protected override splatted = true; constructor(view: FileHistoryView) { - super(unknownGitUri, view); + super('file-history-tracker', unknownGitUri, view); } override dispose() { super.dispose(); - - this.resetChild(); + this.child = undefined; } - @debug() - private resetChild() { - if (this._child == null) return; + private _child: FileHistoryNode | undefined; + protected get child(): FileHistoryNode | undefined { + return this._child; + } + protected set child(value: FileHistoryNode | undefined) { + if (this._child === value) return; - this._child.dispose(); - this._child = undefined; + this._child?.dispose(); + this._child = value; } async getChildren(): Promise { - if (this._child == null) { + if (this.child == null) { if (!this.hasUri) { this.view.description = undefined; @@ -73,17 +75,17 @@ export class FileHistoryTrackerNode extends SubscribeableViewNode b.name === commitish.sha, })); } - this._child = new FileHistoryNode(fileUri, this.view, this, folder, branch); + this.child = new FileHistoryNode(fileUri, this.view, this, folder, branch); } - return this._child.getChildren(); + return this.child.getChildren(); } getTreeItem(): TreeItem { @@ -100,41 +102,38 @@ export class FileHistoryTrackerNode extends SubscribeableViewNode `returned ${r}`, - }) + @debug({ exit: true }) override async refresh(reset: boolean = false) { const scope = getLogScope(); @@ -142,7 +141,7 @@ export class FileHistoryTrackerNode extends SubscribeableViewNode { +export class FileRevisionAsCommitNode extends ViewRefFileNode< + 'file-commit', + ViewsWithCommits | FileHistoryView | LineHistoryView +> { constructor( view: ViewsWithCommits | FileHistoryView | LineHistoryView, parent: ViewNode, @@ -33,7 +41,7 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode { + override async resolveTreeItem(item: TreeItem, token: CancellationToken): Promise { if (item.tooltip == null) { - item.tooltip = await this.getTooltip(); + item.tooltip = await this.getTooltip(token); } return item; } @@ -200,59 +206,76 @@ export class FileRevisionAsCommitNode extends ViewRefFileNode string | undefined; + unpublished?: boolean; + }, +) { + const [remotesResult, _] = await Promise.allSettled([ + container.git.getBestRemotesWithProviders(commit.repoPath, options?.cancellation), + commit.message == null ? commit.ensureFullDetails() : undefined, + ]); + + if (options?.cancellation?.isCancellationRequested) return undefined; + + const remotes = getSettledValue(remotesResult, []); + const [remote] = remotes; + + let enrichedAutolinks; + let pr; + + if (remote?.hasIntegration()) { + const [enrichedAutolinksResult, prResult] = await Promise.allSettled([ + pauseOnCancelOrTimeoutMapTuplePromise(commit.getEnrichedAutolinks(remote), options?.cancellation), + commit.getAssociatedPullRequest(remote), + ]); + + const enrichedAutolinksMaybeResult = getSettledValue(enrichedAutolinksResult); + if (!enrichedAutolinksMaybeResult?.paused) { + enrichedAutolinks = enrichedAutolinksMaybeResult?.value; + } + pr = getSettledValue(prResult); + } + + const status = StatusFileFormatter.fromTemplate( + `\${status}\${ (originalPath)}\${'  â€ĸ  'changesDetail}`, + file, + ); + return CommitFormatter.fromTemplateAsync(tooltipWithStatusFormat.replace('{{slot-status}}', status), commit, { + enrichedAutolinks: enrichedAutolinks, + dateFormat: configuration.get('defaultDateFormat'), + getBranchAndTagTips: options?.getBranchAndTagTips, + messageAutolinks: true, + messageIndent: 4, + pullRequest: pr, + outputFormat: 'markdown', + remotes: remotes, + unpublished: options?.unpublished, + }); +} diff --git a/src/views/nodes/folderNode.ts b/src/views/nodes/folderNode.ts index fc7899efbc5b2..cb03e6c452a42 100644 --- a/src/views/nodes/folderNode.ts +++ b/src/views/nodes/folderNode.ts @@ -1,14 +1,12 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { ViewsFilesConfig } from '../../configuration'; -import { ViewFilesLayout } from '../../configuration'; +import type { ViewFilesLayout, ViewsFilesConfig } from '../../config'; import { GitUri } from '../../git/gitUri'; import type { HierarchicalItem } from '../../system/array'; import { sortCompare } from '../../system/string'; -import type { FileHistoryView } from '../fileHistoryView'; import type { StashesView } from '../stashesView'; import type { ViewsWithCommits } from '../viewBase'; -import type { ViewFileNode } from './viewNode'; -import { ContextValues, ViewNode } from './viewNode'; +import type { ViewFileNode } from './abstract/viewFileNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; export interface FileNode extends ViewFileNode { folderName: string; @@ -20,32 +18,29 @@ export interface FileNode extends ViewFileNode { // root?: HierarchicalItem; } -export class FolderNode extends ViewNode { - static key = ':folder'; - static getId(parent: ViewNode, path: string): string { - return `${parent.id}${this.key}(${path})`; - } - +export class FolderNode extends ViewNode<'folder', ViewsWithCommits | StashesView> { readonly priority: number = 1; constructor( - view: ViewsWithCommits | FileHistoryView | StashesView, + view: ViewsWithCommits | StashesView, protected override parent: ViewNode, + public readonly root: HierarchicalItem, public readonly repoPath: string, public readonly folderName: string, - public readonly root: HierarchicalItem, + public readonly relativePath: string | undefined, private readonly containsWorkingFiles?: boolean, - public readonly relativePath?: string, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); - } + super('folder', GitUri.fromRepoPath(repoPath), view, parent); - override toClipboard(): string { - return this.folderName; + this._uniqueId = getViewNodeId(`${this.type}+${relativePath ?? folderName}`, this.context); } override get id(): string { - return FolderNode.getId(this.parent, this.folderName); + return this._uniqueId; + } + + override toClipboard(): string { + return this.folderName; } getChildren(): (FolderNode | FileNode)[] { @@ -58,7 +53,7 @@ export class FolderNode extends ViewNode (n.relativePath = this.root.relativePath)); children = this.root.descendants; } else { @@ -69,11 +64,11 @@ export class FolderNode extends ViewNode extends ViewNode<'grouping'> { + constructor( + view: View, + private readonly label: string, + private readonly childrenOrFn: TChild[] | Promise | (() => TChild[] | Promise), + private readonly collapsibleState: TreeItemCollapsibleState = TreeItemCollapsibleState.Expanded, + private readonly description?: string, + private readonly tooltip?: string, + private readonly iconPath?: TreeItem['iconPath'], + private readonly contextValue?: string, + ) { + super('grouping', unknownGitUri, view); + } + + getChildren(): ViewNode[] | Promise { + return typeof this.childrenOrFn === 'function' ? this.childrenOrFn() : this.childrenOrFn; + } + + getTreeItem(): TreeItem { + const item = new TreeItem(this.label, this.collapsibleState); + item.id = this.id; + item.contextValue = this.contextValue ?? ContextValues.Grouping; + item.description = this.description; + item.tooltip = this.tooltip; + item.iconPath = this.iconPath; + return item; + } +} diff --git a/src/views/nodes/helpers.ts b/src/views/nodes/helpers.ts index 47d9c4b236a89..01619e1fea458 100644 --- a/src/views/nodes/helpers.ts +++ b/src/views/nodes/helpers.ts @@ -1,7 +1,7 @@ import type { GitCommit } from '../../git/models/commit'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import { MessageNode } from './common'; -import type { ViewNode } from './viewNode'; -import { ContextValues } from './viewNode'; const markers: [number, string][] = [ [0, 'Less than a week ago'], diff --git a/src/views/nodes/lineHistoryNode.ts b/src/views/nodes/lineHistoryNode.ts index 3cf9388559f1e..b4ab4806ee652 100644 --- a/src/views/nodes/lineHistoryNode.ts +++ b/src/views/nodes/lineHistoryNode.ts @@ -1,57 +1,65 @@ import { Disposable, Selection, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; import type { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; +import { deletedOrMissing } from '../../git/models/constants'; import type { GitFile } from '../../git/models/file'; import { GitFileIndexStatus } from '../../git/models/file'; import type { GitLog } from '../../git/models/log'; -import { GitRevision } from '../../git/models/reference'; +import { isUncommitted } from '../../git/models/reference'; import type { RepositoryChangeEvent, RepositoryFileSystemChangeEvent } from '../../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; -import { Logger } from '../../logger'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { memoize } from '../../system/decorators/memoize'; +import { weakEvent } from '../../system/event'; import { filterMap } from '../../system/iterable'; +import { Logger } from '../../system/logger'; import type { FileHistoryView } from '../fileHistoryView'; import type { LineHistoryView } from '../lineHistoryView'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { LoadMoreNode, MessageNode } from './common'; import { FileRevisionAsCommitNode } from './fileRevisionAsCommitNode'; import { insertDateMarkers } from './helpers'; import { LineHistoryTrackerNode } from './lineHistoryTrackerNode'; -import { RepositoryNode } from './repositoryNode'; -import type { PageableViewNode, ViewNode } from './viewNode'; -import { ContextValues, SubscribeableViewNode } from './viewNode'; export class LineHistoryNode - extends SubscribeableViewNode + extends SubscribeableViewNode<'line-history', FileHistoryView | LineHistoryView> implements PageableViewNode { - static key = ':history:line'; - static getId(repoPath: string, uri: string, selection: Selection): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${uri}[${selection.start.line},${ - selection.start.character - }-${selection.end.line},${selection.end.character}])`; - } + limit: number | undefined; protected override splatted = true; constructor( uri: GitUri, view: FileHistoryView | LineHistoryView, - parent: ViewNode, + protected override readonly parent: ViewNode, private readonly branch: GitBranch | undefined, public readonly selection: Selection, private readonly editorContents: string | undefined, ) { - super(uri, view, parent); - } + super('line-history', uri, view, parent); - override toClipboard(): string { - return this.uri.fileName; + if (branch != null) { + this.updateContext({ branch: branch }); + } + this._uniqueId = getViewNodeId( + `${this.type}+${uri.toString()}+[${selection.start.line},${selection.start.character}-${ + selection.end.line + },${selection.end.character}]`, + this.context, + ); + this.limit = this.view.getNodeLastKnownLimit(this); } override get id(): string { - return LineHistoryNode.getId(this.uri.repoPath!, this.uri.toString(true), this.selection); + return this._uniqueId; + } + + override toClipboard(): string { + return this.uri.fileName; } async getChildren(): Promise { @@ -60,22 +68,21 @@ export class LineHistoryNode }`; const children: ViewNode[] = []; + if (this.uri.repoPath == null) return children; let selection = this.selection; const range = this.branch != null ? await this.view.container.git.getBranchAheadRange(this.branch) : undefined; const [log, blame, getBranchAndTagTips, unpublishedCommits] = await Promise.all([ this.getLog(selection), - this.uri.sha == null || GitRevision.isUncommitted(this.uri.sha) + this.uri.sha == null || isUncommitted(this.uri.sha) ? this.editorContents ? await this.view.container.git.getBlameForRangeContents(this.uri, selection, this.editorContents) : await this.view.container.git.getBlameForRange(this.uri, selection) : undefined, - this.branch != null - ? this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, this.branch.name) - : undefined, + this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath, this.branch?.name), range - ? this.view.container.git.getLogRefsOnly(this.uri.repoPath!, { + ? this.view.container.git.getLogRefsOnly(this.uri.repoPath, { limit: 0, ref: range, }) @@ -99,7 +106,7 @@ export class LineHistoryNode selection.active.character, ); - const status = await this.view.container.git.getStatusForFile(this.uri.repoPath!, this.uri); + const status = await this.view.container.git.getStatusForFile(this.uri.repoPath, this.uri); if (status != null) { const file: GitFile = { @@ -107,18 +114,16 @@ export class LineHistoryNode path: commit.file?.path ?? '', indexStatus: status?.indexStatus, originalPath: commit.file?.originalPath, - repoPath: this.uri.repoPath!, + repoPath: this.uri.repoPath, status: status?.status ?? GitFileIndexStatus.Modified, workingTreeStatus: status?.workingTreeStatus, }; - const currentUser = await this.view.container.git.getCurrentUser(this.uri.repoPath!); + const currentUser = await this.view.container.git.getCurrentUser(this.uri.repoPath); const pseudoCommits = status?.getPseudoCommits(this.view.container, currentUser); if (pseudoCommits != null) { for (const commit of pseudoCommits.reverse()) { - children.splice( - 0, - 0, + children.unshift( new FileRevisionAsCommitNode(this.view, this, file, commit, { selection: selection, }), @@ -177,9 +182,7 @@ export class LineHistoryNode get label() { return `${this.uri.fileName}${this.lines}${ - this.uri.sha - ? ` ${this.uri.sha === GitRevision.deletedOrMissing ? this.uri.shortSha : `(${this.uri.shortSha})`}` - : '' + this.uri.sha ? ` ${this.uri.sha === deletedOrMissing ? this.uri.shortSha : `(${this.uri.shortSha})`}` : '' }`; } @@ -196,9 +199,8 @@ export class LineHistoryNode if (repo == null) return undefined; const subscription = Disposable.from( - repo.onDidChange(this.onRepositoryChanged, this), - repo.onDidChangeFileSystem(this.onFileSystemChanged, this), - repo.startWatchingFileSystem(), + weakEvent(repo.onDidChange, this.onRepositoryChanged, this), + weakEvent(repo.onDidChangeFileSystem, this.onFileSystemChanged, this, [repo.watchFileSystem()]), ); return subscription; @@ -263,7 +265,6 @@ export class LineHistoryNode return this._log?.hasMore ?? true; } - limit: number | undefined = this.view.getNodeLastKnownLimit(this); @gate() async loadMore(limit?: number | { until?: any }) { let log = await window.withProgress( @@ -272,7 +273,7 @@ export class LineHistoryNode }, () => this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/lineHistoryTrackerNode.ts b/src/views/nodes/lineHistoryTrackerNode.ts index 7c6c18dedfed8..3490268b3be5b 100644 --- a/src/views/nodes/lineHistoryTrackerNode.ts +++ b/src/views/nodes/lineHistoryTrackerNode.ts @@ -1,51 +1,57 @@ import type { Selection } from 'vscode'; import { TreeItem, TreeItemCollapsibleState, window } from 'vscode'; -import { UriComparer } from '../../comparers'; -import { ContextKeys } from '../../constants'; -import { setContext } from '../../context'; import type { GitCommitish } from '../../git/gitUri'; import { GitUri, unknownGitUri } from '../../git/gitUri'; -import { GitReference, GitRevision } from '../../git/models/reference'; -import { Logger } from '../../logger'; -import { getLogScope } from '../../logScope'; -import { ReferencePicker } from '../../quickpicks/referencePicker'; +import { deletedOrMissing } from '../../git/models/constants'; +import { isBranchReference, isSha } from '../../git/models/reference'; +import { showReferencePicker } from '../../quickpicks/referencePicker'; +import { UriComparer } from '../../system/comparers'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; import { debounce } from '../../system/function'; -import type { LinesChangeEvent } from '../../trackers/gitLineTracker'; +import { Logger } from '../../system/logger'; +import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; +import { setContext } from '../../system/vscode/context'; +import type { LinesChangeEvent } from '../../trackers/lineTracker'; import type { FileHistoryView } from '../fileHistoryView'; import type { LineHistoryView } from '../lineHistoryView'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import { LineHistoryNode } from './lineHistoryNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, SubscribeableViewNode } from './viewNode'; -export class LineHistoryTrackerNode extends SubscribeableViewNode { +export class LineHistoryTrackerNode extends SubscribeableViewNode< + 'line-history-tracker', + FileHistoryView | LineHistoryView +> { private _base: string | undefined; - private _child: LineHistoryNode | undefined; private _editorContents: string | undefined; private _selection: Selection | undefined; protected override splatted = true; constructor(view: FileHistoryView | LineHistoryView) { - super(unknownGitUri, view); + super('line-history-tracker', unknownGitUri, view); } override dispose() { super.dispose(); - - this.resetChild(); + this.child = undefined; } - @debug() - private resetChild() { - if (this._child == null) return; + private _child: LineHistoryNode | undefined; + protected get child(): LineHistoryNode | undefined { + return this._child; + } + protected set child(value: LineHistoryNode | undefined) { + if (this._child === value) return; - this._child.dispose(); - this._child = undefined; + this._child?.dispose(); + this._child = value; } async getChildren(): Promise { - if (this._child == null) { + if (this.child == null) { if (!this.hasUri) { this.view.description = undefined; @@ -53,17 +59,16 @@ export class LineHistoryTrackerNode extends SubscribeableViewNode b.name === commitish.sha, })); } - this._child = new LineHistoryNode(fileUri, this.view, this, branch, this._selection, this._editorContents); + this.child = new LineHistoryNode(fileUri, this.view, this, branch, selection, editorContents); } - return this._child.getChildren(); + return this.child.getChildren(); } getTreeItem(): TreeItem { @@ -110,41 +115,38 @@ export class LineHistoryTrackerNode extends SubscribeableViewNode `returned ${r}`, - }) + @debug({ exit: true }) override async refresh(reset: boolean = false) { const scope = getLogScope(); @@ -152,7 +154,7 @@ export class LineHistoryTrackerNode extends SubscribeableViewNode { - if (e.pending) return; - - onActiveLinesChanged(e); - }), + weakEvent( + this.view.container.lineTracker.onDidChangeActiveLines, + (e: LinesChangeEvent) => { + if (e.pending) return; + + onActiveLinesChanged(e); + }, + this, + ), ); } @@ -260,6 +260,6 @@ export class LineHistoryTrackerNode extends SubscribeableViewNode { +export class MergeConflictCurrentChangesNode extends ViewNode< + 'conflict-current-changes', + ViewsWithCommits | FileHistoryView | LineHistoryView +> { constructor( view: ViewsWithCommits | FileHistoryView | LineHistoryView, - parent: ViewNode, + protected override readonly parent: ViewNode, private readonly status: GitMergeStatus | GitRebaseStatus, private readonly file: GitFile, ) { - super(GitUri.fromFile(file, status.repoPath, 'HEAD'), view, parent); + super('conflict-current-changes', GitUri.fromFile(file, status.repoPath, 'HEAD'), view, parent); + } + + private _commit: Promise | undefined; + private async getCommit(): Promise { + if (this._commit == null) { + this._commit = this.view.container.git.getCommit(this.status.repoPath, 'HEAD'); + } + return this._commit; } getChildren(): ViewNode[] { @@ -29,57 +43,32 @@ export class MergeConflictCurrentChangesNode extends ViewNode { - const commit = await this.view.container.git.getCommit(this.status.repoPath, 'HEAD'); + const commit = await this.getCommit(); const item = new TreeItem('Current changes', TreeItemCollapsibleState.None); item.contextValue = ContextValues.MergeConflictCurrentChanges; - item.description = `${GitReference.toString(this.status.current, { expand: false, icon: false })}${ - commit != null ? ` (${GitReference.toString(commit, { expand: false, icon: false })})` : ' (HEAD)' + item.description = `${getReferenceLabel(this.status.current, { expand: false, icon: false })}${ + commit != null ? ` (${getReferenceLabel(commit, { expand: false, icon: false })})` : ' (HEAD)' }`; item.iconPath = this.view.config.avatars ? (await commit?.getAvatarUri({ defaultStyle: configuration.get('defaultGravatarsStyle') })) ?? new ThemeIcon('diff') : new ThemeIcon('diff'); - - const markdown = new MarkdownString( - `Current changes to $(file)${GlyphChars.Space}${this.file.path} on ${GitReference.toString( - this.status.current, - )}${ - commit != null - ? `\n\n${await CommitFormatter.fromTemplateAsync( - `\${avatar} __\${author}__, \${ago}   _(\${date})_ \n\n\${message}\n\n\${link}\${' via 'pullRequest}`, - commit, - { - avatarSize: 16, - dateFormat: configuration.get('defaultDateFormat'), - // messageAutolinks: true, - messageIndent: 4, - outputFormat: 'markdown', - }, - )}` - : '' - }`, - true, - ); - markdown.supportHtml = true; - markdown.isTrusted = true; - - item.tooltip = markdown; item.command = this.getCommand(); return item; } - override getCommand(): Command | undefined { + override getCommand(): Command { if (this.status.mergeBase == null) { - return { - title: 'Open Revision', - command: CoreCommands.Open, - arguments: [this.view.container.git.getRevisionUri('HEAD', this.file.path, this.status.repoPath)], - }; + return createCoreCommand( + 'vscode.open', + 'Open Revision', + this.view.container.git.getRevisionUri('HEAD', this.file.path, this.status.repoPath), + ); } - const commandArgs: DiffWithCommandArgs = { + return createCommand<[DiffWithCommandArgs]>(Commands.DiffWith, 'Open Changes', { lhs: { sha: this.status.mergeBase, uri: GitUri.fromFile(this.file, this.status.repoPath, undefined, true), @@ -88,7 +77,7 @@ export class MergeConflictCurrentChangesNode extends ViewNode { + if (item.tooltip == null) { + item.tooltip = await this.getTooltip(token); + } + return item; + } + + private async getTooltip(cancellation: CancellationToken) { + const commit = await this.getCommit(); + if (cancellation.isCancellationRequested) return undefined; + + const markdown = new MarkdownString( + `Current changes on ${getReferenceLabel(this.status.current, { label: false })}\\\n$(file)${ + GlyphChars.Space + }${this.file.path}`, + true, + ); + + if (commit == null) return markdown; + + const tooltip = await getFileRevisionAsCommitTooltip( + this.view.container, + commit, + this.file, + this.view.config.formats.commits.tooltipWithStatus, + { cancellation: cancellation }, + ); + + markdown.appendMarkdown(`\n\n${tooltip}`); + markdown.isTrusted = true; + + return markdown; } } diff --git a/src/views/nodes/mergeConflictFileNode.ts b/src/views/nodes/mergeConflictFileNode.ts index 1835578d72c8f..7ce8dafac2192 100644 --- a/src/views/nodes/mergeConflictFileNode.ts +++ b/src/views/nodes/mergeConflictFileNode.ts @@ -1,27 +1,28 @@ import type { Command, Uri } from 'vscode'; import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { CoreCommands } from '../../constants'; import { StatusFileFormatter } from '../../git/formatters/statusFormatter'; import { GitUri } from '../../git/gitUri'; import type { GitFile } from '../../git/models/file'; import type { GitMergeStatus } from '../../git/models/merge'; import type { GitRebaseStatus } from '../../git/models/rebase'; -import { relativeDir } from '../../system/path'; +import { createCoreCommand } from '../../system/vscode/command'; +import { relativeDir } from '../../system/vscode/path'; import type { ViewsWithCommits } from '../viewBase'; +import { ViewFileNode } from './abstract/viewFileNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import type { FileNode } from './folderNode'; import { MergeConflictCurrentChangesNode } from './mergeConflictCurrentChangesNode'; import { MergeConflictIncomingChangesNode } from './mergeConflictIncomingChangesNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, ViewFileNode } from './viewNode'; -export class MergeConflictFileNode extends ViewFileNode implements FileNode { +export class MergeConflictFileNode extends ViewFileNode<'conflict-file', ViewsWithCommits> implements FileNode { constructor( view: ViewsWithCommits, parent: ViewNode, file: GitFile, public readonly status: GitMergeStatus | GitRebaseStatus, ) { - super(GitUri.fromFile(file, status.repoPath, status.HEAD.ref), view, parent, file); + super('conflict-file', GitUri.fromFile(file, status.repoPath, status.HEAD.ref), view, parent, file); } override toClipboard(): string { @@ -116,17 +117,15 @@ export class MergeConflictFileNode extends ViewFileNode implem this._description = undefined; } - override getCommand(): Command | undefined { - return { - title: 'Open File', - command: CoreCommands.Open, - arguments: [ - this.view.container.git.getAbsoluteUri(this.file.path, this.repoPath), - { - preserveFocus: true, - preview: true, - }, - ], - }; + override getCommand(): Command { + return createCoreCommand( + 'vscode.open', + 'Open File', + this.view.container.git.getAbsoluteUri(this.file.path, this.repoPath), + { + preserveFocus: true, + preview: true, + }, + ); } } diff --git a/src/views/nodes/mergeConflictFilesNode.ts b/src/views/nodes/mergeConflictFilesNode.ts new file mode 100644 index 0000000000000..f603bbc9a642f --- /dev/null +++ b/src/views/nodes/mergeConflictFilesNode.ts @@ -0,0 +1,55 @@ +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { GitUri } from '../../git/gitUri'; +import type { GitMergeStatus } from '../../git/models/merge'; +import type { GitRebaseStatus } from '../../git/models/rebase'; +import type { GitStatusFile } from '../../git/models/status'; +import { makeHierarchical } from '../../system/array'; +import { joinPaths, normalizePath } from '../../system/path'; +import { pluralize, sortCompare } from '../../system/string'; +import type { ViewsWithCommits } from '../viewBase'; +import { ViewNode } from './abstract/viewNode'; +import type { FileNode } from './folderNode'; +import { FolderNode } from './folderNode'; +import { MergeConflictFileNode } from './mergeConflictFileNode'; + +export class MergeConflictFilesNode extends ViewNode<'conflict-files', ViewsWithCommits> { + constructor( + view: ViewsWithCommits, + protected override readonly parent: ViewNode, + private readonly status: GitMergeStatus | GitRebaseStatus, + private readonly conflicts: GitStatusFile[], + ) { + super('conflict-files', GitUri.fromRepoPath(status.repoPath), view, parent); + } + + get repoPath(): string { + return this.uri.repoPath!; + } + + getChildren(): ViewNode[] { + let children: (FileNode | FolderNode)[] = this.conflicts.map( + f => new MergeConflictFileNode(this.view, this, f, this.status), + ); + + if (this.view.config.files.layout !== 'list') { + const hierarchy = makeHierarchical( + children as FileNode[], + n => n.uri.relativePath.split('/'), + (...parts: string[]) => normalizePath(joinPaths(...parts)), + this.view.config.files.compact, + ); + + const root = new FolderNode(this.view, this, hierarchy, this.repoPath, '', undefined); + children = root.getChildren(); + } else { + children.sort((a, b) => sortCompare(a.label!, b.label!)); + } + + return children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem(pluralize('conflict', this.conflicts.length), TreeItemCollapsibleState.Expanded); + return item; + } +} diff --git a/src/views/nodes/mergeConflictIncomingChangesNode.ts b/src/views/nodes/mergeConflictIncomingChangesNode.ts index 846805cfb6102..381c57005e182 100644 --- a/src/views/nodes/mergeConflictIncomingChangesNode.ts +++ b/src/views/nodes/mergeConflictIncomingChangesNode.ts @@ -1,27 +1,44 @@ -import type { Command } from 'vscode'; +import type { CancellationToken, Command } from 'vscode'; import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { DiffWithCommandArgs } from '../../commands'; -import { configuration } from '../../configuration'; -import { Commands, CoreCommands, GlyphChars } from '../../constants'; -import { CommitFormatter } from '../../git/formatters/commitFormatter'; +import type { DiffWithCommandArgs } from '../../commands/diffWith'; +import { GlyphChars } from '../../constants'; +import { Commands } from '../../constants.commands'; import { GitUri } from '../../git/gitUri'; +import type { GitCommit } from '../../git/models/commit'; import type { GitFile } from '../../git/models/file'; import type { GitMergeStatus } from '../../git/models/merge'; import type { GitRebaseStatus } from '../../git/models/rebase'; -import { GitReference } from '../../git/models/reference'; +import { getReferenceLabel } from '../../git/models/reference'; +import { createCommand, createCoreCommand } from '../../system/vscode/command'; +import { configuration } from '../../system/vscode/configuration'; import type { FileHistoryView } from '../fileHistoryView'; import type { LineHistoryView } from '../lineHistoryView'; import type { ViewsWithCommits } from '../viewBase'; -import { ContextValues, ViewNode } from './viewNode'; +import { ContextValues, ViewNode } from './abstract/viewNode'; +import { getFileRevisionAsCommitTooltip } from './fileRevisionAsCommitNode'; -export class MergeConflictIncomingChangesNode extends ViewNode { +export class MergeConflictIncomingChangesNode extends ViewNode< + 'conflict-incoming-changes', + ViewsWithCommits | FileHistoryView | LineHistoryView +> { constructor( view: ViewsWithCommits | FileHistoryView | LineHistoryView, - parent: ViewNode, + protected override readonly parent: ViewNode, private readonly status: GitMergeStatus | GitRebaseStatus, private readonly file: GitFile, ) { - super(GitUri.fromFile(file, status.repoPath, status.HEAD.ref), view, parent); + super('conflict-incoming-changes', GitUri.fromFile(file, status.repoPath, status.HEAD.ref), view, parent); + } + + private _commit: Promise | undefined; + private async getCommit(): Promise { + if (this._commit == null) { + const ref = this.status.type === 'rebase' ? this.status.steps.current.commit?.ref : this.status.HEAD.ref; + if (ref == null) return undefined; + + this._commit = this.view.container.git.getCommit(this.status.repoPath, ref); + } + return this._commit; } getChildren(): ViewNode[] { @@ -29,54 +46,22 @@ export class MergeConflictIncomingChangesNode extends ViewNode { - const commit = await this.view.container.git.getCommit( - this.status.repoPath, - this.status.type === 'rebase' ? this.status.steps.current.commit.ref : this.status.HEAD.ref, - ); + const commit = await this.getCommit(); const item = new TreeItem('Incoming changes', TreeItemCollapsibleState.None); item.contextValue = ContextValues.MergeConflictIncomingChanges; - item.description = `${GitReference.toString(this.status.incoming, { expand: false, icon: false })}${ + item.description = `${getReferenceLabel(this.status.incoming, { expand: false, icon: false })}${ this.status.type === 'rebase' - ? ` (${GitReference.toString(this.status.steps.current.commit, { expand: false, icon: false })})` - : ` (${GitReference.toString(this.status.HEAD, { expand: false, icon: false })})` + ? ` (${getReferenceLabel(this.status.steps.current.commit, { + expand: false, + icon: false, + })})` + : ` (${getReferenceLabel(this.status.HEAD, { expand: false, icon: false })})` }`; item.iconPath = this.view.config.avatars ? (await commit?.getAvatarUri({ defaultStyle: configuration.get('defaultGravatarsStyle') })) ?? new ThemeIcon('diff') : new ThemeIcon('diff'); - - const markdown = new MarkdownString( - `Incoming changes to $(file)${GlyphChars.Space}${this.file.path}${ - this.status.incoming != null - ? ` from ${GitReference.toString(this.status.incoming)}${ - commit != null - ? `\n\n${await CommitFormatter.fromTemplateAsync( - `\${avatar} __\${author}__, \${ago}   _(\${date})_ \n\n\${message}\n\n\${link}\${' via 'pullRequest}`, - commit, - { - avatarSize: 16, - dateFormat: configuration.get('defaultDateFormat'), - // messageAutolinks: true, - messageIndent: 4, - outputFormat: 'markdown', - }, - )}` - : this.status.type === 'rebase' - ? `\n\n${GitReference.toString(this.status.steps.current.commit, { - capitalize: true, - label: false, - })}` - : `\n\n${GitReference.toString(this.status.HEAD, { capitalize: true, label: false })}` - }` - : '' - }`, - true, - ); - markdown.supportHtml = true; - markdown.isTrusted = true; - - item.tooltip = markdown; item.command = this.getCommand(); return item; @@ -84,16 +69,14 @@ export class MergeConflictIncomingChangesNode extends ViewNode(Commands.DiffWith, 'Open Changes', { lhs: { sha: this.status.mergeBase, uri: GitUri.fromFile(this.file, this.status.repoPath, undefined, true), @@ -104,7 +87,7 @@ export class MergeConflictIncomingChangesNode extends ViewNode { + if (item.tooltip == null) { + item.tooltip = await this.getTooltip(token); + } + return item; + } + + private async getTooltip(cancellation: CancellationToken) { + const commit = await this.getCommit(); + if (cancellation.isCancellationRequested) return undefined; + + const markdown = new MarkdownString( + `Incoming changes from ${getReferenceLabel(this.status.incoming, { label: false })}\\\n$(file)${ + GlyphChars.Space + }${this.file.path}`, + true, + ); + + if (commit == null) { + markdown.appendMarkdown( + this.status.type === 'rebase' + ? `\n\n${getReferenceLabel(this.status.steps.current.commit, { + capitalize: true, + label: false, + })}` + : `\n\n${getReferenceLabel(this.status.HEAD, { + capitalize: true, + label: false, + })}`, + ); + return markdown; + } + + const tooltip = await getFileRevisionAsCommitTooltip( + this.view.container, + commit, + this.file, + this.view.config.formats.commits.tooltipWithStatus, + { cancellation: cancellation }, + ); + + markdown.appendMarkdown(`\n\n${tooltip}`); + markdown.isTrusted = true; + + return markdown; } } diff --git a/src/views/nodes/mergeStatusNode.ts b/src/views/nodes/mergeStatusNode.ts index 72aacd1ba9753..21679dea46912 100644 --- a/src/views/nodes/mergeStatusNode.ts +++ b/src/views/nodes/mergeStatusNode.ts @@ -1,40 +1,30 @@ import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { ViewFilesLayout } from '../../configuration'; +import type { Colors } from '../../constants.colors'; import { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; import type { GitMergeStatus } from '../../git/models/merge'; -import { GitReference } from '../../git/models/reference'; +import { getReferenceLabel } from '../../git/models/reference'; import type { GitStatus } from '../../git/models/status'; -import { makeHierarchical } from '../../system/array'; -import { joinPaths, normalizePath } from '../../system/path'; -import { pluralize, sortCompare } from '../../system/string'; +import { pluralize } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; -import { BranchNode } from './branchNode'; -import type { FileNode } from './folderNode'; -import { FolderNode } from './folderNode'; -import { MergeConflictFileNode } from './mergeConflictFileNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export class MergeStatusNode extends ViewNode { - static key = ':merge'; - static getId(repoPath: string, name: string, root: boolean): string { - return `${BranchNode.getId(repoPath, name, root)}${this.key}`; - } +import { createViewDecorationUri } from '../viewDecorationProvider'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; +import { MergeConflictFilesNode } from './mergeConflictFilesNode'; +export class MergeStatusNode extends ViewNode<'merge-status', ViewsWithCommits> { constructor( view: ViewsWithCommits, - parent: ViewNode, + protected override readonly parent: ViewNode, public readonly branch: GitBranch, public readonly mergeStatus: GitMergeStatus, public readonly status: GitStatus | undefined, // Specifies that the node is shown as a root public readonly root: boolean, ) { - super(GitUri.fromRepoPath(mergeStatus.repoPath), view, parent); - } + super('merge-status', GitUri.fromRepoPath(mergeStatus.repoPath), view, parent); - override get id(): string { - return MergeStatusNode.getId(this.mergeStatus.repoPath, this.mergeStatus.current.name, this.root); + this.updateContext({ branch: branch, root: root, status: 'merging' }); + this._uniqueId = getViewNodeId(this.type, this.context); } get repoPath(): string { @@ -42,57 +32,50 @@ export class MergeStatusNode extends ViewNode { } getChildren(): ViewNode[] { - if (this.status?.hasConflicts !== true) return []; - - let children: FileNode[] = this.status.conflicts.map( - f => new MergeConflictFileNode(this.view, this, f, this.mergeStatus), - ); - - if (this.view.config.files.layout !== ViewFilesLayout.List) { - const hierarchy = makeHierarchical( - children, - n => n.uri.relativePath.split('/'), - (...parts: string[]) => normalizePath(joinPaths(...parts)), - this.view.config.files.compact, - ); - - const root = new FolderNode(this.view, this, this.repoPath, '', hierarchy); - children = root.getChildren() as FileNode[]; - } else { - children.sort((a, b) => sortCompare(a.label!, b.label!)); - } - - return children; + return this.status?.hasConflicts + ? [new MergeConflictFilesNode(this.view, this, this.mergeStatus, this.status.conflicts)] + : []; } getTreeItem(): TreeItem { + const hasConflicts = this.status?.hasConflicts === true; const item = new TreeItem( - `${this.status?.hasConflicts ? 'Resolve conflicts before merging' : 'Merging'} ${ + `${hasConflicts ? 'Resolve conflicts before merging' : 'Merging'} ${ this.mergeStatus.incoming != null - ? `${GitReference.toString(this.mergeStatus.incoming, { expand: false, icon: false })} ` + ? `${getReferenceLabel(this.mergeStatus.incoming, { expand: false, icon: false })} ` : '' - }into ${GitReference.toString(this.mergeStatus.current, { expand: false, icon: false })}`, - TreeItemCollapsibleState.Expanded, + }into ${getReferenceLabel(this.mergeStatus.current, { expand: false, icon: false })}`, + hasConflicts ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None, ); item.id = this.id; item.contextValue = ContextValues.Merge; - item.description = this.status?.hasConflicts ? pluralize('conflict', this.status.conflicts.length) : undefined; - item.iconPath = this.status?.hasConflicts - ? new ThemeIcon('warning', new ThemeColor('list.warningForeground')) - : new ThemeIcon('debug-pause', new ThemeColor('list.foreground')); + item.description = hasConflicts ? pluralize('conflict', this.status.conflicts.length) : undefined; + item.iconPath = hasConflicts + ? new ThemeIcon( + 'warning', + new ThemeColor( + 'gitlens.decorations.statusMergingOrRebasingConflictForegroundColor' satisfies Colors, + ), + ) + : new ThemeIcon( + 'warning', + new ThemeColor('gitlens.decorations.statusMergingOrRebasingForegroundColor' satisfies Colors), + ); const markdown = new MarkdownString( - `${`Merging ${ - this.mergeStatus.incoming != null ? GitReference.toString(this.mergeStatus.incoming) : '' - }into ${GitReference.toString(this.mergeStatus.current)}`}${ - this.status?.hasConflicts ? `\n\n${pluralize('conflicted file', this.status.conflicts.length)}` : '' + `Merging ${ + this.mergeStatus.incoming != null ? getReferenceLabel(this.mergeStatus.incoming, { label: false }) : '' + }into ${getReferenceLabel(this.mergeStatus.current, { label: false })}${ + hasConflicts + ? `\n\nResolve ${pluralize('conflict', this.status.conflicts.length)} before continuing` + : '' }`, true, ); markdown.supportHtml = true; markdown.isTrusted = true; - item.tooltip = markdown; + item.resourceUri = createViewDecorationUri('status', { status: 'merging', conflicts: hasConflicts }); return item; } diff --git a/src/views/nodes/pullRequestNode.ts b/src/views/nodes/pullRequestNode.ts index f44ad733db500..6fd1c1707d1a2 100644 --- a/src/views/nodes/pullRequestNode.ts +++ b/src/views/nodes/pullRequestNode.ts @@ -1,27 +1,37 @@ import { MarkdownString, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { GitUri } from '../../git/gitUri'; -import type { GitBranch } from '../../git/models/branch'; +import { GitBranch } from '../../git/models/branch'; import type { GitCommit } from '../../git/models/commit'; -import { isCommit } from '../../git/models/commit'; -import { PullRequest, PullRequestState } from '../../git/models/pullRequest'; +import { getIssueOrPullRequestMarkdownIcon, getIssueOrPullRequestThemeIcon } from '../../git/models/issue'; +import type { PullRequest } from '../../git/models/pullRequest'; +import { + ensurePullRequestRefs, + getComparisonRefsForPullRequest, + getOrOpenPullRequestRepository, +} from '../../git/models/pullRequest'; +import type { GitBranchReference } from '../../git/models/reference'; +import { createRevisionRange } from '../../git/models/reference'; +import type { Repository } from '../../git/models/repository'; +import { getAheadBehindFilesQuery, getCommitsQuery } from '../../git/queryResults'; +import { pluralize } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; -import { ContextValues, ViewNode } from './viewNode'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ClipboardType, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { CodeSuggestionsNode } from './codeSuggestionsNode'; +import { MessageNode } from './common'; +import { ResultsCommitsNode } from './resultsCommitsNode'; +import { ResultsFilesNode } from './resultsFilesNode'; -export class PullRequestNode extends ViewNode { - static key = ':pullrequest'; - static getId(parent: ViewNode, id: string, ref?: string): string { - return `${parent.id}${this.key}(${id}):${ref}`; - } - - public readonly pullRequest: PullRequest; - private readonly branchOrCommit?: GitBranch | GitCommit; - private readonly repoPath: string; +export class PullRequestNode extends CacheableChildrenViewNode<'pullrequest', ViewsWithCommits> { + readonly repoPath: string; constructor( view: ViewsWithCommits, protected override readonly parent: ViewNode, - pullRequest: PullRequest, + public readonly pullRequest: PullRequest, branchOrCommitOrRepoPath: GitBranch | GitCommit | string, + private readonly options?: { expand?: boolean }, ) { let branchOrCommit; let repoPath; @@ -32,57 +42,204 @@ export class PullRequestNode extends ViewNode { branchOrCommit = branchOrCommitOrRepoPath; } - super(GitUri.fromRepoPath(repoPath), view, parent); + super('pullrequest', GitUri.fromRepoPath(repoPath), view, parent); - this.branchOrCommit = branchOrCommit; - this.pullRequest = pullRequest; + if (branchOrCommit != null) { + if (branchOrCommit instanceof GitBranch) { + this.updateContext({ branch: branchOrCommit }); + } else { + this.updateContext({ commit: branchOrCommit }); + } + } + + this.updateContext({ pullRequest: pullRequest }); + this._uniqueId = getViewNodeId(this.type, this.context); this.repoPath = repoPath; } - override toClipboard(): string { + override get id(): string { + return this._uniqueId; + } + + override toClipboard(type?: ClipboardType): string { + const url = this.getUrl(); + switch (type) { + case 'markdown': + return `[${this.pullRequest.id}](${url}) ${this.pullRequest.title}`; + default: + return url; + } + } + + override getUrl(): string { return this.pullRequest.url; } - override get id(): string { - return PullRequestNode.getId(this.parent, this.pullRequest.id, this.branchOrCommit?.ref); + get baseRef(): GitBranchReference | undefined { + if (this.pullRequest.refs?.base != null) { + return { + refType: 'branch', + repoPath: this.repoPath, + ref: this.pullRequest.refs.base.sha, + name: this.pullRequest.refs.base.branch, + remote: true, + }; + } + return undefined; } - getChildren(): ViewNode[] { - return []; + get ref(): GitBranchReference | undefined { + if (this.pullRequest.refs?.head != null) { + return { + refType: 'branch', + repoPath: this.repoPath, + ref: this.pullRequest.refs.head.sha, + name: this.pullRequest.refs.head.branch, + remote: true, + }; + } + return undefined; + } + + async getChildren(): Promise { + if (this.children == null) { + const children = await getPullRequestChildren(this.view, this, this.pullRequest, this.repoPath); + this.children = children; + } + return this.children; } getTreeItem(): TreeItem { - const item = new TreeItem(`#${this.pullRequest.id}: ${this.pullRequest.title}`, TreeItemCollapsibleState.None); + const hasRefs = this.pullRequest.refs?.base != null && this.pullRequest.refs.head != null; + + const item = new TreeItem( + `#${this.pullRequest.id}: ${this.pullRequest.title}`, + hasRefs + ? this.options?.expand + ? TreeItemCollapsibleState.Expanded + : TreeItemCollapsibleState.Collapsed + : TreeItemCollapsibleState.None, + ); item.id = this.id; item.contextValue = ContextValues.PullRequest; + if (this.pullRequest.refs?.base != null && this.pullRequest.refs.head != null) { + item.contextValue += `+refs`; + } item.description = `${this.pullRequest.state}, ${this.pullRequest.formatDateFromNow()}`; - item.iconPath = PullRequest.getThemeIcon(this.pullRequest); + item.iconPath = getIssueOrPullRequestThemeIcon(this.pullRequest); + item.tooltip = getPullRequestTooltip(this.pullRequest, this.context); - const tooltip = new MarkdownString('', true); - tooltip.supportHtml = true; - tooltip.isTrusted = true; + return item; + } +} - if (isCommit(this.branchOrCommit)) { - tooltip.appendMarkdown( - `Commit \`$(git-commit) ${this.branchOrCommit.shortSha}\` was introduced by $(git-pull-request) PR #${this.pullRequest.id}\n\n`, - ); - } +export async function getPullRequestChildren( + view: ViewsWithCommits, + parent: ViewNode, + pullRequest: PullRequest, + repoOrPath?: Repository | string, +) { + let repo: Repository | undefined; + if (repoOrPath == null) { + repo = await getOrOpenPullRequestRepository(view.container, pullRequest, { promptIfNeeded: true }); + } else if (typeof repoOrPath === 'string') { + repo = view.container.git.getRepository(repoOrPath); + } else { + repo = repoOrPath; + } - const linkTitle = ` "Open Pull Request \\#${this.pullRequest.id} on ${this.pullRequest.provider.name}"`; - tooltip.appendMarkdown( - `${PullRequest.getMarkdownIcon(this.pullRequest)} [**${this.pullRequest.title.trim()}**](${ - this.pullRequest.url - }${linkTitle}) \\\n[#${this.pullRequest.id}](${this.pullRequest.url}${linkTitle}) by [@${ - this.pullRequest.author.name - }](${this.pullRequest.author.url} "Open @${this.pullRequest.author.name} on ${ - this.pullRequest.provider.name - }") was ${ - this.pullRequest.state === PullRequestState.Open ? 'opened' : this.pullRequest.state.toLowerCase() - } ${this.pullRequest.formatDateFromNow()}`, - ); + if (repo == null) { + return [ + new MessageNode( + view, + parent, + `Unable to locate repository '${pullRequest.refs?.head.owner ?? pullRequest.repository.owner}/${ + pullRequest.refs?.head.repo ?? pullRequest.repository.repo + }'.`, + ), + ]; + } - item.tooltip = tooltip; + const repoPath = repo.path; + const refs = getComparisonRefsForPullRequest(repoPath, pullRequest.refs!); - return item; + const counts = await ensurePullRequestRefs( + view.container, + pullRequest, + repo, + { promptMessage: `Unable to open details for PR #${pullRequest.id} because of a missing remote.` }, + refs, + ); + if (!counts?.right) { + return [new MessageNode(view, parent, 'No commits could be found.')]; } + + const comparison = { + ref1: refs.base.ref, + ref2: refs.head.ref, + }; + + const children = [ + new ResultsCommitsNode( + view, + parent, + repoPath, + 'Commits', + { + query: getCommitsQuery( + view.container, + repoPath, + createRevisionRange(comparison.ref1, comparison.ref2, '..'), + ), + comparison: comparison, + }, + { + autolinks: false, + expand: false, + description: pluralize('commit', counts?.right ?? 0), + }, + ), + new CodeSuggestionsNode(view, parent, repoPath, pullRequest), + new ResultsFilesNode( + view, + parent, + repoPath, + comparison.ref1, + comparison.ref2, + () => + getAheadBehindFilesQuery( + view.container, + repoPath, + createRevisionRange(comparison.ref1, comparison.ref2, '...'), + false, + ), + undefined, + { expand: true, timeout: false }, + ), + ]; + return children; +} + +export function getPullRequestTooltip(pullRequest: PullRequest, context?: { commit?: GitCommit }) { + const tooltip = new MarkdownString('', true); + tooltip.supportHtml = true; + tooltip.isTrusted = true; + + if (context?.commit != null) { + tooltip.appendMarkdown( + `Commit \`$(git-commit) ${context.commit.shortSha}\` was introduced by $(git-pull-request) PR #${pullRequest.id}\n\n`, + ); + } + + const linkTitle = ` "Open Pull Request \\#${pullRequest.id} on ${pullRequest.provider.name}"`; + tooltip.appendMarkdown( + `${getIssueOrPullRequestMarkdownIcon(pullRequest)} [**${pullRequest.title.trim()}**](${ + pullRequest.url + }${linkTitle}) \\\n[#${pullRequest.id}](${pullRequest.url}${linkTitle}) by [@${pullRequest.author.name}](${ + pullRequest.author.url + } "Open @${pullRequest.author.name} on ${ + pullRequest.provider.name + }") was ${pullRequest.state.toLowerCase()} ${pullRequest.formatDateFromNow()}`, + ); + return tooltip; } diff --git a/src/views/nodes/rebaseCommitNode.ts b/src/views/nodes/rebaseCommitNode.ts new file mode 100644 index 0000000000000..58df769d00b3b --- /dev/null +++ b/src/views/nodes/rebaseCommitNode.ts @@ -0,0 +1,26 @@ +import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { CommitFormatter } from '../../git/formatters/commitFormatter'; +import { ContextValues } from './abstract/viewNode'; +import { CommitNode } from './commitNode'; + +export class RebaseCommitNode extends CommitNode { + // eslint-disable-next-line @typescript-eslint/require-await + override async getTreeItem(): Promise { + const item = new TreeItem( + `Paused at commit ${this.commit.shortSha}`, + this._options.expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, + ); + item.id = this.id; + item.contextValue = `${ContextValues.Commit}+rebase`; + item.description = CommitFormatter.fromTemplate(`\${message}`, this.commit, { + messageTruncateAtNewLine: true, + }); + item.iconPath = new ThemeIcon('debug-pause'); + + return item; + } + + protected override getTooltipTemplate(): string { + return `Rebase paused at ${super.getTooltipTemplate()}`; + } +} diff --git a/src/views/nodes/rebaseStatusNode.ts b/src/views/nodes/rebaseStatusNode.ts index e018d07fd5319..97bc83f7a0437 100644 --- a/src/views/nodes/rebaseStatusNode.ts +++ b/src/views/nodes/rebaseStatusNode.ts @@ -1,49 +1,32 @@ -import type { Command } from 'vscode'; import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; -import type { DiffWithPreviousCommandArgs } from '../../commands'; -import { configuration, ViewFilesLayout } from '../../configuration'; -import { Commands, CoreCommands } from '../../constants'; -import { CommitFormatter } from '../../git/formatters/commitFormatter'; +import type { Colors } from '../../constants.colors'; import { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; -import type { GitCommit } from '../../git/models/commit'; import type { GitRebaseStatus } from '../../git/models/rebase'; -import type { GitRevisionReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; +import { getReferenceLabel } from '../../git/models/reference'; import type { GitStatus } from '../../git/models/status'; -import { makeHierarchical } from '../../system/array'; -import { executeCoreCommand } from '../../system/command'; -import { joinPaths, normalizePath } from '../../system/path'; -import { getSettledValue } from '../../system/promise'; -import { pluralize, sortCompare } from '../../system/string'; +import { pluralize } from '../../system/string'; +import { executeCoreCommand } from '../../system/vscode/command'; import type { ViewsWithCommits } from '../viewBase'; -import { BranchNode } from './branchNode'; -import { CommitFileNode } from './commitFileNode'; -import type { FileNode } from './folderNode'; -import { FolderNode } from './folderNode'; -import { MergeConflictFileNode } from './mergeConflictFileNode'; -import { ContextValues, ViewNode, ViewRefNode } from './viewNode'; - -export class RebaseStatusNode extends ViewNode { - static key = ':rebase'; - static getId(repoPath: string, name: string, root: boolean): string { - return `${BranchNode.getId(repoPath, name, root)}${this.key}`; - } +import { createViewDecorationUri } from '../viewDecorationProvider'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; +import { MergeConflictFilesNode } from './mergeConflictFilesNode'; +import { RebaseCommitNode } from './rebaseCommitNode'; +export class RebaseStatusNode extends ViewNode<'rebase-status', ViewsWithCommits> { constructor( view: ViewsWithCommits, - parent: ViewNode, + protected override readonly parent: ViewNode, public readonly branch: GitBranch, public readonly rebaseStatus: GitRebaseStatus, public readonly status: GitStatus | undefined, // Specifies that the node is shown as a root public readonly root: boolean, ) { - super(GitUri.fromRepoPath(rebaseStatus.repoPath), view, parent); - } + super('rebase-status', GitUri.fromRepoPath(rebaseStatus.repoPath), view, parent); - override get id(): string { - return RebaseStatusNode.getId(this.rebaseStatus.repoPath, this.rebaseStatus.incoming.name, this.root); + this.updateContext({ branch: branch, root: root, status: 'rebasing' }); + this._uniqueId = getViewNodeId(this.type, this.context); } get repoPath(): string { @@ -51,197 +34,87 @@ export class RebaseStatusNode extends ViewNode { } async getChildren(): Promise { - let children: FileNode[] = - this.status?.conflicts.map(f => new MergeConflictFileNode(this.view, this, f, this.rebaseStatus)) ?? []; - - if (this.view.config.files.layout !== ViewFilesLayout.List) { - const hierarchy = makeHierarchical( - children, - n => n.uri.relativePath.split('/'), - (...parts: string[]) => normalizePath(joinPaths(...parts)), - this.view.config.files.compact, - ); - - const root = new FolderNode(this.view, this, this.repoPath, '', hierarchy); - children = root.getChildren() as FileNode[]; - } else { - children.sort((a, b) => sortCompare(a.label!, b.label!)); + const children: (MergeConflictFilesNode | RebaseCommitNode)[] = []; + + const revision = this.rebaseStatus.steps.current.commit; + if (revision != null) { + const commit = + revision != null + ? await this.view.container.git.getCommit(this.rebaseStatus.repoPath, revision.ref) + : undefined; + if (commit != null) { + children.push(new RebaseCommitNode(this.view, this, commit)); + } } - const commit = await this.view.container.git.getCommit( - this.rebaseStatus.repoPath, - this.rebaseStatus.steps.current.commit.ref, - ); - if (commit != null) { - children.splice(0, 0, new RebaseCommitNode(this.view, this, commit) as any); + if (this.status?.hasConflicts) { + children.push(new MergeConflictFilesNode(this.view, this, this.rebaseStatus, this.status.conflicts)); } return children; } getTreeItem(): TreeItem { + const started = this.rebaseStatus.steps.total > 0; + const pausedAtCommit = started && this.rebaseStatus.steps.current.commit != null; + const hasConflicts = this.status?.hasConflicts === true; + const item = new TreeItem( - `${this.status?.hasConflicts ? 'Resolve conflicts to continue rebasing' : 'Rebasing'} ${ + `${hasConflicts ? 'Resolve conflicts to continue rebasing' : started ? 'Rebasing' : 'Pending rebase of'} ${ this.rebaseStatus.incoming != null - ? `${GitReference.toString(this.rebaseStatus.incoming, { expand: false, icon: false })}` + ? getReferenceLabel(this.rebaseStatus.incoming, { expand: false, icon: false }) : '' - } (${this.rebaseStatus.steps.current.number}/${this.rebaseStatus.steps.total})`, - TreeItemCollapsibleState.Expanded, + } onto ${getReferenceLabel(this.rebaseStatus.current ?? this.rebaseStatus.onto, { + expand: false, + icon: false, + })}${started ? ` (${this.rebaseStatus.steps.current.number}/${this.rebaseStatus.steps.total})` : ''}`, + pausedAtCommit ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None, ); item.id = this.id; item.contextValue = ContextValues.Rebase; - item.description = this.status?.hasConflicts ? pluralize('conflict', this.status.conflicts.length) : undefined; - item.iconPath = this.status?.hasConflicts - ? new ThemeIcon('warning', new ThemeColor('list.warningForeground')) - : new ThemeIcon('debug-pause', new ThemeColor('list.foreground')); + item.description = hasConflicts ? pluralize('conflict', this.status.conflicts.length) : undefined; + item.iconPath = hasConflicts + ? new ThemeIcon( + 'warning', + new ThemeColor( + 'gitlens.decorations.statusMergingOrRebasingConflictForegroundColor' satisfies Colors, + ), + ) + : new ThemeIcon( + 'warning', + new ThemeColor('gitlens.decorations.statusMergingOrRebasingForegroundColor' satisfies Colors), + ); const markdown = new MarkdownString( - `${`Rebasing ${ - this.rebaseStatus.incoming != null ? GitReference.toString(this.rebaseStatus.incoming) : '' - }onto ${GitReference.toString(this.rebaseStatus.current)}`}\n\nStep ${ - this.rebaseStatus.steps.current.number - } of ${this.rebaseStatus.steps.total}\\\nPaused at ${GitReference.toString( - this.rebaseStatus.steps.current.commit, - { icon: true }, - )}${this.status?.hasConflicts ? `\n\n${pluralize('conflicted file', this.status.conflicts.length)}` : ''}`, + `${started ? 'Rebasing' : 'Pending rebase of'} ${ + this.rebaseStatus.incoming != null + ? getReferenceLabel(this.rebaseStatus.incoming, { label: false }) + : '' + } onto ${getReferenceLabel(this.rebaseStatus.current ?? this.rebaseStatus.onto, { label: false })}${ + started + ? `\n\nPaused at step ${this.rebaseStatus.steps.current.number} of ${ + this.rebaseStatus.steps.total + }${ + hasConflicts + ? `\\\nResolve ${pluralize('conflict', this.status.conflicts.length)} before continuing` + : '' + }` + : '' + }`, true, ); markdown.supportHtml = true; markdown.isTrusted = true; - item.tooltip = markdown; + item.resourceUri = createViewDecorationUri('status', { status: 'rebasing', conflicts: hasConflicts }); return item; } async openEditor() { const rebaseTodoUri = Uri.joinPath(this.uri, '.git', 'rebase-merge', 'git-rebase-todo'); - await executeCoreCommand(CoreCommands.OpenWith, rebaseTodoUri, 'gitlens.rebase', { + await executeCoreCommand('vscode.openWith', rebaseTodoUri, 'gitlens.rebase', { preview: false, }); } } - -export class RebaseCommitNode extends ViewRefNode { - constructor(view: ViewsWithCommits, parent: ViewNode, public readonly commit: GitCommit) { - super(commit.getGitUri(), view, parent); - } - - override toClipboard(): string { - return `${this.commit.shortSha}: ${this.commit.summary}`; - } - - get ref(): GitRevisionReference { - return this.commit; - } - - async getChildren(): Promise { - const commit = this.commit; - - const commits = await commit.getCommitsForFiles(); - let children: FileNode[] = commits.map(c => new CommitFileNode(this.view, this, c.file!, c)); - - if (this.view.config.files.layout !== ViewFilesLayout.List) { - const hierarchy = makeHierarchical( - children, - n => n.uri.relativePath.split('/'), - (...parts: string[]) => normalizePath(joinPaths(...parts)), - this.view.config.files.compact, - ); - - const root = new FolderNode(this.view, this, this.repoPath, '', hierarchy); - children = root.getChildren() as FileNode[]; - } else { - children.sort((a, b) => sortCompare(a.label!, b.label!)); - } - - return children; - } - - getTreeItem(): TreeItem { - const item = new TreeItem(`Paused at commit ${this.commit.shortSha}`, TreeItemCollapsibleState.Collapsed); - - // item.contextValue = ContextValues.RebaseCommit; - - item.description = CommitFormatter.fromTemplate(`\${message}`, this.commit, { - messageTruncateAtNewLine: true, - }); - item.iconPath = new ThemeIcon('git-commit'); - - return item; - } - - override getCommand(): Command | undefined { - const commandArgs: DiffWithPreviousCommandArgs = { - commit: this.commit, - uri: this.uri, - line: 0, - showOptions: { - preserveFocus: true, - preview: true, - }, - }; - return { - title: 'Open Changes with Previous Revision', - command: Commands.DiffWithPrevious, - arguments: [undefined, commandArgs], - }; - } - - override async resolveTreeItem(item: TreeItem): Promise { - if (item.tooltip == null) { - item.tooltip = await this.getTooltip(); - } - return item; - } - - private async getTooltip() { - const remotes = await this.view.container.git.getRemotesWithProviders(this.commit.repoPath); - const remote = await this.view.container.git.getBestRemoteWithRichProvider(remotes); - - if (this.commit.message == null) { - await this.commit.ensureFullDetails(); - } - - let autolinkedIssuesOrPullRequests; - let pr; - - if (remote?.provider != null) { - const [autolinkedIssuesOrPullRequestsResult, prResult] = await Promise.allSettled([ - this.view.container.autolinks.getLinkedIssuesAndPullRequests( - this.commit.message ?? this.commit.summary, - remote, - ), - this.commit.getAssociatedPullRequest({ remote: remote }), - ]); - - autolinkedIssuesOrPullRequests = getSettledValue(autolinkedIssuesOrPullRequestsResult); - pr = getSettledValue(prResult); - - // Remove possible duplicate pull request - if (pr != null) { - autolinkedIssuesOrPullRequests?.delete(pr.id); - } - } - - const tooltip = await CommitFormatter.fromTemplateAsync( - `Rebase paused at ${this.view.config.formats.commits.tooltip}`, - this.commit, - { - autolinkedIssuesOrPullRequests: autolinkedIssuesOrPullRequests, - dateFormat: configuration.get('defaultDateFormat'), - messageAutolinks: true, - messageIndent: 4, - pullRequestOrRemote: pr, - outputFormat: 'markdown', - remotes: remotes, - }, - ); - - const markdown = new MarkdownString(tooltip, true); - markdown.supportHtml = true; - markdown.isTrusted = true; - - return markdown; - } -} diff --git a/src/views/nodes/reflogNode.ts b/src/views/nodes/reflogNode.ts index 5ab1b1059c44b..2672ac7166996 100644 --- a/src/views/nodes/reflogNode.ts +++ b/src/views/nodes/reflogNode.ts @@ -2,33 +2,40 @@ import { TreeItem, TreeItemCollapsibleState } from 'vscode'; import type { GitUri } from '../../git/gitUri'; import type { GitReflog } from '../../git/models/reflog'; import type { Repository } from '../../git/models/repository'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import type { RepositoriesView } from '../repositoriesView'; +import type { WorkspacesView } from '../workspacesView'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { LoadMoreNode, MessageNode } from './common'; import { ReflogRecordNode } from './reflogRecordNode'; -import { RepositoryNode } from './repositoryNode'; -import type { PageableViewNode } from './viewNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export class ReflogNode extends ViewNode implements PageableViewNode { - static key = ':reflog'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; - } - - private _children: ViewNode[] | undefined; - constructor(uri: GitUri, view: RepositoriesView, parent: ViewNode, public readonly repo: Repository) { - super(uri, view, parent); +export class ReflogNode + extends CacheableChildrenViewNode<'reflog', RepositoriesView | WorkspacesView> + implements PageableViewNode +{ + limit: number | undefined; + + constructor( + uri: GitUri, + view: RepositoriesView | WorkspacesView, + parent: ViewNode, + public readonly repo: Repository, + ) { + super('reflog', uri, view, parent); + + this.updateContext({ repository: repo }); + this._uniqueId = getViewNodeId(this.type, this.context); + this.limit = this.view.getNodeLastKnownLimit(this); } override get id(): string { - return ReflogNode.getId(this.repo.path); + return this._uniqueId; } async getChildren(): Promise { - if (this._children === undefined) { + if (this.children === undefined) { const children = []; const reflog = await this.getReflog(); @@ -42,9 +49,9 @@ export class ReflogNode extends ViewNode implements PageableVi children.push(new LoadMoreNode(this.view, this, children[children.length - 1])); } - this._children = children; + this.children = children; } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -60,10 +67,10 @@ export class ReflogNode extends ViewNode implements PageableVi return item; } - @gate() @debug() override refresh(reset?: boolean) { - this._children = undefined; + super.refresh(true); + if (reset) { this._reflog = undefined; } @@ -85,10 +92,9 @@ export class ReflogNode extends ViewNode implements PageableVi return this._reflog?.hasMore ?? true; } - limit: number | undefined = this.view.getNodeLastKnownLimit(this); async loadMore(limit?: number) { let reflog = await this.getReflog(); - if (reflog === undefined || !reflog.hasMore) return; + if (!reflog?.hasMore) return; reflog = await reflog.more?.(limit ?? this.view.config.pageItemLimit); if (this._reflog === reflog) return; diff --git a/src/views/nodes/reflogRecordNode.ts b/src/views/nodes/reflogRecordNode.ts index cee684ac1afe2..7774d9878db08 100644 --- a/src/views/nodes/reflogRecordNode.ts +++ b/src/views/nodes/reflogRecordNode.ts @@ -7,40 +7,28 @@ import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; import type { ViewsWithCommits } from '../viewBase'; +import type { PageableViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode, MessageNode } from './common'; -import { RepositoryNode } from './repositoryNode'; -import type { PageableViewNode } from './viewNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export class ReflogRecordNode extends ViewNode implements PageableViewNode { - static key = ':reflog-record'; - static getId( - repoPath: string, - sha: string, - selector: string, - command: string, - commandArgs: string | undefined, - date: Date, - ): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${sha}|${selector}|${command}|${ - commandArgs ?? '' - }|${date.getTime()})`; - } - constructor(view: ViewsWithCommits, parent: ViewNode, public readonly record: GitReflogRecord) { - super(GitUri.fromRepoPath(record.repoPath), view, parent); +export class ReflogRecordNode extends ViewNode<'reflog-record', ViewsWithCommits> implements PageableViewNode { + limit: number | undefined; + + constructor( + view: ViewsWithCommits, + parent: ViewNode, + public readonly record: GitReflogRecord, + ) { + super('reflog-record', GitUri.fromRepoPath(record.repoPath), view, parent); + + this.updateContext({ reflog: record }); + this._uniqueId = getViewNodeId(this.type, this.context); + this.limit = this.view.getNodeLastKnownLimit(this); } override get id(): string { - return ReflogRecordNode.getId( - this.uri.repoPath!, - this.record.sha, - this.record.selector, - this.record.command, - this.record.commandArgs, - this.record.date, - ); + return this._uniqueId; } async getChildren(): Promise { @@ -105,7 +93,6 @@ export class ReflogRecordNode extends ViewNode implements Page return this._log?.hasMore ?? true; } - limit: number | undefined = this.view.getNodeLastKnownLimit(this); @gate() async loadMore(limit?: number | { until?: any }) { let log = await window.withProgress( @@ -114,7 +101,7 @@ export class ReflogRecordNode extends ViewNode implements Page }, () => this.getLog(), ); - if (log === undefined || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/remoteNode.ts b/src/views/nodes/remoteNode.ts index 9b8a6b83ded7c..aea3890cdb627 100644 --- a/src/views/nodes/remoteNode.ts +++ b/src/views/nodes/remoteNode.ts @@ -1,5 +1,4 @@ -import { ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; -import { ViewBranchesLayout } from '../../configuration'; +import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { GlyphChars } from '../../constants'; import { GitUri } from '../../git/gitUri'; import type { GitRemote } from '../../git/models/remote'; @@ -7,36 +6,37 @@ import { getRemoteUpstreamDescription } from '../../git/models/remote'; import type { Repository } from '../../git/models/repository'; import { makeHierarchical } from '../../system/array'; import { log } from '../../system/decorators/log'; -import type { RemotesView } from '../remotesView'; -import type { RepositoriesView } from '../repositoriesView'; +import type { ViewsWithRemotes } from '../viewBase'; +import { createViewDecorationUri } from '../viewDecorationProvider'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import { BranchNode } from './branchNode'; import { BranchOrTagFolderNode } from './branchOrTagFolderNode'; import { MessageNode } from './common'; -import { RepositoryNode } from './repositoryNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export class RemoteNode extends ViewNode { - static key = ':remote'; - static getId(repoPath: string, name: string, id: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${name}|${id})`; - } +export class RemoteNode extends ViewNode<'remote', ViewsWithRemotes> { constructor( uri: GitUri, - view: RemotesView | RepositoriesView, - parent: ViewNode, - public readonly remote: GitRemote, + view: ViewsWithRemotes, + protected override readonly parent: ViewNode, public readonly repo: Repository, + public readonly remote: GitRemote, ) { - super(uri, view, parent); + super('remote', uri, view, parent); + + this.updateContext({ repository: repo, remote: remote }); + this._uniqueId = getViewNodeId(this.type, this.context); + } + + override get id(): string { + return this._uniqueId; } override toClipboard(): string { return this.remote.name; } - override get id(): string { - return RemoteNode.getId(this.remote.repoPath, this.remote.name, this.remote.id); + get repoPath(): string { + return this.repo.path; } async getChildren(): Promise { @@ -50,12 +50,12 @@ export class RemoteNode extends ViewNode { // TODO@eamodio handle paging const branchNodes = branches.values.map( b => - new BranchNode(GitUri.fromRepoPath(this.uri.repoPath!, b.ref), this.view, this, b, false, { + new BranchNode(GitUri.fromRepoPath(this.uri.repoPath!, b.ref), this.view, this, this.repo, b, false, { showComparison: false, showTracking: false, }), ); - if (this.view.config.branches.layout === ViewBranchesLayout.List) return branchNodes; + if (this.view.config.branches.layout === 'list') return branchNodes; const hierarchy = makeHierarchical( branchNodes, @@ -72,11 +72,10 @@ export class RemoteNode extends ViewNode { this.view, this, 'remote-branch', + hierarchy, this.repo.path, '', undefined, - hierarchy, - `remote(${this.remote.name})`, ); const children = root.getChildren(); return children; @@ -87,6 +86,7 @@ export class RemoteNode extends ViewNode { item.id = this.id; item.description = getRemoteUpstreamDescription(this.remote); + let tooltip; if (this.remote.provider != null) { const { provider } = this.remote; @@ -94,38 +94,49 @@ export class RemoteNode extends ViewNode { provider.avatarUri != null && this.view.config.avatars ? provider.avatarUri : provider.icon === 'remote' - ? new ThemeIcon('cloud') - : { - dark: this.view.container.context.asAbsolutePath(`images/dark/icon-${provider.icon}.svg`), - light: this.view.container.context.asAbsolutePath(`images/light/icon-${provider.icon}.svg`), - }; - - if (provider.hasRichIntegration()) { - const connected = provider.maybeConnected ?? (await provider.isConnected()); + ? new ThemeIcon('cloud') + : { + dark: this.view.container.context.asAbsolutePath( + `images/dark/icon-${provider.icon}.svg`, + ), + light: this.view.container.context.asAbsolutePath( + `images/light/icon-${provider.icon}.svg`, + ), + }; + + if (this.remote.hasIntegration()) { + const integration = await this.view.container.integrations.getByRemote(this.remote); + const connected = integration?.maybeConnected ?? (await integration?.isConnected()); item.contextValue = `${ContextValues.Remote}${connected ? '+connected' : '+disconnected'}`; - item.tooltip = `${this.remote.name} (${provider.name} ${GlyphChars.Dash} ${ + tooltip = `\`${this.remote.name}\` \u00a0(${provider.name} ${GlyphChars.Dash} _${ connected ? 'connected' : 'not connected' - })\n${provider.displayPath}\n`; + }${this.remote.default ? ', default' : ''}_) \n\n${provider.displayPath}`; } else { item.contextValue = ContextValues.Remote; - item.tooltip = `${this.remote.name} (${provider.name})\n${provider.displayPath}\n`; + tooltip = `\`${this.remote.name}\` \u00a0(${provider.name}${ + this.remote.default ? ', default' : '' + }) \n\n${provider.displayPath}`; } } else { item.contextValue = ContextValues.Remote; item.iconPath = new ThemeIcon('cloud'); - item.tooltip = `${this.remote.name} (${this.remote.domain})\n${this.remote.path}\n`; + tooltip = `\`${this.remote.name}\` \u00a0(${this.remote.domain}${ + this.remote.default ? ', default' : '' + }) \n\n${this.remote.path}`; } if (this.remote.default) { item.contextValue += '+default'; - item.resourceUri = Uri.parse('gitlens-view://remote/default'); } + item.resourceUri = createViewDecorationUri('remote', { default: this.remote.default }); for (const { type, url } of this.remote.urls) { - item.tooltip += `\n${url} (${type})`; + tooltip += `\\\n${url} (${type})`; } + item.tooltip = new MarkdownString(tooltip, true); + return item; } diff --git a/src/views/nodes/remotesNode.ts b/src/views/nodes/remotesNode.ts index ad39c3ad1aa30..f534e8ec0d103 100644 --- a/src/views/nodes/remotesNode.ts +++ b/src/views/nodes/remotesNode.ts @@ -1,29 +1,29 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import type { GitUri } from '../../git/gitUri'; import type { Repository } from '../../git/models/repository'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; -import type { RemotesView } from '../remotesView'; -import type { RepositoriesView } from '../repositoriesView'; +import type { ViewsWithRemotesNode } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { MessageNode } from './common'; import { RemoteNode } from './remoteNode'; -import { RepositoryNode } from './repositoryNode'; -import { ContextValues, ViewNode } from './viewNode'; -export class RemotesNode extends ViewNode { - static key = ':remotes'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; - } - - private _children: ViewNode[] | undefined; - - constructor(uri: GitUri, view: RemotesView | RepositoriesView, parent: ViewNode, public readonly repo: Repository) { - super(uri, view, parent); +export class RemotesNode extends CacheableChildrenViewNode<'remotes', ViewsWithRemotesNode> { + constructor( + uri: GitUri, + view: ViewsWithRemotesNode, + protected override readonly parent: ViewNode, + public readonly repo: Repository, + ) { + super('remotes', uri, view, parent); + + this.updateContext({ repository: repo }); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { - return RemotesNode.getId(this.repo.path); + return this._uniqueId; } get repoPath(): string { @@ -31,16 +31,16 @@ export class RemotesNode extends ViewNode { } async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const remotes = await this.repo.getRemotes({ sort: true }); if (remotes.length === 0) { return [new MessageNode(this.view, this, 'No remotes could be found')]; } - this._children = remotes.map(r => new RemoteNode(this.uri, this.view, this, r, this.repo)); + this.children = remotes.map(r => new RemoteNode(this.uri, this.view, this, this.repo, r)); } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -52,9 +52,8 @@ export class RemotesNode extends ViewNode { return item; } - @gate() @debug() override refresh() { - this._children = undefined; + super.refresh(true); } } diff --git a/src/views/nodes/repositoriesNode.ts b/src/views/nodes/repositoriesNode.ts index 580abf41f808a..bc6f0c1ac01d1 100644 --- a/src/views/nodes/repositoriesNode.ts +++ b/src/views/nodes/repositoriesNode.ts @@ -1,67 +1,79 @@ import type { TextEditor } from 'vscode'; -import { Disposable, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; +import { Disposable, TreeItem, TreeItemCollapsibleState, window, workspace } from 'vscode'; import type { RepositoriesChangeEvent } from '../../git/gitProviderService'; import { GitUri, unknownGitUri } from '../../git/gitUri'; -import { Logger } from '../../logger'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; import { debounce, szudzikPairing } from '../../system/function'; -import type { RepositoriesView } from '../repositoriesView'; +import { Logger } from '../../system/logger'; +import type { ViewsWithRepositoriesNode } from '../viewBase'; +import { createViewDecorationUri } from '../viewDecorationProvider'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import { MessageNode } from './common'; import { RepositoryNode } from './repositoryNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, SubscribeableViewNode } from './viewNode'; -export class RepositoriesNode extends SubscribeableViewNode { - private _children: (RepositoryNode | MessageNode)[] | undefined; - - constructor(view: RepositoriesView) { - super(unknownGitUri, view); - } - - override dispose() { - super.dispose(); - - this.resetChildren(); - } - - @debug() - private resetChildren() { - if (this._children == null) return; - - for (const child of this._children) { - if (child instanceof RepositoryNode) { - child.dispose(); - } - } - this._children = undefined; +export class RepositoriesNode extends SubscribeableViewNode< + 'repositories', + ViewsWithRepositoriesNode, + RepositoryNode | MessageNode +> { + constructor(view: ViewsWithRepositoriesNode) { + super('repositories', unknownGitUri, view); } getChildren(): ViewNode[] { - if (this._children == null) { + if (this.children == null) { const repositories = this.view.container.git.openRepositories; if (repositories.length === 0) return [new MessageNode(this.view, this, 'No repositories could be found.')]; - this._children = repositories.map(r => new RepositoryNode(GitUri.fromRepoPath(r.path), this.view, this, r)); + this.children = repositories.map(r => new RepositoryNode(GitUri.fromRepoPath(r.path), this.view, this, r)); } - return this._children; + return this.children; } getTreeItem(): TreeItem { - const item = new TreeItem('Repositories', TreeItemCollapsibleState.Expanded); - item.contextValue = ContextValues.Repositories; + const isInWorkspacesView = this.view.type === 'workspaces'; + const isLinkedWorkspace = isInWorkspacesView && this.view.container.workspaces.currentWorkspaceId != null; + const isCurrentLinkedWorkspace = isLinkedWorkspace && this.view.container.workspaces.currentWorkspace != null; + const item = new TreeItem( + isInWorkspacesView ? 'Current Window' : 'Repositories', + isInWorkspacesView ? TreeItemCollapsibleState.Collapsed : TreeItemCollapsibleState.Expanded, + ); + + if (isInWorkspacesView) { + item.description = workspace.name ?? workspace.workspaceFolders?.[0]?.name ?? ''; + } + let contextValue: string = ContextValues.Repositories; + if (isInWorkspacesView) { + contextValue += '+workspaces'; + } + + if (isLinkedWorkspace) { + contextValue += '+linked'; + } + + if (isCurrentLinkedWorkspace) { + contextValue += '+current'; + item.resourceUri = createViewDecorationUri('repositories', { currentWorkspace: true }); + } + + item.contextValue = contextValue; return item; } @gate() @debug() override async refresh(reset: boolean = false) { - if (this._children == null) return; + const hasChildren = this.children != null; + super.refresh(reset); + if (!hasChildren) return; if (reset) { - this.resetChildren(); await this.unsubscribe(); void this.ensureSubscription(); @@ -69,17 +81,17 @@ export class RepositoriesNode extends SubscribeableViewNode { } const repositories = this.view.container.git.openRepositories; - if (repositories.length === 0 && (this._children == null || this._children.length === 0)) return; + if (repositories.length === 0 && (this.children == null || this.children.length === 0)) return; if (repositories.length === 0) { - this._children = [new MessageNode(this.view, this, 'No repositories could be found.')]; + this.children = [new MessageNode(this.view, this, 'No repositories could be found.')]; return; } const children = []; for (const repo of repositories) { const id = repo.id; - const child = (this._children as RepositoryNode[]).find(c => c.repo.id === id); + const child = (this.children as RepositoryNode[]).find(c => c.repo.id === id); if (child != null) { children.push(child); void child.refresh(); @@ -88,23 +100,21 @@ export class RepositoriesNode extends SubscribeableViewNode { } } - for (const child of this._children as RepositoryNode[]) { - if (children.includes(child)) continue; - - child.dispose(); - } - - this._children = children; + this.children = children; void this.ensureSubscription(); } @debug() protected subscribe() { - const subscriptions = [this.view.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this)]; - - if (this.view.config.autoReveal) { - subscriptions.push(window.onDidChangeActiveTextEditor(debounce(this.onActiveEditorChanged, 500), this)); + const subscriptions = [ + weakEvent(this.view.container.git.onDidChangeRepositories, this.onRepositoriesChanged, this), + ]; + + if (this.view.id === 'gitlens.views.repositories' && this.view.config.autoReveal) { + subscriptions.push( + weakEvent(window.onDidChangeActiveTextEditor, debounce(this.onActiveEditorChanged, 500), this), + ); } return Disposable.from(...subscriptions); @@ -116,18 +126,18 @@ export class RepositoriesNode extends SubscribeableViewNode { @debug({ args: false }) private onActiveEditorChanged(editor: TextEditor | undefined) { - if (editor == null || this._children == null || this._children.length === 1) { + if (editor == null || this.children == null || this.children.length === 1) { return; } try { const uri = editor.document.uri; - const node = this._children.find(n => n instanceof RepositoryNode && n.repo.containsUri(uri)) as + const node = this.children.find(n => n instanceof RepositoryNode && n.repo.containsUri(uri)) as | RepositoryNode | undefined; if (node == null) return; - // Check to see if this repo has a descendent that is already selected + // Check to see if this repo has a descendant that is already selected let parent = this.view.selection.length === 0 ? undefined : this.view.selection[0]; while (parent != null) { if (parent === node) return; @@ -143,6 +153,6 @@ export class RepositoriesNode extends SubscribeableViewNode { @debug() private onRepositoriesChanged(_e: RepositoriesChangeEvent) { - void this.triggerChange(); + void this.triggerChange(true); } } diff --git a/src/views/nodes/repositoryNode.ts b/src/views/nodes/repositoryNode.ts index 8f5b02cd1bcea..5d187a0eef384 100644 --- a/src/views/nodes/repositoryNode.ts +++ b/src/views/nodes/repositoryNode.ts @@ -3,16 +3,28 @@ import { GlyphChars } from '../../constants'; import { Features } from '../../features'; import type { GitUri } from '../../git/gitUri'; import { GitBranch } from '../../git/models/branch'; -import { GitRemote } from '../../git/models/remote'; +import { getHighlanderProviders } from '../../git/models/remote'; import type { RepositoryChangeEvent, RepositoryFileSystemChangeEvent } from '../../git/models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; import type { GitStatus } from '../../git/models/status'; +import { getRepositoryStatusIconPath } from '../../git/utils/repository-utils'; +import type { + CloudWorkspace, + CloudWorkspaceRepositoryDescriptor, + LocalWorkspace, + LocalWorkspaceRepositoryDescriptor, +} from '../../plus/workspaces/models'; import { findLastIndex } from '../../system/array'; import { gate } from '../../system/decorators/gate'; import { debug, log } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; import { disposableInterval } from '../../system/function'; import { pad } from '../../system/string'; -import type { RepositoriesView } from '../repositoriesView'; +import type { ViewsWithRepositories } from '../viewBase'; +import { createViewDecorationUri } from '../viewDecorationProvider'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { AmbientContext, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { BranchesNode } from './branchesNode'; import { BranchNode } from './branchNode'; import { BranchTrackingStatusNode } from './branchTrackingStatusNode'; @@ -26,66 +38,67 @@ import { RemotesNode } from './remotesNode'; import { StashesNode } from './stashesNode'; import { StatusFilesNode } from './statusFilesNode'; import { TagsNode } from './tagsNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, SubscribeableViewNode } from './viewNode'; import { WorktreesNode } from './worktreesNode'; -export class RepositoryNode extends SubscribeableViewNode { - static key = ':repository'; - static getId(repoPath: string): string { - return `gitlens${this.key}(${repoPath})`; - } - - private _children: ViewNode[] | undefined; +export class RepositoryNode extends SubscribeableViewNode<'repository', ViewsWithRepositories> { private _status: Promise; - constructor(uri: GitUri, view: RepositoriesView, parent: ViewNode, public readonly repo: Repository) { - super(uri, view, parent); + constructor( + uri: GitUri, + view: ViewsWithRepositories, + parent: ViewNode, + public readonly repo: Repository, + context?: AmbientContext, + ) { + super('repository', uri, view, parent); + + this.updateContext({ ...context, repository: this.repo }); + this._uniqueId = getViewNodeId(this.type, this.context); this._status = this.repo.getStatus(); } + override get id(): string { + return this._uniqueId; + } + override toClipboard(): string { return this.repo.path; } - override get id(): string { - return RepositoryNode.getId(this.repo.path); + get repoPath(): string { + return this.repo.path; + } + + get workspace(): CloudWorkspace | LocalWorkspace | undefined { + return this.context.workspace; + } + + get wsRepositoryDescriptor(): CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor | undefined { + return this.context.wsRepositoryDescriptor; } async getChildren(): Promise { - if (this._children === undefined) { + if (this.children === undefined) { const children = []; const status = await this._status; if (status != null) { const branch = new GitBranch( + this.view.container, status.repoPath, status.branch, false, true, undefined, status.sha, - status.upstream ? { name: status.upstream, missing: false } : undefined, + status.upstream, status.state.ahead, status.state.behind, status.detached, status.rebasing, ); - if (this.view.config.showBranchComparison !== false) { - children.push( - new CompareBranchNode( - this.uri, - this.view, - this, - branch, - this.view.config.showBranchComparison, - true, - ), - ); - } - const [mergeStatus, rebaseStatus] = await Promise.all([ this.view.container.git.getMergeStatus(status.repoPath), this.view.container.git.getRebaseStatus(status.repoPath), @@ -114,26 +127,39 @@ export class RepositoryNode extends SubscribeableViewNode { ); } } - } else { + } else if (!status.detached) { children.push(new BranchTrackingStatusNode(this.view, this, branch, status, 'none', true)); } } if (this.view.config.includeWorkingTree && status.files.length !== 0) { - const range = undefined; //status.upstream ? GitRevision.createRange(status.upstream, branch.ref) : undefined; + const range = undefined; //status.upstream ? createRange(status.upstream, branch.ref) : undefined; children.push(new StatusFilesNode(this.view, this, status, range)); } + if (this.view.config.showBranchComparison !== false) { + children.push( + new CompareBranchNode( + this.uri, + this.view, + this, + branch, + this.view.config.showBranchComparison, + true, + ), + ); + } + if (children.length !== 0 && !this.view.config.compact) { children.push(new MessageNode(this.view, this, '', GlyphChars.Dash.repeat(2), '')); } if (this.view.config.showCommits) { children.push( - new BranchNode(this.uri, this.view, this, branch, true, { + new BranchNode(this.uri, this.view, this, this.repo, branch, true, { showAsCommits: true, showComparison: false, - showCurrent: false, + showStatusDecorationOnly: true, showStatus: false, showTracking: false, }), @@ -165,13 +191,13 @@ export class RepositoryNode extends SubscribeableViewNode { children.push(new ContributorsNode(this.uri, this.view, this, this.repo)); } - if (this.view.config.showIncomingActivity) { + if (this.view.config.showIncomingActivity && !this.repo.provider.virtual) { children.push(new ReflogNode(this.uri, this.view, this, this.repo)); } - this._children = children; + this.children = children; } - return this._children; + return this.children; } async getTreeItem(): Promise { @@ -184,14 +210,30 @@ export class RepositoryNode extends SubscribeableViewNode { lastFetched ? `${pad(GlyphChars.Dash, 2, 2)}Last fetched ${Repository.formatLastFetched(lastFetched, false)}` : '' - }${this.repo.formattedName ? `\n${this.uri.repoPath}` : ''}`; - let iconSuffix = ''; + }${this.repo.formattedName ? `\\\n${this.uri.repoPath}` : ''}`; let workingStatus = ''; + const { workspace } = this.context; + let contextValue: string = ContextValues.Repository; if (this.repo.starred) { contextValue += '+starred'; } + if (workspace != null) { + contextValue += '+workspace'; + if (workspace.type === 'cloud') { + contextValue += '+cloud'; + } else if (workspace.type === 'local') { + contextValue += '+local'; + } + } + + if (this.repo.virtual) { + contextValue += '+virtual'; + } else if (this.repo.closed) { + // TODO@axosoft-ramint Temporary workaround, remove when our git commands work on closed repos. + contextValue += '+closed'; + } const status = await this._status; if (status != null) { @@ -212,7 +254,7 @@ export class RepositoryNode extends SubscribeableViewNode { let providerName; if (status.upstream != null) { - const providers = GitRemote.getHighlanderProviders( + const providers = getHighlanderProviders( await this.view.container.git.getRemotesWithProviders(status.repoPath), ); providerName = providers?.length ? providers[0].name : undefined; @@ -221,24 +263,21 @@ export class RepositoryNode extends SubscribeableViewNode { providerName = remote?.provider?.name; } - iconSuffix = workingStatus ? '-blue' : ''; if (status.upstream != null) { tooltip += ` is ${status.getUpstreamStatus({ - empty: `up to date with $(git-branch) ${status.upstream}${ + empty: `up to date with $(git-branch) ${status.upstream.name}${ providerName ? ` on ${providerName}` : '' }`, expand: true, icons: true, separator: ', ', - suffix: ` $(git-branch) ${status.upstream}${providerName ? ` on ${providerName}` : ''}`, + suffix: ` $(git-branch) ${status.upstream.name}${providerName ? ` on ${providerName}` : ''}`, })}`; if (status.state.behind) { contextValue += '+behind'; - iconSuffix = '-red'; } if (status.state.ahead) { - iconSuffix = status.state.behind ? '-yellow' : '-green'; contextValue += '+ahead'; } } @@ -252,16 +291,26 @@ export class RepositoryNode extends SubscribeableViewNode { } } - const item = new TreeItem(label, TreeItemCollapsibleState.Expanded); + if (workspace != null) { + tooltip += `\n\nRepository is ${this.repo.closed ? 'not ' : ''}open in the current window`; + } + + const item = new TreeItem( + label, + workspace != null || this.view.type === 'workspaces' + ? TreeItemCollapsibleState.Collapsed + : TreeItemCollapsibleState.Expanded, + ); item.id = this.id; item.contextValue = contextValue; item.description = `${description ?? ''}${ lastFetched ? `${pad(GlyphChars.Dot, 1, 1)}Last fetched ${Repository.formatLastFetched(lastFetched)}` : '' }`; - item.iconPath = { - dark: this.view.container.context.asAbsolutePath(`images/dark/icon-repo${iconSuffix}.svg`), - light: this.view.container.context.asAbsolutePath(`images/light/icon-repo${iconSuffix}.svg`), - }; + item.iconPath = getRepositoryStatusIconPath(this.view.container, this.repo, status); + + if (workspace != null && !this.repo.closed) { + item.resourceUri = createViewDecorationUri('repository', { state: 'open', workspace: true }); + } const markdown = new MarkdownString(tooltip, true); markdown.supportHtml = true; @@ -290,10 +339,10 @@ export class RepositoryNode extends SubscribeableViewNode { @gate() @debug() override async refresh(reset: boolean = false) { + super.refresh(reset); + if (reset) { this._status = this.repo.getStatus(); - - this._children = undefined; } await this.ensureSubscription(); @@ -315,7 +364,7 @@ export class RepositoryNode extends SubscribeableViewNode { protected async subscribe() { const lastFetched = (await this.repo?.getLastFetched()) ?? 0; - const disposables = [this.repo.onDidChange(this.onRepositoryChanged, this)]; + const disposables = [weakEvent(this.repo.onDidChange, this.onRepositoryChanged, this)]; const interval = Repository.getLastFetchedUpdateInterval(lastFetched); if (lastFetched !== 0 && interval > 0) { @@ -337,8 +386,9 @@ export class RepositoryNode extends SubscribeableViewNode { if (this.view.config.includeWorkingTree) { disposables.push( - this.repo.onDidChangeFileSystem(this.onFileSystemChanged, this), - this.repo.startWatchingFileSystem(), + weakEvent(this.repo.onDidChangeFileSystem, this.onFileSystemChanged, this, [ + this.repo.watchFileSystem(), + ]), ); } @@ -361,25 +411,22 @@ export class RepositoryNode extends SubscribeableViewNode { private async onFileSystemChanged(_e: RepositoryFileSystemChangeEvent) { this._status = this.repo.getStatus(); - if (this._children !== undefined) { + if (this.children !== undefined) { const status = await this._status; - let index = this._children.findIndex(c => c instanceof StatusFilesNode); + let index = this.children.findIndex(c => c.type === 'status-files'); if (status !== undefined && (status.state.ahead || status.files.length !== 0)) { let deleteCount = 1; if (index === -1) { - index = findLastIndex( - this._children, - c => c instanceof BranchTrackingStatusNode || c instanceof BranchNode, - ); + index = findLastIndex(this.children, c => c.type === 'tracking-status' || c.type === 'branch'); deleteCount = 0; index++; } - const range = undefined; //status.upstream ? GitRevision.createRange(status.upstream, status.sha) : undefined; - this._children.splice(index, deleteCount, new StatusFilesNode(this.view, this, status, range)); + const range = undefined; //status.upstream ? createRange(status.upstream, status.sha) : undefined; + this.children.splice(index, deleteCount, new StatusFilesNode(this.view, this, status, range)); } else if (index !== -1) { - this._children.splice(index, 1); + this.children.splice(index, 1); } } @@ -395,11 +442,12 @@ export class RepositoryNode extends SubscribeableViewNode { } if ( - this._children == null || + this.children == null || e.changed( RepositoryChange.Config, RepositoryChange.Index, RepositoryChange.Heads, + RepositoryChange.Opened, RepositoryChange.Status, RepositoryChange.Unknown, RepositoryChangeComparisonMode.Any, @@ -411,21 +459,21 @@ export class RepositoryNode extends SubscribeableViewNode { } if (e.changed(RepositoryChange.Remotes, RepositoryChange.RemoteProviders, RepositoryChangeComparisonMode.Any)) { - const node = this._children.find(c => c instanceof RemotesNode); + const node = this.children.find(c => c.type === 'remotes'); if (node != null) { this.view.triggerNodeChange(node); } } if (e.changed(RepositoryChange.Stash, RepositoryChangeComparisonMode.Any)) { - const node = this._children.find(c => c instanceof StashesNode); + const node = this.children.find(c => c.type === 'stashes'); if (node != null) { this.view.triggerNodeChange(node); } } if (e.changed(RepositoryChange.Tags, RepositoryChangeComparisonMode.Any)) { - const node = this._children.find(c => c instanceof TagsNode); + const node = this.children.find(c => c.type === 'tags'); if (node != null) { this.view.triggerNodeChange(node); } diff --git a/src/views/nodes/resultsCommitsNode.ts b/src/views/nodes/resultsCommitsNode.ts index 82acddb6b9aca..1b0c6917b58bd 100644 --- a/src/views/nodes/resultsCommitsNode.ts +++ b/src/views/nodes/resultsCommitsNode.ts @@ -1,32 +1,37 @@ import { TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { configuration } from '../../configuration'; import { GitUri } from '../../git/gitUri'; -import type { GitLog } from '../../git/models/log'; +import { isStash } from '../../git/models/commit'; +import type { CommitsQueryResults, FilesQueryResults } from '../../git/queryResults'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; -import { cancellable, PromiseCancelledError } from '../../system/promise'; +import type { Deferred } from '../../system/promise'; +import { defer, pauseOnCancelOrTimeout } from '../../system/promise'; +import { configuration } from '../../system/vscode/configuration'; import type { ViewsWithCommits } from '../viewBase'; +import type { PageableViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import { AutolinkedItemsNode } from './autolinkedItemsNode'; import { CommitNode } from './commitNode'; -import { LoadMoreNode } from './common'; +import { LoadMoreNode, MessageNode } from './common'; import { insertDateMarkers } from './helpers'; -import type { FilesQueryResults } from './resultsFilesNode'; import { ResultsFilesNode } from './resultsFilesNode'; -import type { PageableViewNode } from './viewNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export interface CommitsQueryResults { - readonly label: string; - readonly log: GitLog | undefined; - readonly hasMore: boolean; - more?(limit: number | undefined): Promise; +import { StashNode } from './stashNode'; + +interface Options { + autolinks: boolean; + expand: boolean; + description?: string; } export class ResultsCommitsNode - extends ViewNode + extends ViewNode<'results-commits', View> implements PageableViewNode { + limit: number | undefined; + + private readonly _options: Options; + constructor( view: View, protected override readonly parent: ViewNode, @@ -43,19 +48,25 @@ export class ResultsCommitsNode Promise; }; }, - private readonly _options: { - id?: string; - description?: string; - expand?: boolean; - } = {}, + options?: Partial, splatted?: boolean, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('results-commits', GitUri.fromRepoPath(repoPath), view, parent); + + if (_results.direction != null) { + this.updateContext({ branchStatusUpstreamType: _results.direction }); + } + this._uniqueId = getViewNodeId(this.type, this.context); + this.limit = this.view.getNodeLastKnownLimit(this); + this._options = { autolinks: true, expand: true, ...options }; if (splatted != null) { this.splatted = splatted; } - this._options = { expand: true, ..._options }; + } + + override get id(): string { + return this._uniqueId; } get ref1(): string | undefined { @@ -66,25 +77,35 @@ export class ResultsCommitsNode | undefined; + async getChildren(): Promise { + this._onChildrenCompleted?.cancel(); + this._onChildrenCompleted = defer(); + const { log } = await this.getCommitsQueryResults(); - if (log == null) return []; + if (log == null) { + this._onChildrenCompleted?.fulfill(); + return [new MessageNode(this.view, this, 'No results found')]; + } const [getBranchAndTagTips] = await Promise.all([ this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath), ]); - const children: ViewNode[] = [ - new AutolinkedItemsNode(this.view, this, this.uri.repoPath!, log, this._expandAutolinks), - ]; + const children: ViewNode[] = []; + if (this._options.autolinks) { + children.push(new AutolinkedItemsNode(this.view, this, this.uri.repoPath!, log, this._expandAutolinks)); + } this._expandAutolinks = false; const { files } = this._results; - if (files != null) { + // Can't support showing files when commits are filtered + if (files != null && !this.isComparisonFiltered) { children.push( new ResultsFilesNode( this.view, @@ -94,9 +115,7 @@ export class ResultsCommitsNode new CommitNode(this.view, this, c, undefined, undefined, getBranchAndTagTips, options), + map(log.commits.values(), c => + isStash(c) + ? new StashNode(this.view, this, c, { icon: true }) + : new CommitNode(this.view, this, c, undefined, undefined, getBranchAndTagTips, options), ), this, undefined, @@ -119,6 +139,7 @@ export class ResultsCommitsNode this.triggerChange(false)); - } + : this._options.expand //|| log.count === 1 + ? TreeItemCollapsibleState.Expanded + : TreeItemCollapsibleState.Collapsed; + } else { + queueMicrotask(async () => { + try { + await this._onChildrenCompleted?.promise; + } catch { + return; + } + + void (await result.value); + this.view.triggerNodeChange(this.parent); + }); // Need to use Collapsed before we have results or the item won't show up in the view until the children are awaited // https://github.com/microsoft/vscode/issues/54806 & https://github.com/microsoft/vscode/issues/62214 @@ -164,24 +194,34 @@ export class ResultsCommitsNode | undefined; + private _commitsQueryResultsPromise: Promise | undefined; private async getCommitsQueryResults() { - if (this._commitsQueryResults == null) { - this._commitsQueryResults = this._results.query(this.limit ?? configuration.get('advanced.maxSearchItems')); - const results = await this._commitsQueryResults; + if (this._commitsQueryResultsPromise == null) { + this._commitsQueryResultsPromise = this._results.query( + this.limit ?? configuration.get('advanced.maxSearchItems'), + ); + const results = await this._commitsQueryResultsPromise; + this._commitsQueryResults = results; + this._hasMore = results.hasMore; if (this._results.deferred) { this._results.deferred = false; - void this.triggerChange(false); + void this.parent.triggerChange(false); } } + return this._commitsQueryResultsPromise; + } + + private _commitsQueryResults: CommitsQueryResults | undefined; + private maybeGetCommitsQueryResults(): CommitsQueryResults | undefined { return this._commitsQueryResults; } @@ -191,10 +231,9 @@ export class ResultsCommitsNode): Promise { const results = await this.getCommitsQueryResults(); - if (results == null || !results.hasMore) return; + if (!results?.hasMore) return; if (context != null && 'expandAutolinks' in context) { this._expandAutolinks = Boolean(context.expandAutolinks); diff --git a/src/views/nodes/resultsFileNode.ts b/src/views/nodes/resultsFileNode.ts index 93ad617dc01d1..8aaa9c2ef58c0 100644 --- a/src/views/nodes/resultsFileNode.ts +++ b/src/views/nodes/resultsFileNode.ts @@ -1,19 +1,27 @@ import type { Command } from 'vscode'; -import { TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { DiffWithCommandArgs } from '../../commands'; -import { Commands } from '../../constants'; +import { TreeItem, TreeItemCheckboxState, TreeItemCollapsibleState } from 'vscode'; +import type { DiffWithCommandArgs } from '../../commands/diffWith'; +import { Commands } from '../../constants.commands'; import { StatusFileFormatter } from '../../git/formatters/statusFormatter'; import { GitUri } from '../../git/gitUri'; -import { GitFile } from '../../git/models/file'; +import type { GitFile } from '../../git/models/file'; +import { getGitFileStatusIcon } from '../../git/models/file'; import type { GitRevisionReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; -import { joinPaths, relativeDir } from '../../system/path'; +import { createReference } from '../../git/models/reference'; +import { joinPaths } from '../../system/path'; +import { relativeDir } from '../../system/vscode/path'; import type { View } from '../viewBase'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefFileNode } from './abstract/viewRefNode'; +import { getComparisonStoragePrefix } from './compareResultsNode'; import type { FileNode } from './folderNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, ViewRefFileNode } from './viewNode'; -export class ResultsFileNode extends ViewRefFileNode implements FileNode { +type State = { + checked: TreeItemCheckboxState; +}; + +export class ResultsFileNode extends ViewRefFileNode<'results-file', View, State> implements FileNode { constructor( view: View, parent: ViewNode, @@ -23,7 +31,16 @@ export class ResultsFileNode extends ViewRefFileNode implements FileNode { public readonly ref2: string, private readonly direction: 'ahead' | 'behind' | undefined, ) { - super(GitUri.fromFile(file, repoPath, ref1 || ref2), view, parent, file); + super('results-file', GitUri.fromFile(file, repoPath, ref1 || ref2), view, parent, file); + + this.updateContext({ file: file }); + if (this.context.storedComparisonId != null) { + this._uniqueId = `${getComparisonStoragePrefix(this.context.storedComparisonId)}${this.direction}|${ + file.path + }`; + } else { + this._uniqueId = getViewNodeId(this.type, this.context); + } } override toClipboard(): string { @@ -31,7 +48,7 @@ export class ResultsFileNode extends ViewRefFileNode implements FileNode { } get ref(): GitRevisionReference { - return GitReference.create(this.ref1 || this.ref2, this.uri.repoPath!); + return createReference(this.ref1 || this.ref2, this.uri.repoPath!); } getChildren(): ViewNode[] { @@ -47,13 +64,19 @@ export class ResultsFileNode extends ViewRefFileNode implements FileNode { this.file, ); - const statusIcon = GitFile.getStatusIcon(this.file.status); + const statusIcon = getGitFileStatusIcon(this.file.status); item.iconPath = { dark: this.view.container.context.asAbsolutePath(joinPaths('images', 'dark', statusIcon)), light: this.view.container.context.asAbsolutePath(joinPaths('images', 'light', statusIcon)), }; item.command = this.getCommand(); + + item.checkboxState = { + state: this.getState('checked') ?? TreeItemCheckboxState.Unchecked, + tooltip: 'Mark as Reviewed', + }; + return item; } diff --git a/src/views/nodes/resultsFilesNode.ts b/src/views/nodes/resultsFilesNode.ts index 1c5e955d6c899..9d63624e0b829 100644 --- a/src/views/nodes/resultsFilesNode.ts +++ b/src/views/nodes/resultsFilesNode.ts @@ -1,8 +1,8 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { ViewFilesLayout } from '../../configuration'; +import type { FilesComparison } from '../../git/actions/commit'; import { GitUri } from '../../git/gitUri'; -import type { GitDiffShortStat } from '../../git/models/diff'; import type { GitFile } from '../../git/models/file'; +import type { FilesQueryResults } from '../../git/queryResults'; import { makeHierarchical } from '../../system/array'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; @@ -11,25 +11,28 @@ import { joinPaths, normalizePath } from '../../system/path'; import { cancellable, PromiseCancelledError } from '../../system/promise'; import { pluralize, sortCompare } from '../../system/string'; import type { ViewsWithCommits } from '../viewBase'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; import { ResultsFileNode } from './resultsFileNode'; -import { ContextValues, ViewNode } from './viewNode'; + +type State = { + filter: FilesQueryFilter | undefined; +}; export enum FilesQueryFilter { Left = 0, Right = 1, } -export interface FilesQueryResults { - label: string; - files: GitFile[] | undefined; - stats?: (GitDiffShortStat & { approximated?: boolean }) | undefined; - - filtered?: Map; +interface Options { + expand: boolean; + timeout: false | number; } -export class ResultsFilesNode extends ViewNode { +export class ResultsFilesNode extends ViewNode<'results-files', ViewsWithCommits, State> { + private readonly _options: Options; + constructor( view: ViewsWithCommits, protected override parent: ViewNode, @@ -38,26 +41,28 @@ export class ResultsFilesNode extends ViewNode { public readonly ref2: string, private readonly _filesQuery: () => Promise, private readonly direction: 'ahead' | 'behind' | undefined, - private readonly _options: { - expand?: boolean; - } = {}, + options?: Partial, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('results-files', GitUri.fromRepoPath(repoPath), view, parent); - this._options = { expand: true, ..._options }; + if (this.direction != null) { + this.updateContext({ branchStatusUpstreamType: this.direction }); + } + this._uniqueId = getViewNodeId(this.type, this.context); + this._options = { expand: true, timeout: 100, ...options }; } override get id(): string { - return `${this.parent.id}:results:files`; + return this._uniqueId; } get filter(): FilesQueryFilter | undefined { - return this.view.nodeState.getState(this.id, 'filter'); + return this.getState('filter'); } set filter(value: FilesQueryFilter | undefined) { if (this.filter === value) return; - this.view.nodeState.storeState(this.id, 'filter', value); + this.storeState('filter', value, true); this._filterResults = undefined; void this.triggerChange(false); @@ -67,6 +72,16 @@ export class ResultsFilesNode extends ViewNode { return this.filter != null || (this.ref1 !== this.ref2 && this.direction === undefined); } + async getFilesComparison(): Promise { + const { files } = await this.getFilesQueryResults(); + return { + files: files ?? [], + repoPath: this.repoPath, + ref1: this.ref1, + ref2: this.ref2, + }; + } + private getFilterContextValue(): string { switch (this.filter) { case FilesQueryFilter.Left: @@ -90,7 +105,7 @@ export class ResultsFilesNode extends ViewNode { ), ]; - if (this.view.config.files.layout !== ViewFilesLayout.List) { + if (this.view.config.files.layout !== 'list') { const hierarchy = makeHierarchical( children, n => n.uri.relativePath.split('/'), @@ -98,7 +113,7 @@ export class ResultsFilesNode extends ViewNode { this.view.config.files.compact, ); - const root = new FolderNode(this.view, this, this.repoPath, '', hierarchy); + const root = new FolderNode(this.view, this, hierarchy, this.repoPath, '', undefined); children = root.getChildren() as FileNode[]; } else { children.sort((a, b) => a.priority - b.priority || sortCompare(a.label!, b.label!)); @@ -117,7 +132,10 @@ export class ResultsFilesNode extends ViewNode { const filter = this.filter; try { - const results = await cancellable(this.getFilesQueryResults(), 100); + const results = await cancellable( + this.getFilesQueryResults(), + this._options.timeout === false ? undefined : this._options.timeout, + ); label = results.label; if (filter == null && results.stats != null) { description = `${pluralize('addition', results.stats.additions)} (+), ${pluralize( @@ -149,8 +167,8 @@ export class ResultsFilesNode extends ViewNode { files == null || files.length === 0 ? TreeItemCollapsibleState.None : this._options.expand - ? TreeItemCollapsibleState.Expanded - : TreeItemCollapsibleState.Collapsed; + ? TreeItemCollapsibleState.Expanded + : TreeItemCollapsibleState.Collapsed; } } catch (ex) { if (ex instanceof PromiseCancelledError) { @@ -184,7 +202,7 @@ export class ResultsFilesNode extends ViewNode { override refresh(reset: boolean = false) { if (!reset) return; - this.view.nodeState.deleteState(this.id, 'filter'); + this.deleteState('filter'); this._filterResults = undefined; this._filesQueryResults = this._filesQuery(); @@ -193,7 +211,7 @@ export class ResultsFilesNode extends ViewNode { private _filesQueryResults: Promise | undefined; private _filterResults: Promise | undefined; - async getFilesQueryResults() { + private async getFilesQueryResults() { if (this._filesQueryResults === undefined) { this._filesQueryResults = this._filesQuery(); } @@ -242,6 +260,6 @@ export class ResultsFilesNode extends ViewNode { if (results.filtered == null) { results.filtered = new Map(); } - results.filtered.set(filter, filterTo == null ? [] : results.files!.filter(f => filterTo!.has(f.path))); + results.filtered.set(filter, filterTo == null ? [] : results.files!.filter(f => filterTo.has(f.path))); } } diff --git a/src/views/nodes/searchResultsNode.ts b/src/views/nodes/searchResultsNode.ts index 4ad00f1022167..25d996af592a1 100644 --- a/src/views/nodes/searchResultsNode.ts +++ b/src/views/nodes/searchResultsNode.ts @@ -1,20 +1,19 @@ +import { md5 } from '@env/crypto'; import type { TreeItem } from 'vscode'; import { ThemeIcon } from 'vscode'; -import { md5 } from '@env/crypto'; +import type { SearchQuery } from '../../constants.search'; import { executeGitCommand } from '../../git/actions'; import { GitUri } from '../../git/gitUri'; import type { GitLog } from '../../git/models/log'; -import type { SearchQuery, StoredSearchQuery } from '../../git/search'; +import type { CommitsQueryResults } from '../../git/queryResults'; import { getSearchQueryComparisonKey, getStoredSearchQuery } from '../../git/search'; import { gate } from '../../system/decorators/gate'; -import { debug, log } from '../../system/decorators/log'; +import { debug } from '../../system/decorators/log'; import { pluralize } from '../../system/string'; import type { SearchAndCompareView } from '../searchAndCompareView'; -import { RepositoryNode } from './repositoryNode'; -import type { CommitsQueryResults } from './resultsCommitsNode'; +import type { PageableViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import { ResultsCommitsNode } from './resultsCommitsNode'; -import type { PageableViewNode } from './viewNode'; -import { ContextValues, ViewNode } from './viewNode'; let instanceId = 0; @@ -25,24 +24,14 @@ interface SearchQueryResults { more?(limit: number | undefined): Promise; } -export class SearchResultsNode extends ViewNode implements PageableViewNode { - static key = ':search-results'; - static getId(repoPath: string, search: SearchQuery | undefined, instanceId: number): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${ - search == null ? '?' : getSearchQueryComparisonKey(search) - }):${instanceId}`; - } - - static getPinnableId(repoPath: string, search: SearchQuery | StoredSearchQuery) { - return md5(`${repoPath}|${getSearchQueryComparisonKey(search)}`, 'base64'); - } - +export class SearchResultsNode extends ViewNode<'search-results', SearchAndCompareView> implements PageableViewNode { private _instanceId: number; + constructor( view: SearchAndCompareView, - parent: ViewNode, + protected override readonly parent: ViewNode, public readonly repoPath: string, - search: SearchQuery, + private _search: SearchQuery, private _labels: { label: string; queryLabel: @@ -58,37 +47,41 @@ export class SearchResultsNode extends ViewNode implements | Promise | GitLog | undefined, - private _pinned: number = 0, + private _storedAt: number = 0, ) { - super(GitUri.fromRepoPath(repoPath), view, parent); + super('search-results', GitUri.fromRepoPath(repoPath), view, parent); - this._search = search; this._instanceId = instanceId++; - this._order = Date.now(); + this.updateContext({ searchId: `${getSearchQueryComparisonKey(this._search)}+${this._instanceId}` }); + this._uniqueId = getViewNodeId(this.type, this.context); + + // If this is a new search, save it + if (this._storedAt === 0) { + this._storedAt = Date.now(); + void this.store(true); + } } override get id(): string { - return SearchResultsNode.getId(this.repoPath, this.search, this._instanceId); + return this._uniqueId; } - get canDismiss(): boolean { - return !this.pinned; + override toClipboard(): string { + return this.search.query; } - private readonly _order: number = Date.now(); get order(): number { - return this._pinned || this._order; - } - - get pinned(): boolean { - return this._pinned !== 0; + return this._storedAt; } - private _search: SearchQuery; get search(): SearchQuery { return this._search; } + dismiss() { + void this.remove(true); + } + private _resultsNode: ResultsCommitsNode | undefined; private ensureResults() { if (this._resultsNode == null) { @@ -117,7 +110,7 @@ export class SearchResultsNode extends ViewNode implements deferred: deferred, }, { - expand: !this.pinned, + expand: false, }, true, ); @@ -133,14 +126,12 @@ export class SearchResultsNode extends ViewNode implements async getTreeItem(): Promise { const item = await this.ensureResults().getTreeItem(); item.id = this.id; - item.contextValue = `${ContextValues.SearchResults}${this._pinned ? '+pinned' : ''}`; + item.contextValue = ContextValues.SearchResults; if (this.view.container.git.repositoryCount > 1) { const repo = this.view.container.git.getRepository(this.repoPath); item.description = repo?.formattedName ?? this.repoPath; } - if (this._pinned) { - item.iconPath = new ThemeIcon('pinned'); - } + item.iconPath = new ThemeIcon('search'); return item; } @@ -182,18 +173,15 @@ export class SearchResultsNode extends ViewNode implements } // Save the current id so we can update it later - const currentId = this.getPinnableId(); + const currentId = this.getStorageId(); this._search = search.pattern; this._labels = search.labels; this._searchQueryOrLog = search.log; this._resultsNode = undefined; - // If we were pinned, remove the existing pin and save a new one - if (this.pinned) { - await this.view.updatePinned(currentId); - await this.updatePinned(); - } + // Remove the existing stored item and save a new one + await this.replace(currentId, true); void this.triggerChange(false); queueMicrotask(() => this.view.reveal(this, { expand: true, focus: true, select: true })); @@ -205,30 +193,6 @@ export class SearchResultsNode extends ViewNode implements this._resultsNode?.refresh(reset); } - @log() - async pin() { - if (this.pinned) return; - - this._pinned = Date.now(); - await this.updatePinned(); - - queueMicrotask(() => this.view.reveal(this, { focus: true, select: true })); - } - - @log() - async unpin() { - if (!this.pinned) return; - - this._pinned = 0; - await this.view.updatePinned(this.getPinnableId()); - - queueMicrotask(() => this.view.reveal(this, { focus: true, select: true })); - } - - private getPinnableId() { - return SearchResultsNode.getPinnableId(this.repoPath, this.search); - } - private getSearchLabel( label: | string @@ -243,7 +207,9 @@ export class SearchResultsNode extends ViewNode implements const count = log?.count ?? 0; const resultsType = - label.resultsType === undefined ? { singular: 'result', plural: 'results' } : label.resultsType; + label.resultsType === undefined + ? { singular: 'search result', plural: 'search results' } + : label.resultsType; return `${pluralize(resultsType.singular, count, { format: c => (log?.hasMore ? `${c}+` : undefined), @@ -268,7 +234,7 @@ export class SearchResultsNode extends ViewNode implements return async (limit: number | undefined) => { log = await (log ?? this.view.container.git.richSearchCommits(this.repoPath, this.search)); - if (!useCacheOnce && log != null && log.query != null) { + if (!useCacheOnce && log?.query != null) { log = await log.query(limit); } useCacheOnce = false; @@ -291,13 +257,30 @@ export class SearchResultsNode extends ViewNode implements }; } - private updatePinned() { - return this.view.updatePinned(this.getPinnableId(), { - type: 'search', - timestamp: this._pinned, - path: this.repoPath, - labels: this._labels, - search: getStoredSearchQuery(this.search), - }); + private getStorageId() { + return md5(`${this.repoPath}|${getSearchQueryComparisonKey(this.search)}`, 'base64'); + } + + private remove(silent: boolean = false) { + return this.view.updateStorage(this.getStorageId(), undefined, silent); + } + + private async replace(id: string, silent: boolean = false) { + await this.view.updateStorage(id, undefined, silent); + return this.store(silent); + } + + private store(silent: boolean = false) { + return this.view.updateStorage( + this.getStorageId(), + { + type: 'search', + timestamp: this._storedAt, + path: this.repoPath, + labels: this._labels, + search: getStoredSearchQuery(this.search), + }, + silent, + ); } } diff --git a/src/views/nodes/stashFileNode.ts b/src/views/nodes/stashFileNode.ts index d3bafaac9d19b..69142d9da90a1 100644 --- a/src/views/nodes/stashFileNode.ts +++ b/src/views/nodes/stashFileNode.ts @@ -1,14 +1,13 @@ import type { GitStashCommit } from '../../git/models/commit'; import type { GitFile } from '../../git/models/file'; -import type { RepositoriesView } from '../repositoriesView'; -import type { StashesView } from '../stashesView'; -import { CommitFileNode } from './commitFileNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues } from './viewNode'; +import type { ViewsWithStashes } from '../viewBase'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; +import { CommitFileNodeBase } from './commitFileNode'; -export class StashFileNode extends CommitFileNode { - constructor(view: StashesView | RepositoriesView, parent: ViewNode, file: GitFile, commit: GitStashCommit) { - super(view, parent, file, commit); +export class StashFileNode extends CommitFileNodeBase<'stash-file', ViewsWithStashes> { + constructor(view: ViewsWithStashes, parent: ViewNode, file: GitFile, commit: GitStashCommit) { + super('stash-file', view, parent, file, commit); } protected override get contextValue(): string { diff --git a/src/views/nodes/stashNode.ts b/src/views/nodes/stashNode.ts index c4abaa6e7641b..5683da75eb961 100644 --- a/src/views/nodes/stashNode.ts +++ b/src/views/nodes/stashNode.ts @@ -1,39 +1,42 @@ -import { TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { ViewFilesLayout } from '../../config'; -import { configuration } from '../../configuration'; +import type { CancellationToken } from 'vscode'; +import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import { CommitFormatter } from '../../git/formatters/commitFormatter'; import type { GitStashCommit } from '../../git/models/commit'; import type { GitStashReference } from '../../git/models/reference'; import { makeHierarchical } from '../../system/array'; import { joinPaths, normalizePath } from '../../system/path'; +import { getSettledValue, pauseOnCancelOrTimeoutMapTuplePromise } from '../../system/promise'; import { sortCompare } from '../../system/string'; -import type { RepositoriesView } from '../repositoriesView'; -import type { StashesView } from '../stashesView'; +import { configuration } from '../../system/vscode/configuration'; +import type { ViewsWithStashes } from '../viewBase'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefNode } from './abstract/viewRefNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; -import { RepositoryNode } from './repositoryNode'; import { StashFileNode } from './stashFileNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, ViewRefNode } from './viewNode'; -export class StashNode extends ViewRefNode { - static key = ':stash'; - static getId(repoPath: string, ref: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${ref})`; +export class StashNode extends ViewRefNode<'stash', ViewsWithStashes, GitStashReference> { + constructor( + view: ViewsWithStashes, + protected override parent: ViewNode, + public readonly commit: GitStashCommit, + private readonly options?: { icon?: boolean }, + ) { + super('stash', commit.getGitUri(), view, parent); + + this.updateContext({ commit: commit }); + this._uniqueId = getViewNodeId(this.type, this.context); } - constructor(view: StashesView | RepositoriesView, parent: ViewNode, public readonly commit: GitStashCommit) { - super(commit.getGitUri(), view, parent); + override get id(): string { + return this._uniqueId; } override toClipboard(): string { return this.commit.stashName; } - override get id(): string { - return StashNode.getId(this.commit.repoPath, this.commit.sha); - } - get ref(): GitStashReference { return this.commit; } @@ -43,7 +46,7 @@ export class StashNode extends ViewRefNode new StashFileNode(this.view, this, c.file!, c as GitStashCommit)); - if (this.view.config.files.layout !== ViewFilesLayout.List) { + if (this.view.config.files.layout !== 'list') { const hierarchy = makeHierarchical( children, n => n.uri.relativePath.split('/'), @@ -51,7 +54,7 @@ export class StashNode extends ViewRefNode sortCompare(a.label!, b.label!)); @@ -73,15 +76,63 @@ export class StashNode extends ViewRefNode { + if (item.tooltip == null) { + item.tooltip = await this.getTooltip(token); + } + return item; + } + + private async getTooltip(cancellation: CancellationToken) { + const [remotesResult, _] = await Promise.allSettled([ + this.view.container.git.getBestRemotesWithProviders(this.commit.repoPath, cancellation), + this.commit.message == null ? this.commit.ensureFullDetails() : undefined, + ]); + + if (cancellation.isCancellationRequested) return undefined; + + const remotes = getSettledValue(remotesResult, []); + const [remote] = remotes; + + let enrichedAutolinks; + + if (remote?.hasIntegration()) { + const [enrichedAutolinksResult] = await Promise.allSettled([ + pauseOnCancelOrTimeoutMapTuplePromise(this.commit.getEnrichedAutolinks(remote), cancellation), + ]); + + if (cancellation.isCancellationRequested) return undefined; + + const enrichedAutolinksMaybeResult = getSettledValue(enrichedAutolinksResult); + if (!enrichedAutolinksMaybeResult?.paused) { + enrichedAutolinks = enrichedAutolinksMaybeResult?.value; + } + } + + const tooltip = await CommitFormatter.fromTemplateAsync( + configuration.get('views.formats.stashes.tooltip'), this.commit, { + enrichedAutolinks: enrichedAutolinks, dateFormat: configuration.get('defaultDateFormat'), - // messageAutolinks: true, + messageAutolinks: true, + messageIndent: 4, + outputFormat: 'markdown', + remotes: remotes, }, ); - return item; + const markdown = new MarkdownString(tooltip, true); + markdown.supportHtml = true; + markdown.isTrusted = true; + + return markdown; } } diff --git a/src/views/nodes/stashesNode.ts b/src/views/nodes/stashesNode.ts index 2c40ae14b770b..10b7ba0fd4bbf 100644 --- a/src/views/nodes/stashesNode.ts +++ b/src/views/nodes/stashesNode.ts @@ -1,41 +1,45 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; import type { GitUri } from '../../git/gitUri'; import type { Repository } from '../../git/models/repository'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; -import type { RepositoriesView } from '../repositoriesView'; -import type { StashesView } from '../stashesView'; +import type { ViewsWithStashesNode } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { MessageNode } from './common'; -import { RepositoryNode } from './repositoryNode'; import { StashNode } from './stashNode'; -import { ContextValues, ViewNode } from './viewNode'; -export class StashesNode extends ViewNode { - static key = ':stashes'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; +export class StashesNode extends CacheableChildrenViewNode<'stashes', ViewsWithStashesNode> { + constructor( + uri: GitUri, + view: ViewsWithStashesNode, + protected override parent: ViewNode, + public readonly repo: Repository, + ) { + super('stashes', uri, view, parent); + + this.updateContext({ repository: repo }); + this._uniqueId = getViewNodeId(this.type, this.context); } - private _children: ViewNode[] | undefined; - - constructor(uri: GitUri, view: StashesView | RepositoriesView, parent: ViewNode, public readonly repo: Repository) { - super(uri, view, parent); + override get id(): string { + return this._uniqueId; } - override get id(): string { - return StashesNode.getId(this.repo.path); + get repoPath(): string { + return this.repo.path; } async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const stash = await this.repo.getStash(); if (stash == null) return [new MessageNode(this.view, this, 'No stashes could be found.')]; - this._children = [...map(stash.commits.values(), c => new StashNode(this.view, this, c))]; + this.children = [...map(stash.commits.values(), c => new StashNode(this.view, this, c))]; } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -46,9 +50,8 @@ export class StashesNode extends ViewNode { return item; } - @gate() @debug() override refresh() { - this._children = undefined; + super.refresh(true); } } diff --git a/src/views/nodes/statusFileNode.ts b/src/views/nodes/statusFileNode.ts index 110f95a093d6d..abe45327e21cf 100644 --- a/src/views/nodes/statusFileNode.ts +++ b/src/views/nodes/statusFileNode.ts @@ -1,26 +1,29 @@ import type { Command } from 'vscode'; import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import type { DiffWithCommandArgs } from '../../commands'; +import type { DiffWithCommandArgs } from '../../commands/diffWith'; import type { DiffWithPreviousCommandArgs } from '../../commands/diffWithPrevious'; -import { Commands } from '../../constants'; +import { Commands } from '../../constants.commands'; import { StatusFileFormatter } from '../../git/formatters/statusFormatter'; import { GitUri } from '../../git/gitUri'; import type { GitCommit } from '../../git/models/commit'; -import { GitFile } from '../../git/models/file'; -import { joinPaths, relativeDir } from '../../system/path'; +import type { GitFile } from '../../git/models/file'; +import { getGitFileStatusIcon } from '../../git/models/file'; +import { joinPaths } from '../../system/path'; import { pluralize } from '../../system/string'; +import { relativeDir } from '../../system/vscode/path'; import type { ViewsWithCommits } from '../viewBase'; +import { ViewFileNode } from './abstract/viewFileNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues } from './abstract/viewNode'; import { FileRevisionAsCommitNode } from './fileRevisionAsCommitNode'; import type { FileNode } from './folderNode'; -import type { ViewNode } from './viewNode'; -import { ContextValues, ViewFileNode } from './viewNode'; -export class StatusFileNode extends ViewFileNode implements FileNode { +export class StatusFileNode extends ViewFileNode<'status-file', ViewsWithCommits> implements FileNode { public readonly commits: GitCommit[]; - private readonly _direction: 'ahead' | 'behind'; private readonly _hasStagedChanges: boolean; private readonly _hasUnstagedChanges: boolean; + private readonly _type: 'ahead' | 'behind' | 'working'; constructor( view: ViewsWithCommits, @@ -28,7 +31,7 @@ export class StatusFileNode extends ViewFileNode implements Fi file: GitFile, repoPath: string, commits: GitCommit[], - direction: 'ahead' | 'behind' = 'ahead', + type: 'ahead' | 'behind' | 'working', ) { let hasStagedChanges = false; let hasUnstagedChanges = false; @@ -54,11 +57,11 @@ export class StatusFileNode extends ViewFileNode implements Fi } } - super(GitUri.fromFile(file, repoPath, ref), view, parent, file); + super('status-file', GitUri.fromFile(file, repoPath, ref), view, parent, file); this.commits = commits; - this._direction = direction; + this._type = type; this._hasStagedChanges = hasStagedChanges; this._hasUnstagedChanges = hasUnstagedChanges; } @@ -118,7 +121,7 @@ export class StatusFileNode extends ViewFileNode implements Fi } else { item.contextValue = ContextValues.StatusFileCommits; - const icon = GitFile.getStatusIcon(this.file.status); + const icon = getGitFileStatusIcon(this.file.status); item.iconPath = { dark: this.view.container.context.asAbsolutePath(joinPaths('images', 'dark', icon)), light: this.view.container.context.asAbsolutePath(joinPaths('images', 'light', icon)), @@ -250,24 +253,63 @@ export class StatusFileNode extends ViewFileNode implements Fi }; } - const commit = this._direction === 'behind' ? this.commits[0] : this.commits[this.commits.length - 1]; - const file = commit.files?.find(f => f.path === this.file.path) ?? this.file; - const commandArgs: DiffWithCommandArgs = { - lhs: { - sha: this._direction === 'behind' ? commit.sha : `${commit.sha}^`, - uri: GitUri.fromFile(file, this.repoPath, undefined, true), - }, - rhs: { - sha: '', - uri: GitUri.fromFile(this.file, this.repoPath), - }, - repoPath: this.repoPath, - line: 0, - showOptions: { - preserveFocus: true, - preview: true, - }, - }; + let commandArgs: DiffWithCommandArgs; + switch (this._type) { + case 'ahead': + case 'behind': { + const lhs = this.commits[this.commits.length - 1]; + const rhs = this.commits[0]; + + commandArgs = { + lhs: { + sha: `${lhs.sha}^`, + uri: GitUri.fromFile( + lhs.files?.find(f => f.path === this.file.path) ?? this.file.path, + this.repoPath, + `${lhs.sha}^`, + true, + ), + }, + rhs: { + sha: rhs.sha, + uri: GitUri.fromFile( + rhs.files?.find(f => f.path === this.file.path) ?? this.file.path, + this.repoPath, + rhs.sha, + ), + }, + repoPath: this.repoPath, + line: 0, + showOptions: { + preserveFocus: true, + preview: true, + }, + }; + break; + } + default: { + const commit = this.commits[this.commits.length - 1]; + const file = commit.files?.find(f => f.path === this.file.path) ?? this.file; + commandArgs = { + lhs: { + sha: `${commit.sha}^`, + uri: GitUri.fromFile(file, this.repoPath, undefined, true), + }, + rhs: { + sha: '', + uri: GitUri.fromFile(this.file, this.repoPath), + }, + repoPath: this.repoPath, + line: 0, + showOptions: { + preserveFocus: true, + preview: true, + }, + }; + break; + } + } + return { title: 'Open Changes', command: Commands.DiffWith, diff --git a/src/views/nodes/statusFilesNode.ts b/src/views/nodes/statusFilesNode.ts index d86d680a4ca8b..fb9e587a31dc2 100644 --- a/src/views/nodes/statusFilesNode.ts +++ b/src/views/nodes/statusFilesNode.ts @@ -1,50 +1,37 @@ import { TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { ViewFilesLayout } from '../../configuration'; import { GitUri } from '../../git/gitUri'; -import type { GitTrackingState } from '../../git/models/branch'; import type { GitCommit } from '../../git/models/commit'; import type { GitFileWithCommit } from '../../git/models/file'; import type { GitLog } from '../../git/models/log'; import type { GitStatus, GitStatusFile } from '../../git/models/status'; -import { groupBy, makeHierarchical } from '../../system/array'; -import { filter, flatMap, map } from '../../system/iterable'; +import { makeHierarchical } from '../../system/array'; +import { filter, flatMap, groupBy, map } from '../../system/iterable'; import { joinPaths, normalizePath } from '../../system/path'; import { pluralize, sortCompare } from '../../system/string'; -import type { RepositoriesView } from '../repositoriesView'; -import { WorktreesView } from '../worktreesView'; +import type { ViewsWithWorkingTree } from '../viewBase'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; import type { FileNode } from './folderNode'; import { FolderNode } from './folderNode'; -import { RepositoryNode } from './repositoryNode'; import { StatusFileNode } from './statusFileNode'; -import { ContextValues, ViewNode } from './viewNode'; - -export class StatusFilesNode extends ViewNode { - static key = ':status-files'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; - } - - readonly repoPath: string; +export class StatusFilesNode extends ViewNode<'status-files', ViewsWithWorkingTree> { constructor( - view: RepositoriesView | WorktreesView, - parent: ViewNode, - public readonly status: - | GitStatus - | { - readonly repoPath: string; - readonly files: GitStatusFile[]; - readonly state: GitTrackingState; - readonly upstream?: string; - }, + view: ViewsWithWorkingTree, + protected override readonly parent: ViewNode, + public readonly status: GitStatus, public readonly range: string | undefined, ) { - super(GitUri.fromRepoPath(status.repoPath), view, parent); - this.repoPath = status.repoPath; + super('status-files', GitUri.fromRepoPath(status.repoPath), view, parent); + + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { - return StatusFilesNode.getId(this.repoPath); + return this._uniqueId; + } + + get repoPath(): string { + return this.status.repoPath; } async getChildren(): Promise { @@ -72,13 +59,8 @@ export class StatusFilesNode extends ViewNode } } - if ( - (this.view instanceof WorktreesView || this.view.config.includeWorkingTree) && - this.status.files.length !== 0 - ) { - files.splice( - 0, - 0, + if ((this.view.type === 'worktrees' || this.view.config.includeWorkingTree) && this.status.files.length !== 0) { + files.unshift( ...flatMap(this.status.files, f => map(f.getPseudoCommits(this.view.container, undefined), c => this.getFileWithPseudoCommit(f, c)), ), @@ -97,10 +79,11 @@ export class StatusFilesNode extends ViewNode files[files.length - 1], repoPath, files.map(s => s.commit), + 'working', ), ); - if (this.view.config.files.layout !== ViewFilesLayout.List) { + if (this.view.config.files.layout !== 'list') { const hierarchy = makeHierarchical( children, n => n.uri.relativePath.split('/'), @@ -108,7 +91,7 @@ export class StatusFilesNode extends ViewNode this.view.config.files.compact, ); - const root = new FolderNode(this.view, this, repoPath, '', hierarchy, true); + const root = new FolderNode(this.view, this, hierarchy, repoPath, '', undefined, true); children = root.getChildren() as FileNode[]; } else { children.sort((a, b) => a.priority - b.priority || sortCompare(a.label!, b.label!)); @@ -119,14 +102,14 @@ export class StatusFilesNode extends ViewNode async getTreeItem(): Promise { let files = - this.view instanceof WorktreesView || this.view.config.includeWorkingTree ? this.status.files.length : 0; + this.view.type === 'worktrees' || this.view.config.includeWorkingTree ? this.status.files.length : 0; if (this.range != null) { if (this.status.upstream != null && this.status.state.ahead > 0) { if (files > 0) { const aheadFiles = await this.view.container.git.getDiffStatus( this.repoPath, - `${this.status.upstream}...`, + `${this.status.upstream?.name}...`, ); if (aheadFiles != null) { @@ -143,7 +126,7 @@ export class StatusFilesNode extends ViewNode } else { const stats = await this.view.container.git.getChangedFilesCount( this.repoPath, - `${this.status.upstream}...`, + `${this.status.upstream?.name}...`, ); if (stats != null) { files += stats.changedFiles; @@ -156,6 +139,7 @@ export class StatusFilesNode extends ViewNode const label = files === -1 ? '?? files changed' : `${pluralize('file', files)} changed`; const item = new TreeItem(label, TreeItemCollapsibleState.Collapsed); + item.description = 'working tree'; item.id = this.id; item.contextValue = ContextValues.StatusFiles; item.iconPath = { diff --git a/src/views/nodes/tagNode.ts b/src/views/nodes/tagNode.ts index 0064e70d0fa44..77cb73bd574c9 100644 --- a/src/views/nodes/tagNode.ts +++ b/src/views/nodes/tagNode.ts @@ -1,45 +1,49 @@ import { TreeItem, TreeItemCollapsibleState, window } from 'vscode'; -import { ViewBranchesLayout } from '../../configuration'; import { GlyphChars } from '../../constants'; import { emojify } from '../../emojis'; import type { GitUri } from '../../git/gitUri'; import type { GitLog } from '../../git/models/log'; import type { GitTagReference } from '../../git/models/reference'; -import { GitRevision } from '../../git/models/reference'; +import { shortenRevision } from '../../git/models/reference'; import type { GitTag } from '../../git/models/tag'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; import { pad } from '../../system/string'; -import type { RepositoriesView } from '../repositoriesView'; -import type { TagsView } from '../tagsView'; +import type { ViewsWithTags } from '../viewBase'; +import type { PageableViewNode, ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { ViewRefNode } from './abstract/viewRefNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode, MessageNode } from './common'; import { insertDateMarkers } from './helpers'; -import { RepositoryNode } from './repositoryNode'; -import type { PageableViewNode, ViewNode } from './viewNode'; -import { ContextValues, ViewRefNode } from './viewNode'; - -export class TagNode extends ViewRefNode implements PageableViewNode { - static key = ':tag'; - static getId(repoPath: string, name: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${name})`; + +export class TagNode extends ViewRefNode<'tag', ViewsWithTags, GitTagReference> implements PageableViewNode { + limit: number | undefined; + + constructor( + uri: GitUri, + view: ViewsWithTags, + public override parent: ViewNode, + public readonly tag: GitTag, + ) { + super('tag', uri, view, parent); + + this.updateContext({ tag: tag }); + this._uniqueId = getViewNodeId(this.type, this.context); + this.limit = this.view.getNodeLastKnownLimit(this); } - constructor(uri: GitUri, view: TagsView | RepositoriesView, parent: ViewNode, public readonly tag: GitTag) { - super(uri, view, parent); + override get id(): string { + return this._uniqueId; } override toClipboard(): string { return this.tag.name; } - override get id(): string { - return TagNode.getId(this.tag.repoPath, this.tag.name); - } - get label(): string { - return this.view.config.branches.layout === ViewBranchesLayout.Tree ? this.tag.getBasename() : this.tag.name; + return this.view.config.branches.layout === 'tree' ? this.tag.getBasename() : this.tag.name; } get ref(): GitTagReference { @@ -79,7 +83,7 @@ export class TagNode extends ViewRefNode this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; diff --git a/src/views/nodes/tagsNode.ts b/src/views/nodes/tagsNode.ts index 8c666c1e9ac1d..1ed3a3875b2af 100644 --- a/src/views/nodes/tagsNode.ts +++ b/src/views/nodes/tagsNode.ts @@ -1,32 +1,31 @@ import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { ViewBranchesLayout } from '../../configuration'; import { GitUri } from '../../git/gitUri'; import type { Repository } from '../../git/models/repository'; import { makeHierarchical } from '../../system/array'; -import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; -import type { RepositoriesView } from '../repositoriesView'; -import type { TagsView } from '../tagsView'; +import type { ViewsWithTagsNode } from '../viewBase'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { BranchOrTagFolderNode } from './branchOrTagFolderNode'; import { MessageNode } from './common'; -import { RepositoryNode } from './repositoryNode'; import { TagNode } from './tagNode'; -import { ContextValues, ViewNode } from './viewNode'; -export class TagsNode extends ViewNode { - static key = ':tags'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; - } - - private _children: ViewNode[] | undefined; +export class TagsNode extends CacheableChildrenViewNode<'tags', ViewsWithTagsNode> { + constructor( + uri: GitUri, + view: ViewsWithTagsNode, + protected override readonly parent: ViewNode, + public readonly repo: Repository, + ) { + super('tags', uri, view, parent); - constructor(uri: GitUri, view: TagsView | RepositoriesView, parent: ViewNode, public readonly repo: Repository) { - super(uri, view, parent); + this.updateContext({ repository: repo }); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { - return TagsNode.getId(this.repo.path); + return this._uniqueId; } get repoPath(): string { @@ -34,7 +33,7 @@ export class TagsNode extends ViewNode { } async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const tags = await this.repo.getTags({ sort: true }); if (tags.values.length === 0) return [new MessageNode(this.view, this, 'No tags could be found.')]; @@ -42,7 +41,7 @@ export class TagsNode extends ViewNode { const tagNodes = tags.values.map( t => new TagNode(GitUri.fromRepoPath(this.uri.repoPath!, t.ref), this.view, this, t), ); - if (this.view.config.branches.layout === ViewBranchesLayout.List) return tagNodes; + if (this.view.config.branches.layout === 'list') return tagNodes; const hierarchy = makeHierarchical( tagNodes, @@ -51,20 +50,11 @@ export class TagsNode extends ViewNode { this.view.config.files.compact, ); - const root = new BranchOrTagFolderNode( - this.view, - this, - 'tag', - this.repo.path, - '', - undefined, - hierarchy, - 'tags', - ); - this._children = root.getChildren(); + const root = new BranchOrTagFolderNode(this.view, this, 'tag', hierarchy, this.repo.path, '', undefined); + this.children = root.getChildren(); } - return this._children; + return this.children; } getTreeItem(): TreeItem { @@ -75,9 +65,8 @@ export class TagsNode extends ViewNode { return item; } - @gate() @debug() override refresh() { - this._children = undefined; + super.refresh(true); } } diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts deleted file mode 100644 index 5c5e85632681f..0000000000000 --- a/src/views/nodes/viewNode.ts +++ /dev/null @@ -1,638 +0,0 @@ -import type { Command, Event, TreeViewVisibilityChangeEvent } from 'vscode'; -import { Disposable, MarkdownString, TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { GlyphChars } from '../../constants'; -import type { RepositoriesChangeEvent } from '../../git/gitProviderService'; -import type { GitUri } from '../../git/gitUri'; -import { unknownGitUri } from '../../git/gitUri'; -import type { GitFile } from '../../git/models/file'; -import type { GitRevisionReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; -import { GitRemote } from '../../git/models/remote'; -import type { RepositoryChangeEvent } from '../../git/models/repository'; -import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; -import { getLoggableName } from '../../logger'; -import type { SubscriptionChangeEvent } from '../../plus/subscription/subscriptionService'; -import { gate } from '../../system/decorators/gate'; -import { debug, log, logName } from '../../system/decorators/log'; -import { is as isA, szudzikPairing } from '../../system/function'; -import { pad } from '../../system/string'; -import type { TreeViewNodeCollapsibleStateChangeEvent, View } from '../viewBase'; - -export const enum ContextValues { - ActiveFileHistory = 'gitlens:history:active:file', - ActiveLineHistory = 'gitlens:history:active:line', - AutolinkedItems = 'gitlens:autolinked:items', - AutolinkedIssue = 'gitlens:autolinked:issue', - AutolinkedItem = 'gitlens:autolinked:item', - Branch = 'gitlens:branch', - Branches = 'gitlens:branches', - BranchStatusAheadOfUpstream = 'gitlens:status-branch:upstream:ahead', - BranchStatusBehindUpstream = 'gitlens:status-branch:upstream:behind', - BranchStatusNoUpstream = 'gitlens:status-branch:upstream:none', - BranchStatusSameAsUpstream = 'gitlens:status-branch:upstream:same', - BranchStatusFiles = 'gitlens:status-branch:files', - Commit = 'gitlens:commit', - Commits = 'gitlens:commits', - Compare = 'gitlens:compare', - CompareBranch = 'gitlens:compare:branch', - ComparePicker = 'gitlens:compare:picker', - ComparePickerWithRef = 'gitlens:compare:picker:ref', - CompareResults = 'gitlens:compare:results', - CompareResultsCommits = 'gitlens:compare:results:commits', - Contributor = 'gitlens:contributor', - Contributors = 'gitlens:contributors', - DateMarker = 'gitlens:date-marker', - File = 'gitlens:file', - FileHistory = 'gitlens:history:file', - Folder = 'gitlens:folder', - LineHistory = 'gitlens:history:line', - Merge = 'gitlens:merge', - MergeConflictCurrentChanges = 'gitlens:merge-conflict:current', - MergeConflictIncomingChanges = 'gitlens:merge-conflict:incoming', - Message = 'gitlens:message', - Pager = 'gitlens:pager', - PullRequest = 'gitlens:pullrequest', - Rebase = 'gitlens:rebase', - Reflog = 'gitlens:reflog', - ReflogRecord = 'gitlens:reflog-record', - Remote = 'gitlens:remote', - Remotes = 'gitlens:remotes', - Repositories = 'gitlens:repositories', - Repository = 'gitlens:repository', - RepositoryFolder = 'gitlens:repo-folder', - ResultsFile = 'gitlens:file:results', - ResultsFiles = 'gitlens:results:files', - SearchAndCompare = 'gitlens:searchAndCompare', - SearchResults = 'gitlens:search:results', - SearchResultsCommits = 'gitlens:search:results:commits', - Stash = 'gitlens:stash', - Stashes = 'gitlens:stashes', - StatusFileCommits = 'gitlens:status:file:commits', - StatusFiles = 'gitlens:status:files', - StatusAheadOfUpstream = 'gitlens:status:upstream:ahead', - StatusBehindUpstream = 'gitlens:status:upstream:behind', - StatusNoUpstream = 'gitlens:status:upstream:none', - StatusSameAsUpstream = 'gitlens:status:upstream:same', - Tag = 'gitlens:tag', - Tags = 'gitlens:tags', - UncommittedFiles = 'gitlens:uncommitted:files', - Worktree = 'gitlens:worktree', - Worktrees = 'gitlens:worktrees', -} - -export interface ViewNode { - readonly id?: string; -} - -@logName((c, name) => `${name}${c.id != null ? `(${c.id})` : ''}`) -export abstract class ViewNode { - protected splatted = false; - - constructor(uri: GitUri, public readonly view: TView, protected parent?: ViewNode) { - this._uri = uri; - } - - toClipboard?(): string; - - toString(): string { - const id = this.id; - return `${getLoggableName(this)}${id != null ? `(${id})` : ''}`; - } - - protected _uri: GitUri; - get uri(): GitUri { - return this._uri; - } - - abstract getChildren(): ViewNode[] | Promise; - - getParent(): ViewNode | undefined { - // If this node's parent has been splatted (e.g. not shown itself, but its children are), then return its grandparent - return this.parent?.splatted ? this.parent?.getParent() : this.parent; - } - - abstract getTreeItem(): TreeItem | Promise; - - resolveTreeItem?(item: TreeItem): TreeItem | Promise; - - getCommand(): Command | undefined { - return undefined; - } - - refresh?(reset?: boolean): boolean | void | Promise | Promise; - - @gate((reset: boolean = false, force: boolean = false, avoidSelf?: ViewNode) => - JSON.stringify([reset, force, avoidSelf?.toString()]), - ) - @debug() - triggerChange(reset: boolean = false, force: boolean = false, avoidSelf?: ViewNode): Promise { - // If this node has been splatted (e.g. not shown itself, but its children are), then delegate the change to its parent - if (this.splatted && this.parent != null && this.parent !== avoidSelf) { - return this.parent.triggerChange(reset, force); - } - - return this.view.refreshNode(this, reset, force); - } - - getSplattedChild?(): Promise; - - deleteState = StateKey>(key?: T): void { - if (this.id == null) { - debugger; - throw new Error('Id is required to delete state'); - } - return this.view.nodeState.deleteState(this.id, key as string); - } - - getState = StateKey>(key: T): StateValue | undefined { - if (this.id == null) { - debugger; - throw new Error('Id is required to get state'); - } - return this.view.nodeState.getState(this.id, key as string); - } - - storeState = StateKey>(key: T, value: StateValue): void { - if (this.id == null) { - debugger; - throw new Error('Id is required to store state'); - } - this.view.nodeState.storeState(this.id, key as string, value); - } -} - -export function isViewNode(node: any): node is ViewNode { - return node instanceof ViewNode; -} - -type StateKey = keyof T; -type StateValue> = P extends keyof T ? T[P] : never; - -export abstract class ViewFileNode extends ViewNode< - TView, - State -> { - constructor(uri: GitUri, view: TView, public override parent: ViewNode, public readonly file: GitFile) { - super(uri, view, parent); - } - - get repoPath(): string { - return this.uri.repoPath!; - } - - override toString(): string { - return `${super.toString()}:${this.file.path}`; - } -} - -export abstract class ViewRefNode< - TView extends View = View, - TReference extends GitReference = GitReference, - State extends object = any, -> extends ViewNode { - abstract get ref(): TReference; - - get repoPath(): string { - return this.uri.repoPath!; - } - - override toString(): string { - return `${super.toString()}:${GitReference.toString(this.ref, false)}`; - } -} - -export abstract class ViewRefFileNode extends ViewFileNode< - TView, - State -> { - constructor(uri: GitUri, view: TView, parent: ViewNode, file: GitFile) { - super(uri, view, parent, file); - } - - abstract get ref(): GitRevisionReference; - - override toString(): string { - return `${super.toString()}:${this.file.path}`; - } -} - -export interface PageableViewNode { - readonly id: string; - limit?: number; - readonly hasMore: boolean; - loadMore(limit?: number | { until?: string | undefined }, context?: Record): Promise; -} - -export function isPageableViewNode(node: ViewNode): node is ViewNode & PageableViewNode { - return isA(node, 'loadMore'); -} - -export abstract class SubscribeableViewNode extends ViewNode { - protected disposable: Disposable; - protected subscription: Promise | undefined; - - protected loaded: boolean = false; - - constructor(uri: GitUri, view: TView, parent?: ViewNode) { - super(uri, view, parent); - - const disposables = [ - this.view.onDidChangeVisibility(this.onVisibilityChanged, this), - this.view.onDidChangeNodeCollapsibleState(this.onNodeCollapsibleStateChanged, this), - ]; - - if (canAutoRefreshView(this.view)) { - disposables.push(this.view.onDidChangeAutoRefresh(this.onAutoRefreshChanged, this)); - } - - const getTreeItem = this.getTreeItem; - this.getTreeItem = function (this: SubscribeableViewNode) { - this.loaded = true; - void this.ensureSubscription(); - return getTreeItem.apply(this); - }; - - const getChildren = this.getChildren; - this.getChildren = function (this: SubscribeableViewNode) { - this.loaded = true; - void this.ensureSubscription(); - return getChildren.apply(this); - }; - - this.disposable = Disposable.from(...disposables); - } - - @debug() - dispose() { - void this.unsubscribe(); - - this.disposable?.dispose(); - } - - @gate() - @debug() - override async triggerChange(reset: boolean = false, force: boolean = false): Promise { - if (!this.loaded) return; - - if (reset && !this.view.visible) { - this._pendingReset = reset; - } - await super.triggerChange(reset, force); - } - - private _canSubscribe: boolean = true; - protected get canSubscribe(): boolean { - return this._canSubscribe; - } - protected set canSubscribe(value: boolean) { - if (this._canSubscribe === value) return; - - this._canSubscribe = value; - - void this.ensureSubscription(); - if (value) { - void this.triggerChange(); - } - } - - private _etag: number | undefined; - protected abstract etag(): number; - - private _pendingReset: boolean = false; - private get requiresResetOnVisible(): boolean { - let reset = this._pendingReset; - this._pendingReset = false; - - const etag = this.etag(); - if (etag !== this._etag) { - this._etag = etag; - reset = true; - } - - return reset; - } - - protected abstract subscribe(): Disposable | undefined | Promise; - - @debug() - protected async unsubscribe(): Promise { - this._etag = this.etag(); - - if (this.subscription != null) { - const subscriptionPromise = this.subscription; - this.subscription = undefined; - - (await subscriptionPromise)?.dispose(); - } - } - - @debug() - protected onAutoRefreshChanged() { - this.onVisibilityChanged({ visible: this.view.visible }); - } - - protected onParentCollapsibleStateChanged?(state: TreeItemCollapsibleState): void; - protected onCollapsibleStateChanged?(state: TreeItemCollapsibleState): void; - - protected collapsibleState: TreeItemCollapsibleState | undefined; - protected onNodeCollapsibleStateChanged(e: TreeViewNodeCollapsibleStateChangeEvent) { - if (e.element === this) { - this.collapsibleState = e.state; - if (this.onCollapsibleStateChanged !== undefined) { - this.onCollapsibleStateChanged(e.state); - } - } else if (e.element === this.parent) { - if (this.onParentCollapsibleStateChanged !== undefined) { - this.onParentCollapsibleStateChanged(e.state); - } - } - } - - @debug() - protected onVisibilityChanged(e: TreeViewVisibilityChangeEvent) { - void this.ensureSubscription(); - - if (e.visible) { - void this.triggerChange(this.requiresResetOnVisible); - } - } - - @gate() - @debug() - async ensureSubscription() { - // We only need to subscribe if we are visible and if auto-refresh enabled (when supported) - if (!this.canSubscribe || !this.view.visible || (canAutoRefreshView(this.view) && !this.view.autoRefresh)) { - await this.unsubscribe(); - - return; - } - - // If we already have a subscription, just kick out - if (this.subscription != null) return; - - this.subscription = Promise.resolve(this.subscribe()); - void (await this.subscription); - } - - @gate() - @debug() - async resetSubscription() { - await this.unsubscribe(); - await this.ensureSubscription(); - } -} - -export abstract class RepositoryFolderNode< - TView extends View = View, - TChild extends ViewNode = ViewNode, -> extends SubscribeableViewNode { - static key = ':repository'; - static getId(repoPath: string): string { - return `gitlens${this.key}(${repoPath})`; - } - - protected override splatted = true; - protected child: TChild | undefined; - - constructor( - uri: GitUri, - view: TView, - parent: ViewNode, - public readonly repo: Repository, - splatted: boolean, - private readonly options?: { showBranchAndLastFetched?: boolean }, - ) { - super(uri, view, parent); - - this.splatted = splatted; - } - - override toClipboard(): string { - return this.repo.path; - } - - override get id(): string { - return RepositoryFolderNode.getId(this.repo.path); - } - - async getTreeItem(): Promise { - this.splatted = false; - - const branch = await this.repo.getBranch(); - const ahead = (branch?.state.ahead ?? 0) > 0; - const behind = (branch?.state.behind ?? 0) > 0; - - const expand = ahead || behind || this.repo.starred || this.view.container.git.isRepositoryForEditor(this.repo); - - const item = new TreeItem( - this.repo.formattedName ?? this.uri.repoPath ?? '', - expand ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed, - ); - item.contextValue = `${ContextValues.RepositoryFolder}${this.repo.starred ? '+starred' : ''}`; - if (ahead) { - item.contextValue += '+ahead'; - } - if (behind) { - item.contextValue += '+behind'; - } - - if (branch != null && this.options?.showBranchAndLastFetched) { - const lastFetched = (await this.repo.getLastFetched()) ?? 0; - - const status = branch.getTrackingStatus(); - item.description = `${status ? `${status}${pad(GlyphChars.Dot, 1, 1)}` : ''}${branch.name}${ - lastFetched - ? `${pad(GlyphChars.Dot, 1, 1)}Last fetched ${Repository.formatLastFetched(lastFetched)}` - : '' - }`; - - let providerName; - if (branch.upstream != null) { - const providers = GitRemote.getHighlanderProviders( - await this.view.container.git.getRemotesWithProviders(branch.repoPath), - ); - providerName = providers?.length ? providers[0].name : undefined; - } else { - const remote = await branch.getRemoteWithProvider(); - providerName = remote?.provider?.name; - } - - item.tooltip = new MarkdownString( - `${this.repo.formattedName ?? this.uri.repoPath ?? ''}${ - lastFetched - ? `${pad(GlyphChars.Dash, 2, 2)}Last fetched ${Repository.formatLastFetched( - lastFetched, - false, - )}` - : '' - }${this.repo.formattedName ? `\n${this.uri.repoPath}` : ''}\n\nCurrent branch $(git-branch) ${ - branch.name - }${ - branch.upstream != null - ? ` is ${branch.getTrackingStatus({ - empty: branch.upstream.missing - ? `missing upstream $(git-branch) ${branch.upstream.name}` - : `up to date with $(git-branch) ${branch.upstream.name}${ - providerName ? ` on ${providerName}` : '' - }`, - expand: true, - icons: true, - separator: ', ', - suffix: ` $(git-branch) ${branch.upstream.name}${ - providerName ? ` on ${providerName}` : '' - }`, - })}` - : `hasn't been published to ${providerName ?? 'a remote'}` - }`, - true, - ); - } else { - item.tooltip = `${ - this.repo.formattedName ? `${this.repo.formattedName}\n${this.uri.repoPath}` : this.uri.repoPath ?? '' - }`; - } - - return item; - } - - override async getSplattedChild() { - if (this.child == null) { - await this.getChildren(); - } - - return this.child; - } - - @gate() - @debug() - override async refresh(reset: boolean = false) { - await this.child?.triggerChange(reset, false, this); - - await this.ensureSubscription(); - } - - @log() - async star() { - await this.repo.star(); - // void this.parent!.triggerChange(); - } - - @log() - async unstar() { - await this.repo.unstar(); - // void this.parent!.triggerChange(); - } - - @debug() - protected subscribe(): Disposable | Promise { - return this.repo.onDidChange(this.onRepositoryChanged, this); - } - - protected override etag(): number { - return this.repo.etag; - } - - protected abstract changed(e: RepositoryChangeEvent): boolean; - - @debug({ args: { 0: e => e.toString() } }) - private onRepositoryChanged(e: RepositoryChangeEvent) { - if (e.changed(RepositoryChange.Closed, RepositoryChangeComparisonMode.Any)) { - this.dispose(); - void this.parent?.triggerChange(true); - - return; - } - - if (e.changed(RepositoryChange.Starred, RepositoryChangeComparisonMode.Any)) { - void this.parent?.triggerChange(true); - - return; - } - - if (this.changed(e)) { - void (this.loaded ? this : this.parent ?? this).triggerChange(true); - } - } -} - -export abstract class RepositoriesSubscribeableNode< - TView extends View = View, - TChild extends ViewNode & Disposable = ViewNode & Disposable, -> extends SubscribeableViewNode { - protected override splatted = true; - protected children: TChild[] | undefined; - - constructor(view: TView) { - super(unknownGitUri, view); - } - - override async getSplattedChild() { - if (this.children == null) { - await this.getChildren(); - } - - return this.children?.length === 1 ? this.children[0] : undefined; - } - - @gate() - @debug() - override refresh(reset: boolean = false) { - if (reset && this.children != null) { - for (const child of this.children) { - child.dispose(); - } - this.children = undefined; - } - } - - protected override etag(): number { - return szudzikPairing(this.view.container.git.etag, this.view.container.subscription.etag); - } - - @debug() - protected subscribe(): Disposable | Promise { - return Disposable.from( - this.view.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), - this.view.container.subscription.onDidChange(this.onSubscriptionChanged, this), - ); - } - - private onRepositoriesChanged(_e: RepositoriesChangeEvent) { - void this.triggerChange(true); - } - - private onSubscriptionChanged(e: SubscriptionChangeEvent) { - if (e.current.plan !== e.previous.plan) { - void this.triggerChange(true); - } - } -} - -interface AutoRefreshableView { - autoRefresh: boolean; - onDidChangeAutoRefresh: Event; -} - -export function canAutoRefreshView(view: View): view is View & AutoRefreshableView { - return isA(view, 'onDidChangeAutoRefresh'); -} - -export function canClearNode(node: ViewNode): node is ViewNode & { clear(): void | Promise } { - return typeof (node as ViewNode & { clear(): void | Promise }).clear === 'function'; -} - -export function canEditNode(node: ViewNode): node is ViewNode & { edit(): void | Promise } { - return typeof (node as ViewNode & { edit(): void | Promise }).edit === 'function'; -} - -export function canGetNodeRepoPath(node?: ViewNode): node is ViewNode & { repoPath: string | undefined } { - return node != null && 'repoPath' in node && typeof node.repoPath === 'string'; -} - -export function canViewDismissNode(view: View): view is View & { dismissNode(node: ViewNode): void } { - return typeof (view as View & { dismissNode(node: ViewNode): void }).dismissNode === 'function'; -} - -export function getNodeRepoPath(node?: ViewNode): string | undefined { - return canGetNodeRepoPath(node) ? node.repoPath : undefined; -} diff --git a/src/views/nodes/workspaceMissingRepositoryNode.ts b/src/views/nodes/workspaceMissingRepositoryNode.ts new file mode 100644 index 0000000000000..ebac55b05344f --- /dev/null +++ b/src/views/nodes/workspaceMissingRepositoryNode.ts @@ -0,0 +1,61 @@ +import { MarkdownString, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import type { Colors } from '../../constants.colors'; +import { unknownGitUri } from '../../git/gitUri'; +import type { + CloudWorkspace, + CloudWorkspaceRepositoryDescriptor, + LocalWorkspace, + LocalWorkspaceRepositoryDescriptor, +} from '../../plus/workspaces/models'; +import { createViewDecorationUri } from '../viewDecorationProvider'; +import type { WorkspacesView } from '../workspacesView'; +import { ContextValues, getViewNodeId, ViewNode } from './abstract/viewNode'; + +export class WorkspaceMissingRepositoryNode extends ViewNode<'workspace-missing-repository', WorkspacesView> { + constructor( + view: WorkspacesView, + parent: ViewNode, + public readonly workspace: CloudWorkspace | LocalWorkspace, + public readonly wsRepositoryDescriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, + ) { + super('workspace-missing-repository', unknownGitUri, view, parent); + + this.updateContext({ wsRepositoryDescriptor: wsRepositoryDescriptor }); + this._uniqueId = getViewNodeId(this.type, this.context); + } + + override get id(): string { + return this._uniqueId; + } + + override toClipboard(): string { + return this.name; + } + + get name(): string { + return this.wsRepositoryDescriptor.name; + } + + get workspaceId(): string { + return this.wsRepositoryDescriptor.workspaceId; + } + + getChildren(): ViewNode[] { + return []; + } + + getTreeItem(): TreeItem { + const item = new TreeItem(this.name, TreeItemCollapsibleState.None); + item.id = this.id; + item.description = 'missing'; + item.tooltip = new MarkdownString(`${this.name}\n\nRepository could not be found`); + item.contextValue = ContextValues.WorkspaceMissingRepository; + item.iconPath = new ThemeIcon( + 'question', + new ThemeColor('gitlens.decorations.workspaceRepoMissingForegroundColor' satisfies Colors), + ); + item.resourceUri = createViewDecorationUri('repository', { state: 'missing', workspace: true }); + + return item; + } +} diff --git a/src/views/nodes/workspaceNode.ts b/src/views/nodes/workspaceNode.ts new file mode 100644 index 0000000000000..afb5ad1ec854f --- /dev/null +++ b/src/views/nodes/workspaceNode.ts @@ -0,0 +1,152 @@ +import { Disposable, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import type { RepositoriesChangeEvent } from '../../git/gitProviderService'; +import { GitUri } from '../../git/gitUri'; +import type { CloudWorkspace, LocalWorkspace } from '../../plus/workspaces/models'; +import { debug } from '../../system/decorators/log'; +import { weakEvent } from '../../system/event'; +import { createCommand } from '../../system/vscode/command'; +import { createViewDecorationUri } from '../viewDecorationProvider'; +import type { WorkspacesView } from '../workspacesView'; +import { SubscribeableViewNode } from './abstract/subscribeableViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; +import { CommandMessageNode, MessageNode } from './common'; +import { RepositoryNode } from './repositoryNode'; +import { WorkspaceMissingRepositoryNode } from './workspaceMissingRepositoryNode'; + +export class WorkspaceNode extends SubscribeableViewNode< + 'workspace', + WorkspacesView, + CommandMessageNode | MessageNode | RepositoryNode | WorkspaceMissingRepositoryNode +> { + constructor( + uri: GitUri, + view: WorkspacesView, + protected override parent: ViewNode, + public readonly workspace: CloudWorkspace | LocalWorkspace, + ) { + super('workspace', uri, view, parent); + + this.updateContext({ workspace: workspace }); + this._uniqueId = getViewNodeId(this.type, this.context); + } + + override get id(): string { + return this._uniqueId; + } + + override toClipboard(): string { + return this.workspace.name; + } + + async getChildren(): Promise { + if (this.children == null) { + const children = []; + + try { + const descriptors = await this.workspace.getRepositoryDescriptors(); + + if (!descriptors?.length) { + children.push( + new CommandMessageNode( + this.view, + this, + createCommand<[WorkspaceNode]>( + 'gitlens.views.workspaces.addRepos', + 'Add Repositories...', + this, + ), + 'No repositories', + ), + ); + + this.children = children; + return this.children; + } + + const reposByName = await this.workspace.getRepositoriesByName({ force: true }); + + for (const descriptor of descriptors) { + const repo = reposByName.get(descriptor.name)?.repository; + if (!repo) { + children.push(new WorkspaceMissingRepositoryNode(this.view, this, this.workspace, descriptor)); + continue; + } + + children.push( + new RepositoryNode( + GitUri.fromRepoPath(repo.path), + this.view, + this, + repo, + this.getNewContext({ wsRepositoryDescriptor: descriptor }), + ), + ); + } + } catch (_ex) { + this.children = undefined; + return [new MessageNode(this.view, this, 'Failed to load repositories')]; + } + + this.children = children; + } + + return this.children; + } + + async getTreeItem(): Promise { + const item = new TreeItem(this.workspace.name, TreeItemCollapsibleState.Collapsed); + + const cloud = this.workspace.type === 'cloud'; + + let contextValue: string = ContextValues.Workspace; + if (cloud) { + contextValue += '+cloud'; + } else { + contextValue += '+local'; + } + + const descriptionItems = []; + if (this.workspace.current) { + contextValue += '+current'; + descriptionItems.push('current'); + item.resourceUri = createViewDecorationUri('workspace', { current: true }); + } + if (this.workspace.localPath != null) { + contextValue += '+hasPath'; + } + + if ((await this.workspace.getRepositoryDescriptors())?.length === 0) { + contextValue += '+empty'; + } + + item.id = this.id; + item.contextValue = contextValue; + item.iconPath = new ThemeIcon(this.workspace.type == 'cloud' ? 'cloud' : 'folder'); + item.tooltip = `${this.workspace.name}\n${ + cloud ? `Cloud Workspace ${this.workspace.shared ? '(Shared)' : ''}` : 'Local Workspace' + }${cloud && this.workspace.provider != null ? `\nProvider: ${this.workspace.provider}` : ''}`; + + if (cloud && this.workspace.organizationId != null) { + descriptionItems.push('shared'); + } + + item.description = descriptionItems.join(', '); + return item; + } + + protected override etag(): number { + return this.view.container.git.etag; + } + + @debug() + protected subscribe(): Disposable | Promise { + return Disposable.from( + weakEvent(this.view.container.git.onDidChangeRepositories, this.onRepositoriesChanged, this), + ); + } + + private onRepositoriesChanged(_e: RepositoriesChangeEvent) { + void this.triggerChange(true); + } +} diff --git a/src/views/nodes/worktreeNode.ts b/src/views/nodes/worktreeNode.ts index efc5c569a1ea7..3c9a84c4f3254 100644 --- a/src/views/nodes/worktreeNode.ts +++ b/src/views/nodes/worktreeNode.ts @@ -1,86 +1,90 @@ -import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, window } from 'vscode'; -import { ContextKeys, GlyphChars } from '../../constants'; -import { getContext } from '../../context'; +import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; +import type { IconPath } from '../../@types/vscode.iconpath'; +import { GlyphChars } from '../../constants'; import type { GitUri } from '../../git/gitUri'; import type { GitBranch } from '../../git/models/branch'; import type { GitLog } from '../../git/models/log'; -import type { PullRequest } from '../../git/models/pullRequest'; -import { PullRequestState } from '../../git/models/pullRequest'; -import { GitRevision } from '../../git/models/reference'; -import { GitRemote, GitRemoteType } from '../../git/models/remote'; +import type { PullRequest, PullRequestState } from '../../git/models/pullRequest'; +import { shortenRevision } from '../../git/models/reference'; +import { getHighlanderProviderName } from '../../git/models/remote'; +import type { GitStatus } from '../../git/models/status'; import type { GitWorktree } from '../../git/models/worktree'; +import { getBranchIconPath } from '../../git/utils/branch-utils'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import { map } from '../../system/iterable'; import type { Deferred } from '../../system/promise'; import { defer, getSettledValue } from '../../system/promise'; import { pad } from '../../system/string'; -import type { RepositoriesView } from '../repositoriesView'; -import type { WorktreesView } from '../worktreesView'; +import { getContext } from '../../system/vscode/context'; +import type { ViewsWithWorktrees } from '../viewBase'; +import { createViewDecorationUri } from '../viewDecorationProvider'; +import { CacheableChildrenViewNode } from './abstract/cacheableChildrenViewNode'; +import type { ViewNode } from './abstract/viewNode'; +import { ContextValues, getViewNodeId } from './abstract/viewNode'; import { CommitNode } from './commitNode'; import { LoadMoreNode, MessageNode } from './common'; import { CompareBranchNode } from './compareBranchNode'; import { insertDateMarkers } from './helpers'; import { PullRequestNode } from './pullRequestNode'; -import { RepositoryNode } from './repositoryNode'; import { UncommittedFilesNode } from './UncommittedFilesNode'; -import { ContextValues, ViewNode } from './viewNode'; type State = { pullRequest: PullRequest | null | undefined; pendingPullRequest: Promise | undefined; }; -export class WorktreeNode extends ViewNode { - static key = ':worktree'; - static getId(repoPath: string, uri: Uri): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${uri.path})`; - } +export class WorktreeNode extends CacheableChildrenViewNode<'worktree', ViewsWithWorktrees, ViewNode, State> { + limit: number | undefined; private _branch: GitBranch | undefined; constructor( uri: GitUri, - view: WorktreesView | RepositoriesView, - parent: ViewNode, + view: ViewsWithWorktrees, + protected override readonly parent: ViewNode, public readonly worktree: GitWorktree, + private readonly worktreeStatus: { status: GitStatus | undefined; missing: boolean } | undefined, ) { - super(uri, view, parent); - } + super('worktree', uri, view, parent); - override toClipboard(): string { - return this.worktree.uri.fsPath; + this.updateContext({ worktree: worktree }); + this._uniqueId = getViewNodeId(this.type, this.context); + this.limit = this.view.getNodeLastKnownLimit(this); } override get id(): string { - return WorktreeNode.getId(this.worktree.repoPath, this.worktree.uri); + return this._uniqueId; + } + + override toClipboard(): string { + return this.worktree.uri.fsPath; } get repoPath(): string { return this.uri.repoPath!; } - private _children: ViewNode[] | undefined; - async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const branch = this._branch; let onCompleted: Deferred | undefined; let pullRequest; + const pullRequestInsertIndex = 0; if ( branch != null && this.view.config.pullRequests.enabled && this.view.config.pullRequests.showForBranches && (branch.upstream != null || branch.remote) && - getContext(ContextKeys.HasConnectedRemotes) + getContext('gitlens:repos:withHostingIntegrationsConnected')?.includes(branch.repoPath) ) { pullRequest = this.getState('pullRequest'); if (pullRequest === undefined && this.getState('pendingPullRequest') === undefined) { onCompleted = defer(); const prPromise = this.getAssociatedPullRequest(branch, { - include: [PullRequestState.Open, PullRequestState.Merged], + include: ['opened', 'merged'], }); queueMicrotask(async () => { @@ -97,9 +101,9 @@ export class WorktreeNode extends ViewNode - range - ? this.view.container.git.getLogRefsOnly(this.uri.repoPath!, { - limit: 0, - ref: range, - }) - : undefined, - ) - : undefined, - ]); + const [logResult, getBranchAndTagTipsResult, unpublishedCommitsResult] = await Promise.allSettled([ + this.getLog(), + this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath), + branch != null && !branch.remote + ? this.view.container.git.getBranchAheadRange(branch).then(range => + range + ? this.view.container.git.getLogRefsOnly(this.uri.repoPath!, { + limit: 0, + ref: range, + }) + : undefined, + ) + : undefined, + ]); const log = getSettledValue(logResult); if (log == null) return [new MessageNode(this.view, this, 'No commits could be found.')]; const children = []; + if (branch != null && pullRequest != null) { + children.push(new PullRequestNode(this.view, this, pullRequest, branch)); + } + if (branch != null && this.view.config.showBranchComparison !== false) { children.push( new CompareBranchNode( @@ -147,10 +153,6 @@ export class WorktreeNode extends ViewNode { this.splatted = false; let description = ''; - const tooltip = new MarkdownString('', true); - let icon: ThemeIcon | undefined; + let icon: IconPath | undefined; let hasChanges = false; + const tooltip = new MarkdownString('', true); + tooltip.isTrusted = true; + const indicators = - this.worktree.main || this.worktree.opened - ? `${pad(GlyphChars.Dash, 2, 2)} ${ - this.worktree.main - ? `_Main${this.worktree.opened ? ', Active_' : '_'}` + this.worktree.isDefault || this.worktree.opened + ? ` \u00a0(${ + this.worktree.isDefault + ? `_default${this.worktree.opened ? ', active_' : '_'}` : this.worktree.opened - ? '_Active_' - : '' - } ` + ? '_active_' + : '' + })` : ''; + const status = this.worktreeStatus?.status; + + const folder = `\\\n$(folder) [\`${ + this.worktree.friendlyPath + }\`](command:gitlens.views.revealWorktreeInExplorer?%22${this.worktree.uri.toString()}%22 "Reveal in Explorer")`; + switch (this.worktree.type) { case 'bare': icon = new ThemeIcon('folder'); tooltip.appendMarkdown( - `${this.worktree.main ? '$(pass) ' : ''}Bare Worktree${indicators}\\\n\`${ - this.worktree.friendlyPath - }\``, + `${this.worktree.isDefault ? '$(pass) ' : ''}Bare Worktree${indicators}${folder}`, ); break; + case 'branch': { - const [branch, status] = await Promise.all([this.worktree.getBranch(), this.worktree.getStatus()]); + const { branch } = this.worktree; this._branch = branch; tooltip.appendMarkdown( - `${this.worktree.main ? '$(pass) ' : ''}Worktree for Branch $(git-branch) ${ - branch?.getNameWithoutRemote() ?? this.worktree.branch - }${indicators}\\\n\`${this.worktree.friendlyPath}\``, + `${this.worktree.isDefault ? '$(pass) ' : ''}Worktree for $(git-branch) \`${ + branch?.getNameWithoutRemote() ?? branch?.name + }\`${indicators}${folder}`, ); - icon = new ThemeIcon('git-branch'); - - if (status != null) { - hasChanges = status.hasChanges; - tooltip.appendMarkdown( - `\n\n${status.getFormattedDiffStatus({ - prefix: 'Has Uncommitted Changes\\\n', - empty: 'No Uncommitted Changes', - expand: true, - })}`, - ); - } + icon = getBranchIconPath(this.view.container, branch); if (branch != null) { - tooltip.appendMarkdown(`\n\nBranch $(git-branch) ${branch.getNameWithoutRemote()}`); - if (!branch.remote) { if (branch.upstream != null) { let arrows = GlyphChars.Dash; @@ -252,11 +246,11 @@ export class WorktreeNode extends ViewNode this.getLog(), ); - if (log == null || !log.hasMore) return; + if (!log?.hasMore) return; log = await log.more?.(limit ?? this.view.config.pageItemLimit); if (this._log === log) return; @@ -418,7 +429,7 @@ export class WorktreeNode extends ViewNode { - static key = ':worktrees'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; - } - - private _children: WorktreeNode[] | undefined; - +export class WorktreesNode extends CacheableChildrenViewNode<'worktrees', ViewsWithWorktreesNode, WorktreeNode> { constructor( uri: GitUri, - view: WorktreesView | RepositoriesView, - parent: ViewNode, + view: ViewsWithWorktreesNode, + protected override readonly parent: ViewNode, public readonly repo: Repository, ) { - super(uri, view, parent); + super('worktrees', uri, view, parent); + + this.updateContext({ repository: repo }); + this._uniqueId = getViewNodeId(this.type, this.context); } override get id(): string { - return WorktreesNode.getId(this.repo.path); + return this._uniqueId; } get repoPath(): string { @@ -38,17 +36,27 @@ export class WorktreesNode extends ViewNode { } async getChildren(): Promise { - if (this._children == null) { + if (this.children == null) { const access = await this.repo.access(PlusFeatures.Worktrees); if (!access.allowed) return []; const worktrees = await this.repo.getWorktrees(); if (worktrees.length === 0) return [new MessageNode(this.view, this, 'No worktrees could be found.')]; - this._children = worktrees.map(c => new WorktreeNode(this.uri, this.view, this, c)); + this.children = await mapAsync(sortWorktrees(worktrees), async w => { + let status; + let missing = false; + try { + status = await w.getStatus(); + } catch (ex) { + Logger.error(ex, `Worktree status failed: ${w.uri.toString(true)}`); + missing = true; + } + return new WorktreeNode(this.uri, this.view, this, w, { status: status, missing: missing }); + }); } - return this._children; + return this.children; } async getTreeItem(): Promise { @@ -62,15 +70,14 @@ export class WorktreesNode extends ViewNode { item.contextValue = ContextValues.Worktrees; item.description = access.allowed ? undefined - : ` ${GlyphChars.Warning} Requires GitLens Pro to access Worktrees on private repos`; + : ` ${GlyphChars.Warning} Requires a trial or paid plan for use on privately-hosted repos`; // TODO@eamodio `folder` icon won't work here for some reason item.iconPath = new ThemeIcon('folder-opened'); return item; } - @gate() @debug() override refresh() { - this._children = undefined; + super.refresh(true); } } diff --git a/src/views/pullRequestView.ts b/src/views/pullRequestView.ts new file mode 100644 index 0000000000000..9bb1038eceafe --- /dev/null +++ b/src/views/pullRequestView.ts @@ -0,0 +1,149 @@ +import type { ConfigurationChangeEvent, Disposable } from 'vscode'; +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import type { PullRequestViewConfig, ViewFilesLayout } from '../config'; +import { Commands } from '../constants.commands'; +import type { Container } from '../container'; +import { unknownGitUri } from '../git/gitUri'; +import type { GitBranch } from '../git/models/branch'; +import type { GitCommit } from '../git/models/commit'; +import type { PullRequest } from '../git/models/pullRequest'; +import { executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; +import { ViewNode } from './nodes/abstract/viewNode'; +import { PullRequestNode } from './nodes/pullRequestNode'; +import { ViewBase } from './viewBase'; +import { registerViewCommand } from './viewCommands'; + +export class PullRequestViewNode extends ViewNode<'pullrequest', PullRequestView> { + private child: PullRequestNode | undefined; + + constructor(view: PullRequestView) { + super('pullrequest', unknownGitUri, view); + } + + getChildren(): PullRequestNode[] { + return this.child != null ? [this.child] : []; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Pull Request', TreeItemCollapsibleState.Expanded); + return item; + } + + async setPullRequest(pr: PullRequest | undefined, branchOrCommitOrRepoPath: GitBranch | GitCommit | string) { + if (pr != null) { + this.child = new PullRequestNode(this.view, this, pr, branchOrCommitOrRepoPath, { expand: true }); + this.view.description = `${pr.repository.owner}/${pr.repository.repo}#${pr.id}`; + void setContext('gitlens:views:pullRequest:visible', true); + } else { + this.child = undefined; + this.view.description = undefined; + void setContext('gitlens:views:pullRequest:visible', false); + } + await this.triggerChange(); + } +} + +export class PullRequestView extends ViewBase<'pullRequest', PullRequestViewNode, PullRequestViewConfig> { + protected readonly configKey = 'pullRequest'; + + constructor(container: Container) { + super(container, 'pullRequest', 'Pull Request', 'commitsView'); + } + + override get canReveal(): boolean { + return false; + } + + override get canSelectMany(): boolean { + return false; + } + + protected override get showCollapseAll(): boolean { + return false; + } + + close() { + this.setVisible(false); + } + + async showPullRequest(pr: PullRequest | undefined, branchOrCommitOrRepoPath: GitBranch | GitCommit | string) { + if (pr != null) { + this.description = `${pr.repository.owner}/${pr.repository.repo}#${pr.id}`; + this.setVisible(true); + } else { + this.description = undefined; + this.setVisible(false); + } + + await this.ensureRoot().setPullRequest(pr, branchOrCommitOrRepoPath); + if (pr != null) { + await this.show(); + } + } + + private setVisible(visible: boolean) { + void setContext('gitlens:views:pullRequest:visible', visible); + } + + protected getRoot() { + return new PullRequestViewNode(this); + } + + protected registerCommands(): Disposable[] { + void this.container.viewCommands; + return [ + registerViewCommand( + this.getQualifiedCommand('copy'), + () => executeCommand(Commands.ViewsCopy, this.activeSelection, this.selection), + this, + ), + registerViewCommand(this.getQualifiedCommand('refresh'), () => this.refresh(true), this), + registerViewCommand(this.getQualifiedCommand('close'), () => this.close(), this), + registerViewCommand( + this.getQualifiedCommand('setFilesLayoutToAuto'), + () => this.setFilesLayout('auto'), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setFilesLayoutToList'), + () => this.setFilesLayout('list'), + this, + ), + registerViewCommand( + this.getQualifiedCommand('setFilesLayoutToTree'), + () => this.setFilesLayout('tree'), + this, + ), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), + registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), + ]; + } + + protected override filterConfigurationChanged(e: ConfigurationChangeEvent) { + const changed = super.filterConfigurationChanged(e); + if ( + !changed && + !configuration.changed(e, 'defaultDateFormat') && + !configuration.changed(e, 'defaultDateLocale') && + !configuration.changed(e, 'defaultDateShortFormat') && + !configuration.changed(e, 'defaultDateSource') && + !configuration.changed(e, 'defaultDateStyle') && + !configuration.changed(e, 'defaultGravatarsStyle') && + !configuration.changed(e, 'defaultTimeFormat') + ) { + return false; + } + + return true; + } + + private setFilesLayout(layout: ViewFilesLayout) { + return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout); + } + + private setShowAvatars(enabled: boolean) { + return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled); + } +} diff --git a/src/views/remotesView.ts b/src/views/remotesView.ts index 1ce0ced383ff8..106f50b71f450 100644 --- a/src/views/remotesView.ts +++ b/src/views/remotesView.ts @@ -1,27 +1,28 @@ import type { CancellationToken, ConfigurationChangeEvent, Disposable } from 'vscode'; import { ProgressLocation, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; -import type { RemotesViewConfig } from '../configuration'; -import { configuration, ViewBranchesLayout, ViewFilesLayout } from '../configuration'; -import { Commands } from '../constants'; +import type { RemotesViewConfig, ViewBranchesLayout, ViewFilesLayout } from '../config'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import { getRemoteNameFromBranchName } from '../git/models/branch'; import type { GitCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; import type { GitBranchReference, GitRevisionReference } from '../git/models/reference'; -import { GitReference } from '../git/models/reference'; +import { getReferenceLabel } from '../git/models/reference'; import type { GitRemote } from '../git/models/remote'; import type { RepositoryChangeEvent } from '../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; -import { executeCommand } from '../system/command'; +import { groupRepositories, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import { gate } from '../system/decorators/gate'; +import { executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { BranchNode } from './nodes/branchNode'; import { BranchOrTagFolderNode } from './nodes/branchOrTagFolderNode'; import { RemoteNode } from './nodes/remoteNode'; import { RemotesNode } from './nodes/remotesNode'; import { RepositoryNode } from './nodes/repositoryNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; @@ -48,9 +49,16 @@ export class RemotesRepositoryNode extends RepositoryFolderNode { async getChildren(): Promise { if (this.children == null) { - const repositories = this.view.container.git.openRepositories; + let repositories = this.view.container.git.openRepositories; + if (configuration.get('views.collapseWorktreesWhenPossible')) { + const grouped = await groupRepositories(repositories); + repositories = [...grouped.keys()]; + } + if (repositories.length === 0) { - this.view.message = 'No remotes could be found.'; + this.view.message = this.view.container.git.isDiscoveringRepositories + ? 'Loading remotes...' + : 'No remotes could be found.'; return []; } @@ -93,17 +101,21 @@ export class RemotesViewNode extends RepositoriesSubscribeableNode { +export class RemotesView extends ViewBase<'remotes', RemotesViewNode, RemotesViewConfig> { protected readonly configKey = 'remotes'; constructor(container: Container) { - super(container, 'gitlens.views.remotes', 'Remotes', 'remotesView'); + super(container, 'remotes', 'Remotes', 'remotesView'); } override get canReveal(): boolean { return this.config.reveal || !configuration.get('views.repositories.showRemotes'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + protected getRoot() { return new RemotesViewNode(this); } @@ -125,29 +137,21 @@ export class RemotesView extends ViewBase { }, this, ), - registerViewCommand( - this.getQualifiedCommand('setLayoutToList'), - () => this.setLayout(ViewBranchesLayout.List), - this, - ), - registerViewCommand( - this.getQualifiedCommand('setLayoutToTree'), - () => this.setLayout(ViewBranchesLayout.Tree), - this, - ), + registerViewCommand(this.getQualifiedCommand('setLayoutToList'), () => this.setLayout('list'), this), + registerViewCommand(this.getQualifiedCommand('setLayoutToTree'), () => this.setLayout('tree'), this), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToAuto'), - () => this.setFilesLayout(ViewFilesLayout.Auto), + () => this.setFilesLayout('auto'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToList'), - () => this.setFilesLayout(ViewFilesLayout.List), + () => this.setFilesLayout('list'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToTree'), - () => this.setFilesLayout(ViewFilesLayout.Tree), + () => this.setFilesLayout('tree'), this, ), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), @@ -177,7 +181,9 @@ export class RemotesView extends ViewBase { !configuration.changed(e, 'defaultGravatarsStyle') && !configuration.changed(e, 'defaultTimeFormat') && !configuration.changed(e, 'integrations.enabled') && - !configuration.changed(e, 'sortBranchesBy') + !configuration.changed(e, 'sortBranchesBy') && + !configuration.changed(e, 'sortRepositoriesBy') && + !configuration.changed(e, 'views.collapseWorktreesWhenPossible') ) { return false; } @@ -188,7 +194,7 @@ export class RemotesView extends ViewBase { findBranch(branch: GitBranchReference, token?: CancellationToken) { if (!branch.remote) return undefined; - const repoNodeId = RepositoryNode.getId(branch.repoPath); + const { repoPath } = branch; return this.findNode((n: any) => n.branch?.ref === branch.ref, { allowPaging: true, @@ -197,13 +203,11 @@ export class RemotesView extends ViewBase { if (n instanceof RemotesViewNode) return true; if (n instanceof RemotesRepositoryNode || n instanceof BranchOrTagFolderNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } if (n instanceof RemoteNode) { - if (!n.id.startsWith(repoNodeId)) return false; - - return n.remote.name === getRemoteNameFromBranchName(branch.name); + return n.repoPath === repoPath && n.remote.name === getRemoteNameFromBranchName(branch.name); } return false; @@ -213,38 +217,39 @@ export class RemotesView extends ViewBase { } async findCommit(commit: GitCommit | { repoPath: string; ref: string }, token?: CancellationToken) { - const repoNodeId = RepositoryNode.getId(commit.repoPath); + const { repoPath } = commit; // Get all the remote branches the commit is on const branches = await this.container.git.getCommitBranches( commit.repoPath, commit.ref, + undefined, isCommit(commit) ? { commitDate: commit.committer.date, remotes: true } : { remotes: true }, ); if (branches.length === 0) return undefined; const remotes = branches.map(b => b.split('/', 1)[0]); - return this.findNode((n: any) => n.commit !== undefined && n.commit.ref === commit.ref, { + return this.findNode((n: any) => n.commit?.ref === commit.ref, { allowPaging: true, maxDepth: 6, canTraverse: n => { if (n instanceof RemotesViewNode) return true; if (n instanceof RemotesRepositoryNode || n instanceof BranchOrTagFolderNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } if (n instanceof RemoteNode) { - return n.id.startsWith(repoNodeId) && remotes.includes(n.remote.name); + return n.repoPath === repoPath && remotes.includes(n.remote.name); } if (n instanceof BranchNode) { - return n.id.startsWith(repoNodeId) && branches.includes(n.branch.name); + return n.repoPath === repoPath && branches.includes(n.branch.name); } if (n instanceof RepositoryNode || n instanceof RemotesNode || n instanceof BranchOrTagFolderNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -254,7 +259,7 @@ export class RemotesView extends ViewBase { } findRemote(remote: GitRemote, token?: CancellationToken) { - const repoNodeId = RepositoryNode.getId(remote.repoPath); + const { repoPath } = remote; return this.findNode((n: any) => n.remote?.name === remote.name, { allowPaging: true, @@ -263,7 +268,7 @@ export class RemotesView extends ViewBase { if (n instanceof RemotesViewNode) return true; if (n instanceof RemotesRepositoryNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -284,10 +289,13 @@ export class RemotesView extends ViewBase { return window.withProgress( { location: ProgressLocation.Notification, - title: `Revealing ${GitReference.toString(branch, { icon: false, quoted: true })} in the side bar...`, + title: `Revealing ${getReferenceLabel(branch, { + icon: false, + quoted: true, + })} in the side bar...`, cancellable: true, }, - async (progress, token) => { + async (_progress, token) => { const node = await this.findBranch(branch, token); if (node == null) return undefined; @@ -310,10 +318,13 @@ export class RemotesView extends ViewBase { return window.withProgress( { location: ProgressLocation.Notification, - title: `Revealing ${GitReference.toString(commit, { icon: false, quoted: true })} in the side bar...`, + title: `Revealing ${getReferenceLabel(commit, { + icon: false, + quoted: true, + })} in the side bar...`, cancellable: true, }, - async (progress, token) => { + async (_progress, token) => { const node = await this.findCommit(commit, token); if (node == null) return undefined; @@ -339,7 +350,7 @@ export class RemotesView extends ViewBase { title: `Revealing remote '${remote.name}' in the side bar...`, cancellable: true, }, - async (progress, token) => { + async (_progress, token) => { const node = await this.findRemote(remote, token); if (node == null) return undefined; @@ -355,7 +366,7 @@ export class RemotesView extends ViewBase { repoPath: string, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, ) { - const node = await this.findNode(RepositoryFolderNode.getId(repoPath), { + const node = await this.findNode(n => n instanceof RepositoryFolderNode && n.repoPath === repoPath, { maxDepth: 1, canTraverse: n => n instanceof RemotesViewNode || n instanceof RepositoryFolderNode, }); diff --git a/src/views/repositoriesView.ts b/src/views/repositoriesView.ts index 5e6be41917e46..2f84fa3d2e7db 100644 --- a/src/views/repositoriesView.ts +++ b/src/views/repositoriesView.ts @@ -1,10 +1,8 @@ import type { CancellationToken, ConfigurationChangeEvent, Disposable, Event } from 'vscode'; import { EventEmitter, ProgressLocation, window } from 'vscode'; -import type { RepositoriesViewConfig } from '../configuration'; -import { configuration, ViewBranchesLayout, ViewFilesLayout, ViewShowBranchComparison } from '../configuration'; -import { Commands, ContextKeys } from '../constants'; +import type { RepositoriesViewConfig, ViewBranchesLayout, ViewFilesLayout } from '../config'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { setContext } from '../context'; import { getRemoteNameFromBranchName } from '../git/models/branch'; import type { GitCommit } from '../git/models/commit'; import { isCommit } from '../git/models/commit'; @@ -15,11 +13,13 @@ import type { GitStashReference, GitTagReference, } from '../git/models/reference'; -import { GitReference } from '../git/models/reference'; +import { getReferenceLabel } from '../git/models/reference'; import type { GitRemote } from '../git/models/remote'; import type { GitWorktree } from '../git/models/worktree'; -import { executeCommand } from '../system/command'; import { gate } from '../system/decorators/gate'; +import { executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; import { BranchesNode } from './nodes/branchesNode'; import { BranchNode } from './nodes/branchNode'; import { BranchOrTagFolderNode } from './nodes/branchOrTagFolderNode'; @@ -33,18 +33,17 @@ import { RemotesNode } from './nodes/remotesNode'; import { RepositoriesNode } from './nodes/repositoriesNode'; import { RepositoryNode } from './nodes/repositoryNode'; import { StashesNode } from './nodes/stashesNode'; -import { StashNode } from './nodes/stashNode'; import { TagsNode } from './nodes/tagsNode'; import { WorktreeNode } from './nodes/worktreeNode'; import { WorktreesNode } from './nodes/worktreesNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; -export class RepositoriesView extends ViewBase { +export class RepositoriesView extends ViewBase<'repositories', RepositoriesNode, RepositoriesViewConfig> { protected readonly configKey = 'repositories'; constructor(container: Container) { - super(container, 'gitlens.views.repositories', 'Repositories', 'repositoriesView'); + super(container, 'repositories', 'Repositories', 'repositoriesView'); } private _onDidChangeAutoRefresh = new EventEmitter(); @@ -52,10 +51,6 @@ export class RepositoriesView extends ViewBase this.setBranchesLayout(ViewBranchesLayout.List), + () => this.setBranchesLayout('list'), this, ), registerViewCommand( this.getQualifiedCommand('setBranchesLayoutToTree'), - () => this.setBranchesLayout(ViewBranchesLayout.Tree), + () => this.setBranchesLayout('tree'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToAuto'), - () => this.setFilesLayout(ViewFilesLayout.Auto), + () => this.setFilesLayout('auto'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToList'), - () => this.setFilesLayout(ViewFilesLayout.List), + () => this.setFilesLayout('list'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToTree'), - () => this.setFilesLayout(ViewFilesLayout.Tree), + () => this.setFilesLayout('tree'), this, ), registerViewCommand( @@ -255,13 +250,15 @@ export class RepositoriesView extends ViewBase n.branch !== undefined && n.branch.ref === branch.ref, { + return this.findNode((n: any) => n.branch?.ref === branch.ref, { allowPaging: true, maxDepth: 6, canTraverse: n => { @@ -286,7 +283,7 @@ export class RepositoriesView extends ViewBase n.branch !== undefined && n.branch.ref === branch.ref, { + return this.findNode((n: any) => n.branch?.ref === branch.ref, { allowPaging: true, maxDepth: 5, canTraverse: n => { @@ -314,7 +311,7 @@ export class RepositoriesView extends ViewBase n.commit !== undefined && n.commit.ref === commit.ref, { + return this.findNode((n: any) => n.commit?.ref === commit.ref, { allowPaging: true, maxDepth: 6, canTraverse: async n => { // Only search for commit nodes in the same repo within BranchNodes if (n instanceof RepositoriesNode) return true; - if (n instanceof BranchNode) { - if (n.id.startsWith(repoNodeId) && branches.includes(n.branch.name)) { - await n.loadMore({ until: commit.ref }); - return true; - } - } - if ( n instanceof RepositoryNode || n instanceof BranchesNode || n instanceof BranchOrTagFolderNode ) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; + } + + if (n instanceof BranchNode && n.repoPath === repoPath && branches.includes(n.branch.name)) { + await n.loadMore({ until: commit.ref }); + return true; } return false; @@ -365,13 +361,14 @@ export class RepositoriesView extends ViewBase b.split('/', 1)[0]); - return this.findNode((n: any) => n.commit !== undefined && n.commit.ref === commit.ref, { + return this.findNode((n: any) => n.commit?.ref === commit.ref, { allowPaging: true, maxDepth: 8, canTraverse: n => { @@ -379,15 +376,15 @@ export class RepositoriesView extends ViewBase + n instanceof ContributorNode && + n.contributor.username === username && + n.contributor.email === email && + n.contributor.name === name, { maxDepth: 2, canTraverse: n => { @@ -408,7 +409,7 @@ export class RepositoriesView extends ViewBase n.remote?.name === remote.name, { allowPaging: true, @@ -429,7 +430,7 @@ export class RepositoriesView extends ViewBase n.commit?.ref === stash.ref, { maxDepth: 3, canTraverse: n => { // Only search for stash nodes in the same repo within a StashesNode if (n instanceof RepositoriesNode) return true; if (n instanceof RepositoryNode || n instanceof StashesNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -458,9 +459,9 @@ export class RepositoriesView extends ViewBase n.tag !== undefined && n.tag.ref === tag.ref, { + return this.findNode((n: any) => n.tag?.ref === tag.ref, { allowPaging: true, maxDepth: 5, canTraverse: n => { @@ -468,7 +469,7 @@ export class RepositoriesView extends ViewBase n instanceof WorktreeNode && worktree.uri.toString() === url, { maxDepth: 2, canTraverse: n => { // Only search for worktree nodes in the same repo within WorktreesNode if (n instanceof RepositoriesNode) return true; if (n instanceof RepositoryNode || n instanceof WorktreesNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -508,13 +510,13 @@ export class RepositoriesView extends ViewBase { + async (_progress, token) => { const node = await this.findBranch(branch, token); if (node == null) return undefined; @@ -534,16 +536,14 @@ export class RepositoriesView extends ViewBase n instanceof BranchesNode && n.repoPath === repoPath, { maxDepth: 2, canTraverse: n => { // Only search for branches nodes in the same repo if (n instanceof RepositoriesNode) return true; if (n instanceof RepositoryNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -569,13 +569,13 @@ export class RepositoriesView extends ViewBase { + async (_progress, token) => { const node = await this.findCommit(commit, token); if (node == null) return undefined; @@ -601,7 +601,7 @@ export class RepositoriesView extends ViewBase { + async (_progress, token) => { const node = await this.findContributor(contributor, token); if (node == null) return undefined; @@ -627,7 +627,7 @@ export class RepositoriesView extends ViewBase { + async (_progress, token) => { const node = await this.findRemote(remote, token); if (node == null) return undefined; @@ -647,9 +647,7 @@ export class RepositoriesView extends ViewBase n instanceof RepositoryNode && n.repoPath === repoPath, { maxDepth: 1, canTraverse: n => n instanceof RepositoriesNode, }); @@ -673,13 +671,13 @@ export class RepositoriesView extends ViewBase { + async (_progress, token) => { const node = await this.findStash(stash, token); if (node !== undefined) { await this.reveal(node, options); @@ -699,16 +697,14 @@ export class RepositoriesView extends ViewBase n instanceof StashesNode && n.repoPath === repoPath, { maxDepth: 2, canTraverse: n => { // Only search for stashes nodes in the same repo if (n instanceof RepositoriesNode) return true; if (n instanceof RepositoryNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -734,13 +730,13 @@ export class RepositoriesView extends ViewBase { + async (_progress, token) => { const node = await this.findTag(tag, token); if (node == null) return undefined; @@ -760,16 +756,14 @@ export class RepositoriesView extends ViewBase n instanceof TagsNode && n.repoPath === repoPath, { maxDepth: 2, canTraverse: n => { // Only search for tags nodes in the same repo if (n instanceof RepositoriesNode) return true; if (n instanceof RepositoryNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -798,7 +792,7 @@ export class RepositoriesView extends ViewBase { + async (_progress, token) => { const node = await this.findWorktree(worktree, token); if (node == null) return undefined; @@ -818,16 +812,14 @@ export class RepositoriesView extends ViewBase n instanceof WorktreesNode && n.repoPath === repoPath, { maxDepth: 2, canTraverse: n => { // Only search for worktrees nodes in the same repo if (n instanceof RepositoriesNode) return true; if (n instanceof RepositoryNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -850,7 +842,7 @@ export class RepositoriesView extends ViewBase { +export class SearchAndCompareViewNode extends ViewNode<'search-compare', SearchAndCompareView> { protected override splatted = true; private comparePicker: ComparePickerNode | undefined; constructor(view: SearchAndCompareView) { - super(unknownGitUri, view); + super('search-compare', unknownGitUri, view); + } + + override dispose() { + super.dispose(); + disposeChildren(this._children); } private _children: (ComparePickerNode | CompareResultsNode | SearchResultsNode)[] | undefined; private get children(): (ComparePickerNode | CompareResultsNode | SearchResultsNode)[] { if (this._children == null) { - this._children = []; + const children = []; - // Get pinned searches & comparisons - const pinned = this.view.getPinned(); - if (pinned.length !== 0) { - this._children.push(...pinned); + // Get stored searches & comparisons + const stored = this.view.getStoredNodes(); + if (stored.length !== 0) { + children.push(...stored); } + + disposeChildren(this._children, children); + this._children = children; } return this._children; } + private set children(value: (ComparePickerNode | CompareResultsNode | SearchResultsNode)[] | undefined) { + if (this.children === value) return; - getChildren(): ViewNode[] { - if (this.children.length === 0) return []; + disposeChildren(this.children, value); + this._children = value; + } - this.view.message = undefined; + getChildren(): ViewNode[] { + const children = this.children; + if (children.length === 0) return []; - return this.children.sort((a, b) => (a.pinned ? -1 : 1) - (b.pinned ? -1 : 1) || b.order - a.order); + return children.sort((a, b) => b.order - a.order); } getTreeItem(): TreeItem { @@ -66,31 +80,26 @@ export class SearchAndCompareViewNode extends ViewNode { return item; } - addOrReplace(results: CompareResultsNode | SearchResultsNode, replace: boolean) { - if (this.children.includes(results)) return; - - if (replace) { - this.clear(); - } + addOrReplace(results: CompareResultsNode | SearchResultsNode) { + const children = [...this.children]; + if (children.includes(results)) return; - this.children.push(results); + children.push(results); + this.children = children; this.view.triggerNodeChange(); } @log() - clear(silent: boolean = false) { + async clear() { if (this.children.length === 0) return; this.removeComparePicker(true); - const index = this._children!.findIndex(c => !c.pinned); - if (index !== -1) { - this._children!.splice(index, this._children!.length); - } + this.children = []; - if (!silent) { - this.view.triggerNodeChange(); - } + await this.view.clearStorage(); + + this.view.triggerNodeChange(); } @log({ args: { 0: n => n.toString() } }) @@ -101,28 +110,35 @@ export class SearchAndCompareViewNode extends ViewNode { return; } - if (this.children.length === 0) return; + if (node instanceof CompareResultsNode || node instanceof SearchResultsNode) { + node.dismiss(); + } + + const children = [...this.children]; + if (children.length === 0) return; - const index = this.children.indexOf(node); + const index = children.indexOf(node); if (index === -1) return; - this.children.splice(index, 1); + children.splice(index, 1); + this.children = children; this.view.triggerNodeChange(); } @gate() @debug() - override async refresh() { - if (this.children.length === 0) return; + override async refresh(reset: boolean = false) { + const children = this.children; + if (children.length === 0) return; const promises: Promise[] = [ - ...filterMap(this.children, c => { - const result = c.refresh === undefined ? false : c.refresh(); + ...filterMap(children, c => { + const result = c.refresh?.(reset); return isPromise(result) ? result : undefined; }), ]; - await Promise.all(promises); + await Promise.allSettled(promises); } async compareWithSelected(repoPath?: string, ref?: string | StoredNamedRef) { @@ -139,12 +155,12 @@ export class SearchAndCompareViewNode extends ViewNode { } if (ref == null) { - const pick = await ReferencePicker.show( + const pick = await showReferencePicker( repoPath, `Compare ${this.getRefName(selectedRef.ref)} with`, - 'Choose a reference to compare with', + 'Choose a reference (branch, tag, etc) to compare with', { - allowEnteringRefs: true, + allowRevisions: true, picked: typeof selectedRef.ref === 'string' ? selectedRef.ref : selectedRef.ref.ref, // checkmarks: true, include: ReferencesQuickPickIncludes.BranchesAndTags | ReferencesQuickPickIncludes.HEAD, @@ -169,7 +185,7 @@ export class SearchAndCompareViewNode extends ViewNode { async selectForCompare(repoPath?: string, ref?: string | StoredNamedRef, options?: { prompt?: boolean }) { if (repoPath == null) { - repoPath = (await RepositoryPicker.getRepositoryOrShow('Compare'))?.path; + repoPath = (await getRepositoryOrShowPicker('Compare'))?.path; } if (repoPath == null) return; @@ -178,15 +194,16 @@ export class SearchAndCompareViewNode extends ViewNode { let prompt = options?.prompt ?? false; let ref2; if (ref == null) { - const pick = await ReferencePicker.show(repoPath, 'Compare', 'Choose a reference to compare', { - allowEnteringRefs: { ranges: true }, - // checkmarks: false, - include: - ReferencesQuickPickIncludes.BranchesAndTags | - ReferencesQuickPickIncludes.HEAD | - ReferencesQuickPickIncludes.WorkingTree, - sort: { branches: { current: true }, tags: {} }, - }); + const pick = await showReferencePicker( + repoPath, + 'Compare', + 'Choose a reference (branch, tag, etc) to compare', + { + allowRevisions: { ranges: true }, + include: ReferencesQuickPickIncludes.All, + sort: { branches: { current: true }, tags: {} }, + }, + ); if (pick == null) { await this.triggerChange(); @@ -195,11 +212,11 @@ export class SearchAndCompareViewNode extends ViewNode { ref = pick.ref; - if (GitRevision.isRange(ref)) { - const range = GitRevision.splitRange(ref); + if (isRevisionRange(ref)) { + const range = getRevisionRangeParts(ref); if (range != null) { - ref = range.ref1 || 'HEAD'; - ref2 = range.ref2 || 'HEAD'; + ref = range.left || 'HEAD'; + ref2 = range.right || 'HEAD'; } } @@ -211,12 +228,14 @@ export class SearchAndCompareViewNode extends ViewNode { repoPath: repoPath, ref: ref, }); - this.children.splice(0, 0, this.comparePicker); - void setContext(ContextKeys.ViewsCanCompare, true); - await this.triggerChange(); + const children = [...this.children]; + children.unshift(this.comparePicker); + this.children = children; + + void setContext('gitlens:views:canCompare', true); - await this.view.reveal(this.comparePicker, { focus: false, select: true }); + await this.triggerChange(); if (prompt) { await this.compareWithSelected(repoPath, ref2); @@ -225,16 +244,19 @@ export class SearchAndCompareViewNode extends ViewNode { private getRefName(ref: string | StoredNamedRef): string { return typeof ref === 'string' - ? GitRevision.shorten(ref, { strings: { working: 'Working Tree' } })! - : ref.label ?? GitRevision.shorten(ref.ref)!; + ? shortenRevision(ref, { strings: { working: 'Working Tree' } }) + : ref.label ?? shortenRevision(ref.ref); } private removeComparePicker(silent: boolean = false) { - void setContext(ContextKeys.ViewsCanCompare, false); + void setContext('gitlens:views:canCompare', false); if (this.comparePicker != null) { - const index = this.children.indexOf(this.comparePicker); + const children = [...this.children]; + const index = children.indexOf(this.comparePicker); if (index !== -1) { - this.children.splice(index, 1); + children.splice(index, 1); + this.children = children; + if (!silent) { void this.triggerChange(); } @@ -244,13 +266,19 @@ export class SearchAndCompareViewNode extends ViewNode { } } -export class SearchAndCompareView extends ViewBase { +export class SearchAndCompareView extends ViewBase< + 'searchAndCompare', + SearchAndCompareViewNode, + SearchAndCompareViewConfig +> { protected readonly configKey = 'searchAndCompare'; constructor(container: Container) { - super(container, 'gitlens.views.searchAndCompare', 'Search & Compare', 'searchAndCompareView'); + super(container, 'searchAndCompare', 'Search & Compare', 'searchAndCompareView'); + } - void setContext(ContextKeys.ViewsSearchAndCompareKeepResults, this.keepResults); + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; } protected getRoot() { @@ -261,7 +289,7 @@ export class SearchAndCompareView extends ViewBase this.clear(), this), + registerViewCommand(this.getQualifiedCommand('clear'), () => void this.clear(), this), registerViewCommand( this.getQualifiedCommand('copy'), () => executeCommand(Commands.ViewsCopy, this.activeSelection, this.selection), @@ -270,30 +298,22 @@ export class SearchAndCompareView extends ViewBase this.refresh(true), this), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToAuto'), - () => this.setFilesLayout(ViewFilesLayout.Auto), + () => this.setFilesLayout('auto'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToList'), - () => this.setFilesLayout(ViewFilesLayout.List), + () => this.setFilesLayout('list'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToTree'), - () => this.setFilesLayout(ViewFilesLayout.Tree), - this, - ), - registerViewCommand(this.getQualifiedCommand('setKeepResultsToOn'), () => this.setKeepResults(true), this), - registerViewCommand( - this.getQualifiedCommand('setKeepResultsToOff'), - () => this.setKeepResults(false), + () => this.setFilesLayout('tree'), this, ), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOff'), () => this.setShowAvatars(false), this), - registerViewCommand(this.getQualifiedCommand('pin'), this.pin, this), - registerViewCommand(this.getQualifiedCommand('unpin'), this.unpin, this), registerViewCommand(this.getQualifiedCommand('swapComparison'), this.swapComparison, this), registerViewCommand(this.getQualifiedCommand('selectForCompare'), () => this.selectForCompare()), registerViewCommand(this.getQualifiedCommand('compareWithSelected'), this.compareWithSelected, this), @@ -334,12 +354,8 @@ export class SearchAndCompareView extends ViewBase { + return this.addResultsNode( + () => + new CompareResultsNode( + this, + this.ensureRoot(), + repoPath, + typeof ref1 === 'string' ? { ref: ref1 } : ref1, + typeof ref2 === 'string' ? { ref: ref2 } : ref2, + ), + options?.reveal === false ? false : undefined, ); } @@ -402,54 +424,32 @@ export class SearchAndCompareView extends ViewBase new SearchResultsNode(this, this.root!, repoPath, search, labels, results), + reveal, + ); + } - const migratedPins = Object.create(null) as StoredPinnedItems; - let migrated = false; + getStoredNodes() { + const stored = this.container.storage.getWorkspace('views:searchAndCompare:pinned'); + if (stored == null) return []; const root = this.ensureRoot(); - const pins = Object.entries(savedPins) + const nodes = Object.entries(stored) .sort(([, a], [, b]) => (b.timestamp ?? 0) - (a.timestamp ?? 0)) - .map(([k, p]) => { + .map(([, p]) => { if (p.type === 'comparison') { - // Migrated any old keys (sha1) to new keys (md5) - const key = CompareResultsNode.getPinnableId(p.path, p.ref1.ref, p.ref2.ref); - if (k !== key) { - migrated = true; - migratedPins[key] = p; - } else { - migratedPins[k] = p; - } + restoreComparisonCheckedFiles(this, p.checkedFiles); return new CompareResultsNode( this, @@ -461,15 +461,6 @@ export class SearchAndCompareView extends ViewBase '') @@ -500,7 +494,7 @@ export class SearchAndCompareView extends ViewBase n instanceof RepositoryFolderNode && n.repoPath === repoPath, { maxDepth: 1, canTraverse: n => n instanceof SearchAndCompareViewNode || n instanceof RepositoryFolderNode, }); @@ -512,43 +506,41 @@ export class SearchAndCompareView extends ViewBase( + resultsNodeFn: () => T, + reveal: + | { + expand?: boolean | number; + focus?: boolean; + select?: boolean; + } + | false = { expand: true, focus: true, select: true }, + ): Promise { + if (!this.visible && reveal !== false) { await this.show(); } const root = this.ensureRoot(); - root.addOrReplace(results, !this.keepResults); - queueMicrotask(() => this.reveal(results, options)); + // Deferred creating the results node until the view is visible (otherwise we will hit a duplicate timing issue when storing the new node, but then loading it from storage during the view's initialization) + const resultsNode = resultsNodeFn(); + root.addOrReplace(resultsNode); + + if (reveal !== false) { + queueMicrotask(() => this.reveal(resultsNode, reveal)); + } + + return resultsNode; } private setFilesLayout(layout: ViewFilesLayout) { return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout); } - private setKeepResults(enabled: boolean) { - void this.container.storage.storeWorkspace('views:searchAndCompare:keepResults', enabled); - void setContext(ContextKeys.ViewsSearchAndCompareKeepResults, enabled); - } - private setShowAvatars(enabled: boolean) { return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled); } - private pin(node: CompareResultsNode | SearchResultsNode) { - if (!(node instanceof CompareResultsNode) && !(node instanceof SearchResultsNode)) return undefined; - - return node.pin(); - } - private setFilesFilter(node: ResultsFilesNode, filter: FilesQueryFilter | undefined) { if (!(node instanceof ResultsFilesNode)) return; @@ -560,10 +552,4 @@ export class SearchAndCompareView extends ViewBase { async getChildren(): Promise { if (this.children == null) { - const repositories = this.view.container.git.openRepositories; + let repositories = this.view.container.git.openRepositories; + if (configuration.get('views.collapseWorktreesWhenPossible')) { + const grouped = await groupRepositories(repositories); + repositories = [...grouped.keys()]; + } + if (repositories.length === 0) { - this.view.message = 'No stashes could be found.'; + this.view.message = this.view.container.git.isDiscoveringRepositories + ? 'Loading stashes...' + : 'No stashes could be found.'; return []; } @@ -88,17 +87,21 @@ export class StashesViewNode extends RepositoriesSubscribeableNode { +export class StashesView extends ViewBase<'stashes', StashesViewNode, StashesViewConfig> { protected readonly configKey = 'stashes'; constructor(container: Container) { - super(container, 'gitlens.views.stashes', 'Stashes', 'stashesView'); + super(container, 'stashes', 'Stashes', 'stashesView'); } override get canReveal(): boolean { return this.config.reveal || !configuration.get('views.repositories.showStashes'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + protected getRoot() { return new StashesViewNode(this); } @@ -122,17 +125,17 @@ export class StashesView extends ViewBase { ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToAuto'), - () => this.setFilesLayout(ViewFilesLayout.Auto), + () => this.setFilesLayout('auto'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToList'), - () => this.setFilesLayout(ViewFilesLayout.List), + () => this.setFilesLayout('list'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToTree'), - () => this.setFilesLayout(ViewFilesLayout.Tree), + () => this.setFilesLayout('tree'), this, ), ]; @@ -148,7 +151,9 @@ export class StashesView extends ViewBase { !configuration.changed(e, 'defaultDateSource') && !configuration.changed(e, 'defaultDateStyle') && !configuration.changed(e, 'defaultGravatarsStyle') && - !configuration.changed(e, 'defaultTimeFormat') + !configuration.changed(e, 'defaultTimeFormat') && + !configuration.changed(e, 'sortRepositoriesBy') && + !configuration.changed(e, 'views.collapseWorktreesWhenPossible') ) { return false; } @@ -156,58 +161,16 @@ export class StashesView extends ViewBase { return true; } - protected override onSelectionChanged(e: TreeViewSelectionChangeEvent) { - super.onSelectionChanged(e); - this.notifySelections(); - } - - protected override onVisibilityChanged(e: TreeViewVisibilityChangeEvent) { - super.onVisibilityChanged(e); - if (e.visible) { - this.notifySelections(); - } - } - - private notifySelections() { - const node = this.selection?.[0]; - if (node == null) return; - - if (node instanceof StashNode || node instanceof StashFileNode) { - this.container.events.fire( - 'commit:selected', - { - commit: node.commit, - pin: false, - preserveFocus: true, - preserveVisibility: true, - }, - { source: this.id }, - ); - } - - if (node instanceof StashFileNode) { - this.container.events.fire( - 'file:selected', - { - uri: node.uri, - preserveFocus: true, - preserveVisibility: true, - }, - { source: this.id }, - ); - } - } - findStash(stash: GitStashReference, token?: CancellationToken) { - const repoNodeId = RepositoryNode.getId(stash.repoPath); + const { repoPath } = stash; - return this.findNode(StashNode.getId(stash.repoPath, stash.ref), { + return this.findNode((n: any) => n.commit?.ref === stash.ref, { maxDepth: 2, canTraverse: n => { if (n instanceof StashesViewNode) return true; if (n instanceof StashesRepositoryNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -221,7 +184,7 @@ export class StashesView extends ViewBase { repoPath: string, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, ) { - const node = await this.findNode(RepositoryFolderNode.getId(repoPath), { + const node = await this.findNode(n => n instanceof RepositoryFolderNode && n.repoPath === repoPath, { maxDepth: 1, canTraverse: n => n instanceof StashesViewNode || n instanceof RepositoryFolderNode, }); @@ -245,10 +208,13 @@ export class StashesView extends ViewBase { return window.withProgress( { location: ProgressLocation.Notification, - title: `Revealing ${GitReference.toString(stash, { icon: false, quoted: true })} in the side bar...`, + title: `Revealing ${getReferenceLabel(stash, { + icon: false, + quoted: true, + })} in the side bar...`, cancellable: true, }, - async (progress, token) => { + async (_progress, token) => { const node = await this.findStash(stash, token); if (node == null) return undefined; diff --git a/src/views/tagsView.ts b/src/views/tagsView.ts index 9a1a7cab22654..4e7b07386432d 100644 --- a/src/views/tagsView.ts +++ b/src/views/tagsView.ts @@ -1,21 +1,21 @@ import type { CancellationToken, ConfigurationChangeEvent, Disposable } from 'vscode'; import { ProgressLocation, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; -import type { TagsViewConfig } from '../configuration'; -import { configuration, ViewBranchesLayout, ViewFilesLayout } from '../configuration'; -import { Commands } from '../constants'; +import type { TagsViewConfig, ViewBranchesLayout, ViewFilesLayout } from '../config'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { GitUri } from '../git/gitUri'; import type { GitTagReference } from '../git/models/reference'; -import { GitReference } from '../git/models/reference'; +import { getReferenceLabel } from '../git/models/reference'; import type { RepositoryChangeEvent } from '../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; -import { executeCommand } from '../system/command'; +import { groupRepositories, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import { gate } from '../system/decorators/gate'; +import { executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { BranchOrTagFolderNode } from './nodes/branchOrTagFolderNode'; -import { RepositoryNode } from './nodes/repositoryNode'; import { TagsNode } from './nodes/tagsNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; import { ViewBase } from './viewBase'; import { registerViewCommand } from './viewCommands'; @@ -36,9 +36,16 @@ export class TagsRepositoryNode extends RepositoryFolderNode export class TagsViewNode extends RepositoriesSubscribeableNode { async getChildren(): Promise { if (this.children == null) { - const repositories = this.view.container.git.openRepositories; + let repositories = this.view.container.git.openRepositories; + if (configuration.get('views.collapseWorktreesWhenPossible')) { + const grouped = await groupRepositories(repositories); + repositories = [...grouped.keys()]; + } + if (repositories.length === 0) { - this.view.message = 'No tags could be found.'; + this.view.message = this.view.container.git.isDiscoveringRepositories + ? 'Loading tags...' + : 'No tags could be found.'; return []; } @@ -81,17 +88,21 @@ export class TagsViewNode extends RepositoriesSubscribeableNode { +export class TagsView extends ViewBase<'tags', TagsViewNode, TagsViewConfig> { protected readonly configKey = 'tags'; constructor(container: Container) { - super(container, 'gitlens.views.tags', 'Tags', 'tagsView'); + super(container, 'tags', 'Tags', 'tagsView'); } override get canReveal(): boolean { return this.config.reveal || !configuration.get('views.repositories.showTags'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + protected getRoot() { return new TagsViewNode(this); } @@ -113,29 +124,21 @@ export class TagsView extends ViewBase { }, this, ), - registerViewCommand( - this.getQualifiedCommand('setLayoutToList'), - () => this.setLayout(ViewBranchesLayout.List), - this, - ), - registerViewCommand( - this.getQualifiedCommand('setLayoutToTree'), - () => this.setLayout(ViewBranchesLayout.Tree), - this, - ), + registerViewCommand(this.getQualifiedCommand('setLayoutToList'), () => this.setLayout('list'), this), + registerViewCommand(this.getQualifiedCommand('setLayoutToTree'), () => this.setLayout('tree'), this), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToAuto'), - () => this.setFilesLayout(ViewFilesLayout.Auto), + () => this.setFilesLayout('auto'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToList'), - () => this.setFilesLayout(ViewFilesLayout.List), + () => this.setFilesLayout('list'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToTree'), - () => this.setFilesLayout(ViewFilesLayout.Tree), + () => this.setFilesLayout('tree'), this, ), registerViewCommand(this.getQualifiedCommand('setShowAvatarsOn'), () => this.setShowAvatars(true), this), @@ -154,7 +157,9 @@ export class TagsView extends ViewBase { !configuration.changed(e, 'defaultDateStyle') && !configuration.changed(e, 'defaultGravatarsStyle') && !configuration.changed(e, 'defaultTimeFormat') && - !configuration.changed(e, 'sortTagsBy') + !configuration.changed(e, 'sortTagsBy') && + !configuration.changed(e, 'sortRepositoriesBy') && + !configuration.changed(e, 'views.collapseWorktreesWhenPossible') ) { return false; } @@ -163,7 +168,7 @@ export class TagsView extends ViewBase { } findTag(tag: GitTagReference, token?: CancellationToken) { - const repoNodeId = RepositoryNode.getId(tag.repoPath); + const { repoPath } = tag; return this.findNode((n: any) => n.tag?.ref === tag.ref, { allowPaging: true, @@ -172,7 +177,7 @@ export class TagsView extends ViewBase { if (n instanceof TagsViewNode) return true; if (n instanceof TagsRepositoryNode || n instanceof BranchOrTagFolderNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -186,7 +191,7 @@ export class TagsView extends ViewBase { repoPath: string, options?: { select?: boolean; focus?: boolean; expand?: boolean | number }, ) { - const node = await this.findNode(RepositoryFolderNode.getId(repoPath), { + const node = await this.findNode(n => n instanceof RepositoryFolderNode && n.repoPath === repoPath, { maxDepth: 1, canTraverse: n => n instanceof TagsViewNode || n instanceof RepositoryFolderNode, }); @@ -210,10 +215,13 @@ export class TagsView extends ViewBase { return window.withProgress( { location: ProgressLocation.Notification, - title: `Revealing ${GitReference.toString(tag, { icon: false, quoted: true })} in the side bar...`, + title: `Revealing ${getReferenceLabel(tag, { + icon: false, + quoted: true, + })} in the side bar...`, cancellable: true, }, - async (progress, token) => { + async (_progress, token) => { const node = await this.findTag(tag, token); if (node == null) return undefined; diff --git a/src/views/viewBase.ts b/src/views/viewBase.ts index a7c983e3df2fb..3ee6d7471ec64 100644 --- a/src/views/viewBase.ts +++ b/src/views/viewBase.ts @@ -2,20 +2,25 @@ import type { CancellationToken, ConfigurationChangeEvent, Event, + TreeCheckboxChangeEvent, TreeDataProvider, TreeItem, TreeView, TreeViewExpansionEvent, TreeViewSelectionChangeEvent, TreeViewVisibilityChangeEvent, + ViewBadge, } from 'vscode'; import { Disposable, EventEmitter, MarkdownString, TreeItemCollapsibleState, window } from 'vscode'; import type { BranchesViewConfig, CommitsViewConfig, ContributorsViewConfig, + DraftsViewConfig, FileHistoryViewConfig, + LaunchpadViewConfig, LineHistoryViewConfig, + PullRequestViewConfig, RemotesViewConfig, RepositoriesViewConfig, SearchAndCompareViewConfig, @@ -24,66 +29,151 @@ import type { ViewsCommonConfig, ViewsConfigKeys, WorktreesViewConfig, -} from '../configuration'; -import { configuration, viewsCommonConfigKeys, viewsConfigKeys } from '../configuration'; +} from '../config'; +import { viewsCommonConfigKeys, viewsConfigKeys } from '../config'; +import type { TreeViewCommandSuffixesByViewType } from '../constants.commands'; +import type { TreeViewIds, TreeViewTypes } from '../constants.views'; import type { Container } from '../container'; -import { Logger } from '../logger'; -import { getLogScope } from '../logScope'; -import { executeCommand } from '../system/command'; import { debug, log } from '../system/decorators/log'; import { once } from '../system/event'; import { debounce } from '../system/function'; +import { Logger } from '../system/logger'; +import { getLogScope } from '../system/logger.scope'; import { cancellable, isPromise } from '../system/promise'; -import type { TrackedUsageFeatures } from '../usageTracker'; +import { executeCoreCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import type { TrackedUsageFeatures } from '../telemetry/usageTracker'; import type { BranchesView } from './branchesView'; import type { CommitsView } from './commitsView'; import type { ContributorsView } from './contributorsView'; +import type { DraftsView } from './draftsView'; import type { FileHistoryView } from './fileHistoryView'; +import type { LaunchpadView } from './launchpadView'; import type { LineHistoryView } from './lineHistoryView'; -import type { PageableViewNode, ViewNode } from './nodes/viewNode'; -import { isPageableViewNode } from './nodes/viewNode'; +import type { PageableViewNode, ViewNode } from './nodes/abstract/viewNode'; +import { isPageableViewNode } from './nodes/abstract/viewNode'; +import type { PullRequestView } from './pullRequestView'; import type { RemotesView } from './remotesView'; import type { RepositoriesView } from './repositoriesView'; import type { SearchAndCompareView } from './searchAndCompareView'; import type { StashesView } from './stashesView'; import type { TagsView } from './tagsView'; +import type { WorkspacesView } from './workspacesView'; import type { WorktreesView } from './worktreesView'; export type View = | BranchesView | CommitsView | ContributorsView + | DraftsView | FileHistoryView + | LaunchpadView | LineHistoryView + | PullRequestView | RemotesView | RepositoriesView | SearchAndCompareView | StashesView | TagsView + | WorkspacesView | WorktreesView; -export type ViewsWithCommits = Exclude; -export type ViewsWithRepositoryFolders = Exclude; + +// prettier-ignore +type TreeViewByType = { + [T in TreeViewTypes]: T extends 'branches' + ? BranchesView + : T extends 'commits' + ? CommitsView + : T extends 'contributors' + ? ContributorsView + : T extends 'drafts' + ? DraftsView + : T extends 'fileHistory' + ? FileHistoryView + : T extends 'launchpad' + ? LaunchpadView + : T extends 'lineHistory' + ? LineHistoryView + : T extends 'pullRequest' + ? PullRequestView + : T extends 'remotes' + ? RemotesView + : T extends 'repositories' + ? RepositoriesView + : T extends 'searchAndCompare' + ? SearchAndCompareView + : T extends 'stashes' + ? StashesView + : T extends 'tags' + ? TagsView + : T extends 'workspaces' + ? WorkspacesView + : T extends 'worktrees' + ? WorktreesView + : View; +}; + +export type ViewsWithBranches = BranchesView | CommitsView | RemotesView | RepositoriesView | WorkspacesView; +export type ViewsWithBranchesNode = BranchesView | RepositoriesView | WorkspacesView; +export type ViewsWithCommits = Exclude; +export type ViewsWithContributors = ContributorsView | RepositoriesView | WorkspacesView; +export type ViewsWithContributorsNode = ContributorsView | RepositoriesView | WorkspacesView; +export type ViewsWithRemotes = RemotesView | RepositoriesView | WorkspacesView; +export type ViewsWithRemotesNode = RemotesView | RepositoriesView | WorkspacesView; +export type ViewsWithRepositories = RepositoriesView | WorkspacesView; +export type ViewsWithRepositoriesNode = RepositoriesView | WorkspacesView; +export type ViewsWithRepositoryFolders = Exclude< + View, + DraftsView | FileHistoryView | LaunchpadView | LineHistoryView | PullRequestView | RepositoriesView | WorkspacesView +>; +export type ViewsWithStashes = StashesView | ViewsWithCommits; +export type ViewsWithStashesNode = RepositoriesView | StashesView | WorkspacesView; +export type ViewsWithTags = RepositoriesView | TagsView | WorkspacesView; +export type ViewsWithTagsNode = RepositoriesView | TagsView | WorkspacesView; +export type ViewsWithWorkingTree = RepositoriesView | WorktreesView | WorkspacesView; +export type ViewsWithWorktrees = RepositoriesView | WorktreesView | WorkspacesView; +export type ViewsWithWorktreesNode = RepositoriesView | WorktreesView | WorkspacesView; export interface TreeViewNodeCollapsibleStateChangeEvent extends TreeViewExpansionEvent { state: TreeItemCollapsibleState; } export abstract class ViewBase< - RootNode extends ViewNode, - ViewConfig extends - | BranchesViewConfig - | ContributorsViewConfig - | FileHistoryViewConfig - | CommitsViewConfig - | LineHistoryViewConfig - | RemotesViewConfig - | RepositoriesViewConfig - | SearchAndCompareViewConfig - | StashesViewConfig - | TagsViewConfig - | WorktreesViewConfig, -> implements TreeDataProvider, Disposable + Type extends TreeViewTypes, + RootNode extends ViewNode, + ViewConfig extends + | BranchesViewConfig + | CommitsViewConfig + | ContributorsViewConfig + | DraftsViewConfig + | FileHistoryViewConfig + | LaunchpadViewConfig + | LineHistoryViewConfig + | PullRequestViewConfig + | RemotesViewConfig + | RepositoriesViewConfig + | SearchAndCompareViewConfig + | StashesViewConfig + | TagsViewConfig + | WorktreesViewConfig, + > + implements TreeDataProvider, Disposable { + is(type: T): this is TreeViewByType[T] { + return this.type === (type as unknown as Type); + } + + isAny(...types: T): this is TreeViewByType[T[number]] { + return types.includes(this.type as unknown as T[number]); + } + + get id(): TreeViewIds { + return `gitlens.views.${this.type}`; + } + + protected _onDidInitialize = new EventEmitter(); + private initialized = false; + protected _onDidChangeTreeData = new EventEmitter(); get onDidChangeTreeData(): Event { return this._onDidChangeTreeData.event; @@ -104,6 +194,11 @@ export abstract class ViewBase< return this._onDidChangeNodeCollapsibleState.event; } + private _onDidChangeNodesCheckedState = new EventEmitter>(); + get onDidChangeNodesCheckedState(): Event> { + return this._onDidChangeNodesCheckedState.event; + } + protected disposables: Disposable[] = []; protected root: RootNode | undefined; protected tree: TreeView | undefined; @@ -112,7 +207,7 @@ export abstract class ViewBase< constructor( public readonly container: Container, - public readonly id: `gitlens.views.${ViewsConfigKeys}`, + public readonly type: Type, public readonly name: string, private readonly trackingFeature: TrackedUsageFeatures, ) { @@ -140,7 +235,7 @@ export abstract class ViewBase< } const getTreeItemFn = this.getTreeItem; - this.getTreeItem = async function (this: ViewBase, node: ViewNode) { + this.getTreeItem = async function (this: ViewBase, node: ViewNode) { const item = await getTreeItemFn.apply(this, [node]); if (node.resolveTreeItem == null) { @@ -152,11 +247,12 @@ export abstract class ViewBase< const resolveTreeItemFn = this.resolveTreeItem; this.resolveTreeItem = async function ( - this: ViewBase, + this: ViewBase, item: TreeItem, node: ViewNode, + token: CancellationToken, ) { - item = await resolveTreeItemFn.apply(this, [item, node]); + item = await resolveTreeItemFn.apply(this, [item, node, token]); addDebuggingInfo(item, node, node.getParent()); @@ -183,10 +279,7 @@ export abstract class ViewBase< } get canSelectMany(): boolean { - return ( - this.container.prereleaseOrDebugging && - configuration.get('views.experimental.multiSelect.enabled', undefined, false) - ); + return false; } private _nodeState: ViewNodeState | undefined; @@ -212,6 +305,16 @@ export abstract class ViewBase< return false; } + + get badge(): ViewBadge | undefined { + return this.tree?.badge; + } + set badge(value: ViewBadge | undefined) { + if (this.tree != null) { + this.tree.badge = value; + } + } + private _title: string | undefined; get title(): string | undefined { return this._title; @@ -245,8 +348,8 @@ export abstract class ViewBase< } } - getQualifiedCommand(command: string) { - return `${this.id}.${command}`; + getQualifiedCommand(command: TreeViewCommandSuffixesByViewType) { + return `gitlens.views.${this.type}.${command}` as const; } protected abstract getRoot(): RootNode; @@ -258,7 +361,7 @@ export abstract class ViewBase< } protected initialize(options: { canSelectMany?: boolean; showCollapseAll?: boolean } = {}) { - this.tree = window.createTreeView>(this.id, { + this.tree = window.createTreeView(this.id, { ...options, treeDataProvider: this, }); @@ -272,10 +375,22 @@ export abstract class ViewBase< this.tree, this.tree.onDidChangeSelection(debounce(this.onSelectionChanged, 250), this), this.tree.onDidChangeVisibility(debounce(this.onVisibilityChanged, 250), this), + this.tree.onDidChangeCheckboxState(this.onCheckboxStateChanged, this), this.tree.onDidCollapseElement(this.onElementCollapsed, this), this.tree.onDidExpandElement(this.onElementExpanded, this), ); - this._title = this.tree.title; + + if (this._title != null) { + this.tree.title = this._title; + } else { + this._title = this.tree.title; + } + if (this._description != null) { + this.tree.description = this._description; + } + if (this._message != null) { + this.tree.message = this._message; + } } protected ensureRoot(force: boolean = false) { @@ -290,7 +405,22 @@ export abstract class ViewBase< if (node != null) return node.getChildren(); const root = this.ensureRoot(); - return root.getChildren(); + const children = root.getChildren(); + if (!this.initialized) { + if (isPromise(children)) { + void children.then(() => { + if (!this.initialized) { + this.initialized = true; + setTimeout(() => this._onDidInitialize.fire(), 1); + } + }); + } else { + this.initialized = true; + setTimeout(() => this._onDidInitialize.fire(), 1); + } + } + + return children; } getParent(node: ViewNode): ViewNode | undefined { @@ -301,8 +431,8 @@ export abstract class ViewBase< return node.getTreeItem(); } - resolveTreeItem(item: TreeItem, node: ViewNode): TreeItem | Promise { - return node.resolveTreeItem?.(item) ?? item; + resolveTreeItem(item: TreeItem, node: ViewNode, token: CancellationToken): TreeItem | Promise { + return node.resolveTreeItem?.(item, token) ?? item; } protected onElementCollapsed(e: TreeViewExpansionEvent) { @@ -313,8 +443,24 @@ export abstract class ViewBase< this._onDidChangeNodeCollapsibleState.fire({ ...e, state: TreeItemCollapsibleState.Expanded }); } + protected onCheckboxStateChanged(e: TreeCheckboxChangeEvent) { + try { + for (const [node, state] of e.items) { + if (node.id == null) { + debugger; + throw new Error('Id is required for checkboxes'); + } + + node.storeState('checked', state, true); + } + } finally { + this._onDidChangeNodesCheckedState.fire(e); + } + } + protected onSelectionChanged(e: TreeViewSelectionChangeEvent) { this._onDidChangeSelection.fire(e); + this.notifySelections(); } protected onVisibilityChanged(e: TreeViewVisibilityChangeEvent) { @@ -323,6 +469,45 @@ export abstract class ViewBase< } this._onDidChangeVisibility.fire(e); + if (e.visible) { + this.notifySelections(); + } + } + + private notifySelections() { + const node = this.selection?.[0]; + if (node == null) return; + + if ( + node.is('commit') || + node.is('stash') || + node.is('file-commit') || + node.is('commit-file') || + node.is('stash-file') + ) { + this.container.events.fire( + 'commit:selected', + { + commit: node.commit, + interaction: 'passive', + preserveFocus: true, + preserveVisibility: true, + }, + { source: this.id }, + ); + } + + if (node.is('file-commit') || node.is('commit-file') || node.is('stash-file')) { + this.container.events.fire( + 'file:selected', + { + uri: node.uri, + preserveFocus: true, + preserveVisibility: true, + }, + { source: this.id }, + ); + } } get activeSelection(): ViewNode | undefined { @@ -342,55 +527,32 @@ export abstract class ViewBase< return this.tree?.visible ?? false; } - async findNode( - id: string, - options?: { - allowPaging?: boolean; - canTraverse?: (node: ViewNode) => boolean | Promise; - maxDepth?: number; - token?: CancellationToken; - }, - ): Promise; - async findNode( - predicate: (node: ViewNode) => boolean, - options?: { - allowPaging?: boolean; - canTraverse?: (node: ViewNode) => boolean | Promise; - maxDepth?: number; - token?: CancellationToken; - }, - ): Promise; - @log['findNode']>({ + @log['findNode']>({ args: { - 0: predicate => (typeof predicate === 'string' ? predicate : ''), + 0: '', 1: opts => `options=${JSON.stringify({ ...opts, canTraverse: undefined, token: undefined })}`, }, }) async findNode( - predicate: string | ((node: ViewNode) => boolean), - { - allowPaging = false, - canTraverse, - maxDepth = 2, - token, - }: { + predicate: (node: ViewNode) => boolean, + options?: { allowPaging?: boolean; canTraverse?: (node: ViewNode) => boolean | Promise; maxDepth?: number; token?: CancellationToken; - } = {}, + }, ): Promise { const scope = getLogScope(); - async function find(this: ViewBase) { + async function find(this: ViewBase) { try { const node = await this.findNodeCoreBFS( - typeof predicate === 'string' ? n => n.id === predicate : predicate, + predicate, this.ensureRoot(), - allowPaging, - canTraverse, - maxDepth, - token, + options?.allowPaging ?? false, + options?.canTraverse, + options?.maxDepth ?? 2, + options?.token, ); return node; @@ -400,12 +562,14 @@ export abstract class ViewBase< } } - if (this.root != null) return find.call(this); + if (this.initialized) return find.call(this); // If we have no root (e.g. never been initialized) force it so the tree will load properly - await this.show({ preserveFocus: true }); + void this.show({ preserveFocus: true }); // Since we have to show the view, give the view time to load and let the callstack unwind before we try to find the node - return new Promise(resolve => setTimeout(() => resolve(find.call(this)), 100)); + return new Promise(resolve => + once(this._onDidInitialize.event)(() => resolve(find.call(this)), this), + ); } private async findNodeCoreBFS( @@ -464,7 +628,7 @@ export abstract class ViewBase< await this.loadMoreNodeChildren(node, defaultPageSize); - pagedChildren = await cancellable(Promise.resolve(node.getChildren()), token ?? 60000, { + pagedChildren = await cancellable(Promise.resolve(node.getChildren()), 60000, token, { onDidCancel: resolve => resolve([]), }); @@ -525,7 +689,7 @@ export abstract class ViewBase< this.triggerNodeChange(); } - @debug['refreshNode']>({ args: { 0: n => n.toString() } }) + @debug['refreshNode']>({ args: { 0: n => n.toString() } }) async refreshNode(node: ViewNode, reset: boolean = false, force: boolean = false) { const cancel = await node.refresh?.(reset); if (!force && cancel === true) return; @@ -533,7 +697,7 @@ export abstract class ViewBase< this.triggerNodeChange(node); } - @log['reveal']>({ args: { 0: n => n.toString() } }) + @log['reveal']>({ args: { 0: n => n.toString() } }) async reveal( node: ViewNode, options?: { @@ -556,7 +720,7 @@ export abstract class ViewBase< const scope = getLogScope(); try { - void (await executeCommand(`${this.id}.focus`, options)); + void (await executeCoreCommand(`${this.id}.focus`, options)); } catch (ex) { Logger.error(ex, scope); } @@ -567,7 +731,7 @@ export abstract class ViewBase< return this._lastKnownLimits.get(node.id); } - @debug['loadMoreNodeChildren']>({ + @debug['loadMoreNodeChildren']>({ args: { 0: n => n.toString(), 2: n => n?.toString() }, }) async loadMoreNodeChildren( @@ -584,7 +748,7 @@ export abstract class ViewBase< this._lastKnownLimits.set(node.id, node.limit); } - @debug['resetNodeLastKnownLimit']>({ + @debug['resetNodeLastKnownLimit']>({ args: { 0: n => n.toString() }, singleLine: true, }) @@ -592,7 +756,7 @@ export abstract class ViewBase< this._lastKnownLimits.delete(node.id); } - @debug['triggerNodeChange']>({ args: { 0: n => n?.toString() } }) + @debug['triggerNodeChange']>({ args: { 0: n => n?.toString() } }) triggerNodeChange(node?: ViewNode) { // Since the root node won't actually refresh, force everything this._onDidChangeTreeData.fire(node != null && node !== this.root ? node : undefined); @@ -605,6 +769,7 @@ export abstract class ViewBase< if (this._config == null) { const cfg = { ...configuration.get('views') }; for (const view of viewsConfigKeys) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete cfg[view]; } @@ -616,42 +781,168 @@ export abstract class ViewBase< return this._config; } + + // NOTE: @eamodio uncomment to track node leaks + // private _nodeTracking = new Map(); + // private registry = new FinalizationRegistry(uuid => { + // const id = this._nodeTracking.get(uuid); + + // Logger.log(`@@@ ${this.type} Finalizing [${uuid}]:${id}`); + + // this._nodeTracking.delete(uuid); + + // if (id != null) { + // const c = count(this._nodeTracking.values(), v => v === id); + // Logger.log(`@@@ ${this.type} [${padLeft(String(c), 3)}] ${id}`); + // } + // }); + + // registerNode(node: ViewNode) { + // const uuid = node.uuid; + + // Logger.log(`@@@ ${this.type}.registerNode [${uuid}]:${node.id}`); + + // this._nodeTracking.set(uuid, node.id); + // this.registry.register(node, uuid); + // } + + // unregisterNode(node: ViewNode) { + // const uuid = node.uuid; + + // Logger.log(`@@@ ${this.type}.unregisterNode [${uuid}]:${node.id}`); + + // this._nodeTracking.delete(uuid); + // this.registry.unregister(node); + // } + + // private _timer = setInterval(() => { + // const counts = new Map(); + // for (const value of this._nodeTracking.values()) { + // const count = counts.get(value) ?? 0; + // counts.set(value, count + 1); + // } + + // let total = 0; + // for (const [id, count] of counts) { + // if (count > 1) { + // Logger.log(`@@@ ${this.type} [${padLeft(String(count), 3)}] ${id}`); + // } + // total += count; + // } + + // Logger.log(`@@@ ${this.type} total=${total}`); + // }, 10000); } export class ViewNodeState implements Disposable { - private _state: Map> | undefined; + private _store: Map> | undefined; + private _stickyStore: Map> | undefined; dispose() { this.reset(); + + this._stickyStore?.clear(); + this._stickyStore = undefined; } reset() { - this._state?.clear(); - this._state = undefined; + this._store?.clear(); + this._store = undefined; + } + + delete(prefix: string, key: string): void { + for (const store of [this._store, this._stickyStore]) { + if (store == null) continue; + + for (const [id, map] of store) { + if (id.startsWith(prefix)) { + map.delete(key); + if (map.size === 0) { + store.delete(id); + } + } + } + } } deleteState(id: string, key?: string): void { if (key == null) { - this._state?.delete(id); + this._store?.delete(id); + this._stickyStore?.delete(id); } else { - this._state?.get(id)?.delete(key); + for (const store of [this._store, this._stickyStore]) { + if (store == null) continue; + + const map = store.get(id); + if (map == null) continue; + + map.delete(key); + if (map.size === 0) { + store.delete(id); + } + } } } + get(prefix: string, key: string): Map { + const maps = new Map(); + + for (const store of [this._store, this._stickyStore]) { + if (store == null) continue; + + for (const [id, map] of store) { + if (id.startsWith(prefix) && map.has(key)) { + maps.set(id, map.get(key) as T); + } + } + } + + return maps; + } + getState(id: string, key: string): T | undefined { - return this._state?.get(id)?.get(key) as T | undefined; + return (this._stickyStore?.get(id)?.get(key) ?? this._store?.get(id)?.get(key)) as T | undefined; } - storeState(id: string, key: string, value: T): void { - if (this._state == null) { - this._state = new Map(); + storeState(id: string, key: string, value: T, sticky?: boolean): void { + let store; + if (sticky) { + if (this._stickyStore == null) { + this._stickyStore = new Map(); + } + store = this._stickyStore; + } else { + if (this._store == null) { + this._store = new Map(); + } + store = this._store; } - const state = this._state.get(id); + const state = store.get(id); if (state != null) { state.set(key, value); } else { - this._state.set(id, new Map([[key, value]])); + store.set(id, new Map([[key, value]])); + } + } +} + +export function disposeChildren(oldChildren: ViewNode[] | undefined, newChildren?: ViewNode[]) { + if (!oldChildren?.length) return; + + const children = newChildren?.length ? oldChildren.filter(c => !newChildren.includes(c)) : [...oldChildren]; + if (!children.length) return; + + if (children.length > 1000) { + // Defer the disposals to avoid impacting the treeview's rendering + setTimeout(() => { + for (const child of children) { + child.dispose(); + } + }, 500); + } else { + for (const child of children) { + child.dispose(); } } } diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 8238680a9c33b..42910011ebe9e 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -1,17 +1,17 @@ +import { getTempFile } from '@env/platform'; import type { Disposable, TextDocumentShowOptions } from 'vscode'; -import { env, Uri, window } from 'vscode'; +import { env, Uri, window, workspace } from 'vscode'; import type { CreatePullRequestActionContext, OpenPullRequestActionContext } from '../api/gitlens'; -import type { - DiffWithCommandArgs, - DiffWithPreviousCommandArgs, - DiffWithWorkingCommandArgs, - OpenFileAtRevisionCommandArgs, -} from '../commands'; -import { configuration, FileAnnotationType, ViewShowBranchComparison } from '../configuration'; -import { Commands, ContextKeys, CoreCommands, CoreGitCommands } from '../constants'; +import type { DiffWithCommandArgs } from '../commands/diffWith'; +import type { DiffWithPreviousCommandArgs } from '../commands/diffWithPrevious'; +import type { DiffWithWorkingCommandArgs } from '../commands/diffWithWorking'; +import type { OpenFileAtRevisionCommandArgs } from '../commands/openFileAtRevision'; +import type { OpenOnRemoteCommandArgs } from '../commands/openOnRemote'; +import type { ViewShowBranchComparison } from '../config'; +import { GlyphChars } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; -import { setContext } from '../context'; -import { browseAtRevision } from '../git/actions'; +import { browseAtRevision, executeGitCommand } from '../git/actions'; import * as BranchActions from '../git/actions/branch'; import * as CommitActions from '../git/actions/commit'; import * as ContributorActions from '../git/actions/contributor'; @@ -21,8 +21,22 @@ import * as StashActions from '../git/actions/stash'; import * as TagActions from '../git/actions/tag'; import * as WorktreeActions from '../git/actions/worktree'; import { GitUri } from '../git/gitUri'; -import type { GitStashReference } from '../git/models/reference'; -import { GitReference, GitRevision } from '../git/models/reference'; +import { deletedOrMissing } from '../git/models/constants'; +import { matchContributor } from '../git/models/contributor'; +import { + ensurePullRequestRefs, + getComparisonRefsForPullRequest, + getOpenedPullRequestRepo, + getRepositoryIdentityForPullRequest, +} from '../git/models/pullRequest'; +import { createReference, shortenRevision } from '../git/models/reference'; +import { RemoteResourceType } from '../git/models/remoteResource'; +import { showPatchesView } from '../plus/drafts/actions'; +import { getPullRequestBranchDeepLink } from '../plus/launchpad/launchpadProvider'; +import { showContributorsPicker } from '../quickpicks/contributorsPicker'; +import { filterMap, mapAsync } from '../system/array'; +import { log } from '../system/decorators/log'; +import { partial, sequentialize } from '../system/function'; import { executeActionCommand, executeCommand, @@ -30,46 +44,50 @@ import { executeCoreGitCommand, executeEditorCommand, registerCommand, -} from '../system/command'; -import { debug } from '../system/decorators/log'; -import { sequentialize } from '../system/function'; -import { openWorkspace, OpenWorkspaceLocation } from '../system/utils'; -import type { BranchesNode } from './nodes/branchesNode'; -import { BranchNode } from './nodes/branchNode'; -import { BranchTrackingStatusNode } from './nodes/branchTrackingStatusNode'; -import { CommitFileNode } from './nodes/commitFileNode'; -import { CommitNode } from './nodes/commitNode'; -import type { PagerNode } from './nodes/common'; -import { CompareBranchNode } from './nodes/compareBranchNode'; -import { ContributorNode } from './nodes/contributorNode'; -import { FileHistoryNode } from './nodes/fileHistoryNode'; -import { FileRevisionAsCommitNode } from './nodes/fileRevisionAsCommitNode'; -import { FolderNode } from './nodes/folderNode'; -import { LineHistoryNode } from './nodes/lineHistoryNode'; -import { MergeConflictFileNode } from './nodes/mergeConflictFileNode'; -import { PullRequestNode } from './nodes/pullRequestNode'; -import { RemoteNode } from './nodes/remoteNode'; -import { RepositoryNode } from './nodes/repositoryNode'; -import { ResultsFileNode } from './nodes/resultsFileNode'; -import { ResultsFilesNode } from './nodes/resultsFilesNode'; -import { StashFileNode } from './nodes/stashFileNode'; -import { StashNode } from './nodes/stashNode'; -import { StatusFileNode } from './nodes/statusFileNode'; -import { TagNode } from './nodes/tagNode'; -import type { TagsNode } from './nodes/tagsNode'; +} from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; +import type { OpenWorkspaceLocation } from '../system/vscode/utils'; +import { openUrl, openWorkspace, revealInFileExplorer } from '../system/vscode/utils'; +import { DeepLinkActionType } from '../uris/deepLinks/deepLink'; +import type { LaunchpadItemNode } from './launchpadView'; +import type { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ClipboardType } from './nodes/abstract/viewNode'; import { - canClearNode, canEditNode, canViewDismissNode, getNodeRepoPath, isPageableViewNode, - RepositoryFolderNode, ViewNode, - ViewRefFileNode, - ViewRefNode, -} from './nodes/viewNode'; -import { WorktreeNode } from './nodes/worktreeNode'; -import { WorktreesNode } from './nodes/worktreesNode'; +} from './nodes/abstract/viewNode'; +import { ViewRefFileNode, ViewRefNode } from './nodes/abstract/viewRefNode'; +import type { BranchesNode } from './nodes/branchesNode'; +import type { BranchNode } from './nodes/branchNode'; +import type { BranchTrackingStatusFilesNode } from './nodes/branchTrackingStatusFilesNode'; +import type { BranchTrackingStatusNode } from './nodes/branchTrackingStatusNode'; +import type { CommitFileNode } from './nodes/commitFileNode'; +import type { CommitNode } from './nodes/commitNode'; +import type { PagerNode } from './nodes/common'; +import type { CompareResultsNode } from './nodes/compareResultsNode'; +import type { ContributorNode } from './nodes/contributorNode'; +import type { DraftNode } from './nodes/draftNode'; +import type { FileHistoryNode } from './nodes/fileHistoryNode'; +import type { FileRevisionAsCommitNode } from './nodes/fileRevisionAsCommitNode'; +import type { FolderNode } from './nodes/folderNode'; +import type { LineHistoryNode } from './nodes/lineHistoryNode'; +import type { MergeConflictFileNode } from './nodes/mergeConflictFileNode'; +import type { PullRequestNode } from './nodes/pullRequestNode'; +import type { RemoteNode } from './nodes/remoteNode'; +import type { RepositoryNode } from './nodes/repositoryNode'; +import type { ResultsFileNode } from './nodes/resultsFileNode'; +import type { ResultsFilesNode } from './nodes/resultsFilesNode'; +import type { StashFileNode } from './nodes/stashFileNode'; +import type { StashNode } from './nodes/stashNode'; +import type { StatusFileNode } from './nodes/statusFileNode'; +import type { TagNode } from './nodes/tagNode'; +import type { TagsNode } from './nodes/tagsNode'; +import type { WorktreeNode } from './nodes/worktreeNode'; +import type { WorktreesNode } from './nodes/worktreesNode'; interface CompareSelectedInfo { ref: string; @@ -77,37 +95,37 @@ interface CompareSelectedInfo { uri?: Uri; } -const enum ViewCommandMultiSelectMode { - Disallowed, - Allowed, - Custom, -} - export function registerViewCommand( command: string, callback: (...args: any[]) => unknown, thisArg?: any, - multiSelect: ViewCommandMultiSelectMode = ViewCommandMultiSelectMode.Allowed, + multiselect: boolean | 'sequential' = false, ): Disposable { return registerCommand( command, (...args: any[]) => { - if (multiSelect !== ViewCommandMultiSelectMode.Disallowed) { - let [node, nodes, ...rest] = args; - // If there is a node followed by an array of nodes, then we want to execute the command for each - if (node instanceof ViewNode && Array.isArray(nodes) && nodes[0] instanceof ViewNode) { - nodes = nodes.filter(n => n?.constructor === node.constructor); - - if (multiSelect === ViewCommandMultiSelectMode.Custom) { - return callback.apply(thisArg, [node, nodes, ...rest]); + if (multiselect) { + const [active, selection, ...rest] = args; + + // If there is a node followed by an array of nodes, then check how we want to execute the command + if (active instanceof ViewNode && Array.isArray(selection) && selection[0] instanceof ViewNode) { + const nodes = selection.filter((n): n is ViewNode => n?.constructor === active.constructor); + + if (multiselect === 'sequential') { + if (!nodes.includes(active)) { + nodes.splice(0, 0, active); + } + + // Execute the command for each node sequentially + return sequentialize( + callback, + nodes.map<[ViewNode, ...any[]]>(n => [n, ...rest]), + thisArg, + ); } - return sequentialize( - callback, - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - (nodes as ViewNode[]).map(n => [n, ...rest]), - thisArg, - ); + // Delegate to the callback to handle the multi-select + return callback.apply(thisArg, [active, nodes, ...rest]); } } @@ -119,22 +137,15 @@ export function registerViewCommand( export class ViewCommands { constructor(private readonly container: Container) { - registerViewCommand('gitlens.views.clearNode', (n: ViewNode) => canClearNode(n) && n.clear(), this); - // Register independently as it already handles copying multiple nodes - registerCommand( - Commands.ViewsCopy, - async (active: ViewNode | undefined, selection: ViewNode[]) => { - selection = Array.isArray(selection) ? selection : active != null ? [active] : []; - if (selection.length === 0) return; - - const data = selection - .map(n => n.toClipboard?.()) - .filter(s => Boolean(s)) - .join('\n'); - await env.clipboard.writeText(data); - }, - this, - ); + registerViewCommand('gitlens.views.clearComparison', n => this.clearComparison(n), this); + registerViewCommand('gitlens.views.clearReviewed', n => this.clearReviewed(n), this); + registerViewCommand(Commands.ViewsCopy, partial(copyNode, 'text'), this, true); + registerViewCommand(Commands.ViewsCopyAsMarkdown, partial(copyNode, 'markdown'), this, true); + registerViewCommand(Commands.ViewsCopyUrl, copyNodeUrl, this); + registerViewCommand(`${Commands.ViewsCopyUrl}.multi`, copyNodeUrl, this, true); + registerViewCommand(Commands.ViewsOpenUrl, openNodeUrl, this); + registerViewCommand(`${Commands.ViewsOpenUrl}.multi`, openNodeUrl, this, true); + registerViewCommand('gitlens.views.collapseNode', () => executeCoreCommand('list.collapseAllToFocus'), this); registerViewCommand( 'gitlens.views.dismissNode', (n: ViewNode) => canViewDismissNode(n.view) && n.view.dismissNode(n), @@ -159,6 +170,7 @@ export class ViewCommands { return n.view.refreshNode(n, reset == null ? true : reset); }, this, + 'sequential', ); registerViewCommand( @@ -184,8 +196,11 @@ export class ViewCommands { registerViewCommand('gitlens.views.unsetAsDefault', this.unsetAsDefault, this); registerViewCommand('gitlens.views.openInTerminal', this.openInTerminal, this); + registerViewCommand('gitlens.views.openInIntegratedTerminal', this.openInIntegratedTerminal, this); registerViewCommand('gitlens.views.star', this.star, this); + registerViewCommand('gitlens.views.star.multi', this.star, this, 'sequential'); registerViewCommand('gitlens.views.unstar', this.unstar, this); + registerViewCommand('gitlens.views.unstar.multi', this.unstar, this, 'sequential'); registerViewCommand('gitlens.views.browseRepoAtRevision', this.browseRepoAtRevision, this); registerViewCommand( @@ -206,6 +221,36 @@ export class ViewCommands { registerViewCommand('gitlens.views.addAuthors', this.addAuthors, this); registerViewCommand('gitlens.views.addAuthor', this.addAuthor, this); + registerViewCommand('gitlens.views.addAuthor.multi', this.addAuthor, this, true); + + registerViewCommand( + 'gitlens.views.openBranchOnRemote', + n => executeCommand(Commands.OpenBranchOnRemote, n), + this, + ); + registerViewCommand( + 'gitlens.views.openBranchOnRemote.multi', + n => executeCommand(Commands.OpenBranchOnRemote, n), + this, + 'sequential', + ); + + registerViewCommand( + 'gitlens.views.copyRemoteCommitUrl', + (n, nodes) => this.openCommitOnRemote(n, nodes, true), + this, + ); + registerViewCommand( + 'gitlens.views.copyRemoteCommitUrl.multi', + (n, nodes) => this.openCommitOnRemote(n, nodes, true), + this, + ); + registerViewCommand('gitlens.views.openCommitOnRemote', (n, nodes) => this.openCommitOnRemote(n, nodes), this); + registerViewCommand( + 'gitlens.views.openCommitOnRemote.multi', + (n, nodes) => this.openCommitOnRemote(n, nodes), + this, + ); registerViewCommand('gitlens.views.openChanges', this.openChanges, this); registerViewCommand('gitlens.views.openChangesWithWorking', this.openChangesWithWorking, this); @@ -213,8 +258,23 @@ export class ViewCommands { registerViewCommand('gitlens.views.openFile', this.openFile, this); registerViewCommand('gitlens.views.openFileRevision', this.openRevision, this); registerViewCommand('gitlens.views.openChangedFiles', this.openFiles, this); - registerViewCommand('gitlens.views.openChangedFileDiffs', this.openAllChanges, this); - registerViewCommand('gitlens.views.openChangedFileDiffsWithWorking', this.openAllChangesWithWorking, this); + registerViewCommand('gitlens.views.openOnlyChangedFiles', this.openOnlyChangedFiles); + registerViewCommand('gitlens.views.openChangedFileDiffs', (n, o) => this.openAllChanges(n, o), this); + registerViewCommand( + 'gitlens.views.openChangedFileDiffsWithWorking', + (n, o) => this.openAllChangesWithWorking(n, o), + this, + ); + registerViewCommand( + 'gitlens.views.openChangedFileDiffsIndividually', + (n, o) => this.openAllChanges(n, o, true), + this, + ); + registerViewCommand( + 'gitlens.views.openChangedFileDiffsWithWorkingIndividually', + (n, o) => this.openAllChangesWithWorking(n, o, true), + this, + ); registerViewCommand('gitlens.views.openChangedFileRevisions', this.openRevisions, this); registerViewCommand('gitlens.views.applyChanges', this.applyChanges, this); registerViewCommand('gitlens.views.highlightChanges', this.highlightChanges, this); @@ -233,8 +293,16 @@ export class ViewCommands { registerViewCommand('gitlens.views.unstageDirectory', this.unstageDirectory, this); registerViewCommand('gitlens.views.unstageFile', this.unstageFile, this); + registerViewCommand( + 'gitlens.views.openChangedFileDiffsWithMergeBase', + this.openChangedFileDiffsWithMergeBase, + this, + ); + registerViewCommand('gitlens.views.compareAncestryWithWorking', this.compareAncestryWithWorking, this); registerViewCommand('gitlens.views.compareWithHead', this.compareHeadWith, this); + registerViewCommand('gitlens.views.compareBranchWithHead', this.compareBranchWithHead, this); + registerViewCommand('gitlens.views.compareWithMergeBase', this.compareWithMergeBase, this); registerViewCommand('gitlens.views.compareWithUpstream', this.compareWithUpstream, this); registerViewCommand('gitlens.views.compareWithSelected', this.compareWithSelected, this); registerViewCommand('gitlens.views.selectForCompare', this.selectForCompare, this); @@ -244,28 +312,33 @@ export class ViewCommands { registerViewCommand( 'gitlens.views.setBranchComparisonToWorking', - n => this.setBranchComparison(n, ViewShowBranchComparison.Working), + n => this.setBranchComparison(n, 'working'), this, ); registerViewCommand( 'gitlens.views.setBranchComparisonToBranch', - n => this.setBranchComparison(n, ViewShowBranchComparison.Branch), + n => this.setBranchComparison(n, 'branch'), this, ); - registerViewCommand('gitlens.views.cherryPick', this.cherryPick, this, ViewCommandMultiSelectMode.Custom); + registerViewCommand('gitlens.views.cherryPick', this.cherryPick, this); + registerViewCommand('gitlens.views.cherryPick.multi', this.cherryPick, this, true); registerViewCommand('gitlens.views.title.createBranch', () => this.createBranch()); registerViewCommand('gitlens.views.createBranch', this.createBranch, this); registerViewCommand('gitlens.views.deleteBranch', this.deleteBranch, this); + registerViewCommand('gitlens.views.deleteBranch.multi', this.deleteBranch, this, true); registerViewCommand('gitlens.views.renameBranch', this.renameBranch, this); - registerViewCommand('gitlens.views.title.applyStash', () => this.applyStash()); - registerViewCommand('gitlens.views.deleteStash', this.deleteStash, this, ViewCommandMultiSelectMode.Custom); + registerViewCommand('gitlens.views.stash.apply', this.applyStash, this); + registerViewCommand('gitlens.views.stash.delete', this.deleteStash, this); + registerViewCommand('gitlens.views.stash.delete.multi', this.deleteStash, this, true); + registerViewCommand('gitlens.views.stash.rename', this.renameStash, this); registerViewCommand('gitlens.views.title.createTag', () => this.createTag()); registerViewCommand('gitlens.views.createTag', this.createTag, this); registerViewCommand('gitlens.views.deleteTag', this.deleteTag, this); + registerViewCommand('gitlens.views.deleteTag.multi', this.deleteTag, this, true); registerViewCommand('gitlens.views.mergeBranchInto', this.merge, this); registerViewCommand('gitlens.views.pushToCommit', this.pushToCommit, this); @@ -276,71 +349,99 @@ export class ViewCommands { registerViewCommand('gitlens.views.resetCommit', this.resetCommit, this); registerViewCommand('gitlens.views.resetToCommit', this.resetToCommit, this); + registerViewCommand('gitlens.views.resetToTip', this.resetToTip, this); registerViewCommand('gitlens.views.revert', this.revert, this); registerViewCommand('gitlens.views.undoCommit', this.undoCommit, this); registerViewCommand('gitlens.views.createPullRequest', this.createPullRequest, this); registerViewCommand('gitlens.views.openPullRequest', this.openPullRequest, this); + registerViewCommand('gitlens.views.openPullRequestChanges', this.openPullRequestChanges, this); + registerViewCommand('gitlens.views.openPullRequestComparison', this.openPullRequestComparison, this); + + registerViewCommand('gitlens.views.draft.open', this.openDraft, this); + registerViewCommand('gitlens.views.draft.openOnWeb', this.openDraftOnWeb, this); registerViewCommand('gitlens.views.title.createWorktree', () => this.createWorktree()); registerViewCommand('gitlens.views.createWorktree', this.createWorktree, this); registerViewCommand('gitlens.views.deleteWorktree', this.deleteWorktree, this); + registerViewCommand('gitlens.views.deleteWorktree.multi', this.deleteWorktree, this, true); registerViewCommand('gitlens.views.openWorktree', this.openWorktree, this); + registerViewCommand('gitlens.views.openInWorktree', this.openInWorktree, this); + registerViewCommand('gitlens.views.revealRepositoryInExplorer', this.revealRepositoryInExplorer, this); registerViewCommand('gitlens.views.revealWorktreeInExplorer', this.revealWorktreeInExplorer, this); registerViewCommand( 'gitlens.views.openWorktreeInNewWindow', - n => this.openWorktree(n, { location: OpenWorkspaceLocation.NewWindow }), + n => this.openWorktree(n, undefined, { location: 'newWindow' }), + this, + ); + registerViewCommand( + 'gitlens.views.openWorktreeInNewWindow.multi', + (n, nodes) => this.openWorktree(n, nodes, { location: 'newWindow' }), + this, + true, + ); + + registerViewCommand( + 'gitlens.views.setResultsCommitsFilterAuthors', + n => this.setResultsCommitsFilter(n, true), + this, + ); + registerViewCommand( + 'gitlens.views.setResultsCommitsFilterOff', + n => this.setResultsCommitsFilter(n, false), this, ); } - @debug() + @log() private addAuthors(node?: ViewNode) { return ContributorActions.addAuthors(getNodeRepoPath(node)); } - @debug() - private addAuthor(node?: ContributorNode) { - if (node instanceof ContributorNode) { - return ContributorActions.addAuthors( - node.repoPath, - node.contributor.current ? undefined : node.contributor, - ); - } + @log() + private addAuthor(node?: ContributorNode, nodes?: ContributorNode[]) { + if (!node?.is('contributor')) return Promise.resolve(); - return Promise.resolve(); + const contributors = nodes?.length ? nodes.map(n => n.contributor) : [node.contributor]; + return ContributorActions.addAuthors( + node.repoPath, + contributors.filter(c => !c.current), + ); } - @debug() + @log() private addRemote(node?: ViewNode) { return RemoteActions.add(getNodeRepoPath(node)); } - @debug() + @log() private applyChanges(node: ViewRefFileNode) { - if (!(node instanceof ViewRefFileNode)) return Promise.resolve(); - - if (node instanceof ResultsFileNode) { + if (node.is('results-file')) { return CommitActions.applyChanges( node.file, - GitReference.create(node.ref1, node.repoPath), - GitReference.create(node.ref2, node.repoPath), + createReference(node.ref1, node.repoPath), + createReference(node.ref2, node.repoPath), ); } - if (node.ref == null || node.ref.ref === 'HEAD') return Promise.resolve(); + if (!(node instanceof ViewRefFileNode) || node.ref == null || node.ref.ref === 'HEAD') return Promise.resolve(); return CommitActions.applyChanges(node.file, node.ref); } - @debug() - private applyStash() { - return StashActions.apply(); + @log() + private applyStash(node: StashNode) { + if (!node.is('stash')) return Promise.resolve(); + + return StashActions.apply(node.repoPath, node.commit); } - @debug() - private browseRepoAtRevision(node: ViewRefNode, options?: { before?: boolean; openInNewWindow?: boolean }) { - if (!(node instanceof ViewRefNode)) return Promise.resolve(); + @log() + private browseRepoAtRevision( + node: ViewRefNode | ViewRefFileNode, + options?: { before?: boolean; openInNewWindow?: boolean }, + ) { + if (!(node instanceof ViewRefNode) && !(node instanceof ViewRefFileNode)) return Promise.resolve(); return browseAtRevision(node.uri, { before: options?.before, @@ -348,35 +449,51 @@ export class ViewCommands { }); } - @debug() + @log() private cherryPick(node: CommitNode, nodes?: CommitNode[]) { - if (!(node instanceof CommitNode)) return Promise.resolve(); + if (!node.is('commit')) return Promise.resolve(); - if (nodes != null && nodes.length !== 0) { - return RepoActions.cherryPick( - node.repoPath, - nodes.map(n => n.ref), - ); + const refs = nodes?.length ? nodes.map(n => n.ref) : [node.ref]; + return RepoActions.cherryPick(node.repoPath, refs); + } + + @log() + private clearComparison(node: ViewNode) { + if (node.is('compare-branch')) { + void node.clear(); } + } - return RepoActions.cherryPick(node.repoPath, node.ref); + @log() + private clearReviewed(node: ViewNode) { + let compareNode; + if (node.is('results-files')) { + compareNode = node.getParent(); + if (compareNode == null) return; + } else { + compareNode = node; + } + + if (compareNode.isAny('compare-branch', 'compare-results')) { + compareNode.clearReviewed(); + } } - @debug() + @log() private closeRepository(node: RepositoryNode | RepositoryFolderNode) { - if (!(node instanceof RepositoryNode) && !(node instanceof RepositoryFolderNode)) return; + if (!node.isAny('repository', 'repo-folder')) return; node.repo.closed = true; } - @debug() - private async createBranch(node?: ViewRefNode | BranchesNode | BranchTrackingStatusNode) { + @log() + private async createBranch(node?: ViewRefNode | ViewRefFileNode | BranchesNode | BranchTrackingStatusNode) { let from = - node instanceof ViewRefNode + node instanceof ViewRefNode || node instanceof ViewRefFileNode ? node?.ref - : node instanceof BranchTrackingStatusNode - ? node.branch - : undefined; + : node?.is('tracking-status') + ? node.branch + : undefined; if (from == null) { const branch = await this.container.git.getBranch( node?.repoPath ?? this.container.git.getBestRepository()?.uri, @@ -386,11 +503,9 @@ export class ViewCommands { return BranchActions.create(node?.repoPath, from); } - @debug() + @log() private async createPullRequest(node: BranchNode | BranchTrackingStatusNode) { - if (!(node instanceof BranchNode) && !(node instanceof BranchTrackingStatusNode)) { - return Promise.resolve(); - } + if (!node.isAny('branch', 'tracking-status')) return Promise.resolve(); const remote = await node.branch.getRemote(); @@ -419,14 +534,14 @@ export class ViewCommands { }); } - @debug() - private async createTag(node?: ViewRefNode | TagsNode | BranchTrackingStatusNode) { + @log() + private async createTag(node?: ViewRefNode | ViewRefFileNode | TagsNode | BranchTrackingStatusNode) { let from = - node instanceof ViewRefNode + node instanceof ViewRefNode || node instanceof ViewRefFileNode ? node?.ref - : node instanceof BranchTrackingStatusNode - ? node.branch - : undefined; + : node?.is('tracking-status') + ? node.branch + : undefined; if (from == null) { const branch = await this.container.git.getBranch( node?.repoPath ?? this.container.git.getBestRepository()?.uri, @@ -436,123 +551,119 @@ export class ViewCommands { return TagActions.create(node?.repoPath, from); } - @debug() + @log() private async createWorktree(node?: BranchNode | WorktreesNode) { - if (node instanceof WorktreesNode) { + if (node?.is('worktrees')) { node = undefined; } - if (node != null && !(node instanceof BranchNode)) return undefined; + if (node != null && !node.is('branch')) return undefined; return WorktreeActions.create(node?.repoPath, undefined, node?.ref); } - @debug() - private deleteBranch(node: BranchNode) { - if (!(node instanceof BranchNode)) return Promise.resolve(); + @log() + private deleteBranch(node: BranchNode, nodes?: BranchNode[]) { + if (!node.is('branch')) return Promise.resolve(); - return BranchActions.remove(node.repoPath, node.branch); + const refs = nodes?.length ? nodes.map(n => n.branch) : [node.branch]; + return BranchActions.remove(node.repoPath, refs); } - @debug() + @log() private deleteStash(node: StashNode, nodes?: StashNode[]) { - if (!(node instanceof StashNode)) return Promise.resolve(); + if (!node.is('stash')) return Promise.resolve(); - if (nodes != null && nodes.length !== 0) { - const sorted = nodes.sort((a, b) => parseInt(b.commit.number, 10) - parseInt(a.commit.number, 10)); + const refs = nodes?.length ? nodes.map(n => n.commit) : [node.commit]; + return StashActions.drop(node.repoPath, refs); + } - return sequentialize( - StashActions.drop, - sorted.map<[string, GitStashReference]>(n => [n.repoPath, n.commit]), - this, - ); - } - return StashActions.drop(node.repoPath, node.commit); + @log() + private renameStash(node: StashNode) { + if (!node.is('stash')) return Promise.resolve(); + + return StashActions.rename(node.repoPath, node.commit); } - @debug() - private deleteTag(node: TagNode) { - if (!(node instanceof TagNode)) return Promise.resolve(); + @log() + private deleteTag(node: TagNode, nodes?: TagNode[]) { + if (!node.is('tag')) return Promise.resolve(); - return TagActions.remove(node.repoPath, node.tag); + const refs = nodes?.length ? nodes.map(n => n.tag) : [node.tag]; + return TagActions.remove(node.repoPath, refs); } - @debug() - private async deleteWorktree(node: WorktreeNode) { - if (!(node instanceof WorktreeNode)) return undefined; + @log() + private async deleteWorktree(node: WorktreeNode, nodes?: WorktreeNode[]) { + if (!node.is('worktree')) return undefined; - return WorktreeActions.remove(node.repoPath, node.worktree.uri); + const worktrees = nodes?.length ? nodes.map(n => n.worktree) : [node.worktree]; + const uris = worktrees.filter(w => !w.isDefault && !w.opened).map(w => w.uri); + return WorktreeActions.remove(node.repoPath, uris); } - @debug() + @log() private fetch(node: RemoteNode | RepositoryNode | RepositoryFolderNode | BranchNode | BranchTrackingStatusNode) { - if (node instanceof RepositoryNode || node instanceof RepositoryFolderNode) return RepoActions.fetch(node.repo); - if (node instanceof RemoteNode) return RemoteActions.fetch(node.remote.repoPath, node.remote.name); - if (node instanceof BranchNode || node instanceof BranchTrackingStatusNode) { + if (node.isAny('repository', 'repo-folder')) return RepoActions.fetch(node.repo); + if (node.is('remote')) return RemoteActions.fetch(node.remote.repoPath, node.remote.name); + if (node.isAny('branch', 'tracking-status')) { return RepoActions.fetch(node.repoPath, node.root ? undefined : node.branch); } return Promise.resolve(); } - @debug() + @log() private async highlightChanges(node: CommitFileNode | StashFileNode | FileRevisionAsCommitNode | ResultsFileNode) { - if ( - !(node instanceof CommitFileNode) && - !(node instanceof StashFileNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof ResultsFileNode) - ) { - return; - } + if (!node.isAny('commit-file', 'stash-file', 'file-commit', 'results-file')) return; await this.openFile(node, { preserveFocus: true, preview: true }); void (await this.container.fileAnnotations.toggle( window.activeTextEditor, - FileAnnotationType.Changes, + 'changes', { sha: node.ref.ref }, true, )); } - @debug() + @log() private async highlightRevisionChanges( node: CommitFileNode | StashFileNode | FileRevisionAsCommitNode | ResultsFileNode, ) { - if ( - !(node instanceof CommitFileNode) && - !(node instanceof StashFileNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof ResultsFileNode) - ) { - return; - } + if (!node.isAny('commit-file', 'stash-file', 'file-commit', 'results-file')) return; await this.openFile(node, { preserveFocus: true, preview: true }); void (await this.container.fileAnnotations.toggle( window.activeTextEditor, - FileAnnotationType.Changes, + 'changes', { sha: node.ref.ref, only: true }, true, )); } - @debug() + @log() private merge(node: BranchNode | TagNode) { - if (!(node instanceof BranchNode) && !(node instanceof TagNode)) return Promise.resolve(); + if (!node.isAny('branch', 'tag')) return Promise.resolve(); - return RepoActions.merge(node.repoPath, node instanceof BranchNode ? node.branch : node.tag); + return RepoActions.merge(node.repoPath, node.is('branch') ? node.branch : node.tag); } - @debug() - private openInTerminal(node: RepositoryNode | RepositoryFolderNode) { - if (!(node instanceof RepositoryNode) && !(node instanceof RepositoryFolderNode)) return Promise.resolve(); + @log() + private openInTerminal(node: BranchTrackingStatusNode | RepositoryNode | RepositoryFolderNode) { + if (!node.isAny('tracking-status', 'repository', 'repo-folder')) return Promise.resolve(); - return executeCoreCommand(CoreCommands.OpenInTerminal, Uri.file(node.repo.path)); + return executeCoreCommand('openInTerminal', Uri.file(node.repoPath)); } - @debug() + @log() + private openInIntegratedTerminal(node: BranchTrackingStatusNode | RepositoryNode | RepositoryFolderNode) { + if (!node.isAny('tracking-status', 'repository', 'repo-folder')) return Promise.resolve(); + + return executeCoreCommand('openInIntegratedTerminal', Uri.file(node.repoPath)); + } + + @log() private openPullRequest(node: PullRequestNode) { - if (!(node instanceof PullRequestNode)) return Promise.resolve(); + if (!node.is('pullrequest')) return Promise.resolve(); return executeActionCommand('openPullRequest', { repoPath: node.uri.repoPath!, @@ -568,54 +679,175 @@ export class ViewCommands { }); } - @debug() - private openWorktree(node: WorktreeNode, options?: { location?: OpenWorkspaceLocation }) { - if (!(node instanceof WorktreeNode)) return undefined; + @log() + private async openPullRequestChanges(node: PullRequestNode | LaunchpadItemNode) { + if (!node.is('pullrequest') && !node.is('launchpad-item')) return Promise.resolve(); + + const pr = node.pullRequest; + if (pr?.refs?.base == null || pr?.refs.head == null) return Promise.resolve(); + + const repo = await getOpenedPullRequestRepo(this.container, pr, node.repoPath); + if (repo == null) return Promise.resolve(); + + const refs = getComparisonRefsForPullRequest(repo.path, pr.refs); + const counts = await ensurePullRequestRefs( + this.container, + pr, + repo, + { promptMessage: `Unable to open changes for PR #${pr.id} because of a missing remote.` }, + refs, + ); + if (counts == null) return Promise.resolve(); + + return CommitActions.openComparisonChanges( + this.container, + { + repoPath: refs.repoPath, + lhs: refs.base.ref, + rhs: refs.head.ref, + }, + { + title: `Changes in Pull Request #${pr.id}`, + }, + ); + } + + @log() + private async openPullRequestComparison(node: PullRequestNode | LaunchpadItemNode) { + if (!node.is('pullrequest') && !node.is('launchpad-item')) return Promise.resolve(); + + const pr = node.pullRequest; + if (pr?.refs?.base == null || pr?.refs.head == null) return Promise.resolve(); + + const repo = await getOpenedPullRequestRepo(this.container, pr, node.repoPath); + if (repo == null) return Promise.resolve(); + + const refs = getComparisonRefsForPullRequest(repo.path, pr.refs); + const counts = await ensurePullRequestRefs( + this.container, + pr, + repo, + { promptMessage: `Unable to open comparison for PR #${pr.id} because of a missing remote.` }, + refs, + ); + if (counts == null) return Promise.resolve(); + + return this.container.searchAndCompareView.compare(refs.repoPath, refs.head, refs.base); + } + + @log() + private async openDraft(node: DraftNode) { + await showPatchesView({ mode: 'view', draft: node.draft }); + } + + @log() + private async openDraftOnWeb(node: DraftNode) { + const url = this.container.drafts.generateWebUrl(node.draft); + await openUrl(url); + } + + @log() + private async openWorktree( + node: BranchNode | WorktreeNode, + nodes?: (BranchNode | WorktreeNode)[], + options?: { location?: OpenWorkspaceLocation }, + ) { + if (!node.is('branch') && !node.is('worktree')) return; + if (node.worktree == null) return; + + let uri; + if (nodes?.length && options?.location === 'newWindow') { + type VSCodeWorkspace = { + folders: ({ name: string; path: string } | { name: string; uri: Uri })[]; + settings: { [key: string]: unknown }; + }; + + // TODO@eamodio hash the folder paths to get a unique, but re-usable workspace name? + const codeWorkspace: VSCodeWorkspace = { + folders: filterMap(nodes, n => + n.worktree != null ? { name: n.worktree.name, path: n.worktree.uri.fsPath } : undefined, + ), + settings: {}, + }; + uri = Uri.file(getTempFile(`worktrees-${Date.now()}.code-workspace`)); + + await workspace.fs.writeFile(uri, new TextEncoder().encode(JSON.stringify(codeWorkspace, null, 2))); + } else { + uri = node.worktree.uri; + } + + openWorkspace(uri, options); + } + + @log() + private async openInWorktree(node: BranchNode | PullRequestNode | LaunchpadItemNode) { + if (!node.is('branch') && !node.is('pullrequest') && !node.is('launchpad-item')) return; - return openWorkspace(node.worktree.uri, options); + if (node.is('branch')) { + return executeGitCommand({ + command: 'switch', + state: { + repos: node.repo, + reference: node.branch, + skipWorktreeConfirmations: true, + }, + }); + } else if (node.is('pullrequest') || node.is('launchpad-item')) { + const pr = node.pullRequest; + if (pr?.refs?.head == null) return Promise.resolve(); + const repoIdentity = getRepositoryIdentityForPullRequest(pr); + if (repoIdentity.remote.url == null) return Promise.resolve(); + const deepLink = getPullRequestBranchDeepLink( + this.container, + pr.refs.head.branch, + repoIdentity.remote.url, + DeepLinkActionType.SwitchToPullRequestWorktree, + ); + return this.container.deepLinks.processDeepLinkUri(deepLink, false); + } } - @debug() + @log() private pruneRemote(node: RemoteNode) { - if (!(node instanceof RemoteNode)) return Promise.resolve(); + if (!node.is('remote')) return Promise.resolve(); - return RemoteActions.prune(node.repo, node.remote.name); + return RemoteActions.prune(node.remote.repoPath, node.remote.name); } - @debug() + @log() private async removeRemote(node: RemoteNode) { - if (!(node instanceof RemoteNode)) return Promise.resolve(); + if (!node.is('remote')) return Promise.resolve(); - return RemoteActions.remove(node.repo, node.remote.name); + return RemoteActions.remove(node.remote.repoPath, node.remote.name); } - @debug() + @log() private publishBranch(node: BranchNode | BranchTrackingStatusNode) { - if (node instanceof BranchNode || node instanceof BranchTrackingStatusNode) { + if (node.isAny('branch', 'tracking-status')) { return RepoActions.push(node.repoPath, undefined, node.branch); } return Promise.resolve(); } - @debug() + @log() private publishRepository(node: BranchNode | BranchTrackingStatusNode) { - if (node instanceof BranchNode || node instanceof BranchTrackingStatusNode) { - return executeCoreGitCommand(CoreGitCommands.Publish, Uri.file(node.repoPath)); + if (node.isAny('branch', 'tracking-status')) { + return executeCoreGitCommand('git.publish', Uri.file(node.repoPath)); } return Promise.resolve(); } - @debug() + @log() private pull(node: RepositoryNode | RepositoryFolderNode | BranchNode | BranchTrackingStatusNode) { - if (node instanceof RepositoryNode || node instanceof RepositoryFolderNode) return RepoActions.pull(node.repo); - if (node instanceof BranchNode || node instanceof BranchTrackingStatusNode) { + if (node.isAny('repository', 'repo-folder')) return RepoActions.pull(node.repo); + if (node.isAny('branch', 'tracking-status')) { return RepoActions.pull(node.repoPath, node.root ? undefined : node.branch); } return Promise.resolve(); } - @debug() + @log() private push( node: | RepositoryNode @@ -626,15 +858,15 @@ export class ViewCommands { | FileRevisionAsCommitNode, force?: boolean, ) { - if (node instanceof RepositoryNode || node instanceof RepositoryFolderNode) { + if (node.isAny('repository', 'repo-folder')) { return RepoActions.push(node.repo, force); } - if (node instanceof BranchNode || node instanceof BranchTrackingStatusNode) { + if (node.isAny('branch', 'tracking-status')) { return RepoActions.push(node.repoPath, force, node.root ? undefined : node.branch); } - if (node instanceof CommitNode || node instanceof FileRevisionAsCommitNode) { + if (node.isAny('commit', 'file-commit')) { if (node.isTip) { return RepoActions.push(node.repoPath, force); } @@ -645,37 +877,32 @@ export class ViewCommands { return Promise.resolve(); } - @debug() + @log() private pushToCommit(node: CommitNode | FileRevisionAsCommitNode) { - if (!(node instanceof CommitNode) && !(node instanceof FileRevisionAsCommitNode)) return Promise.resolve(); + if (!node.isAny('commit', 'file-commit')) return Promise.resolve(); return RepoActions.push(node.repoPath, false, node.commit); } - @debug() + @log() private rebase(node: BranchNode | CommitNode | FileRevisionAsCommitNode | TagNode) { - if ( - !(node instanceof BranchNode) && - !(node instanceof CommitNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof TagNode) - ) { + if (!node.isAny('branch', 'commit', 'file-commit', 'tag')) { return Promise.resolve(); } return RepoActions.rebase(node.repoPath, node.ref); } - @debug() + @log() private rebaseToRemote(node: BranchNode | BranchTrackingStatusNode) { - if (!(node instanceof BranchNode) && !(node instanceof BranchTrackingStatusNode)) return Promise.resolve(); + if (!node.isAny('branch', 'tracking-status')) return Promise.resolve(); - const upstream = node instanceof BranchNode ? node.branch.upstream?.name : node.status.upstream; + const upstream = node.is('branch') ? node.branch.upstream?.name : node.status.upstream?.name; if (upstream == null) return Promise.resolve(); return RepoActions.rebase( node.repoPath, - GitReference.create(upstream, node.repoPath, { + createReference(upstream, node.repoPath, { refType: 'branch', name: upstream, remote: true, @@ -683,20 +910,20 @@ export class ViewCommands { ); } - @debug() + @log() private renameBranch(node: BranchNode) { - if (!(node instanceof BranchNode)) return Promise.resolve(); + if (!node.is('branch')) return Promise.resolve(); return BranchActions.rename(node.repoPath, node.branch); } - @debug() + @log() private resetCommit(node: CommitNode | FileRevisionAsCommitNode) { - if (!(node instanceof CommitNode) && !(node instanceof FileRevisionAsCommitNode)) return Promise.resolve(); + if (!node.isAny('commit', 'file-commit')) return Promise.resolve(); return RepoActions.reset( node.repoPath, - GitReference.create(`${node.ref.ref}^`, node.ref.repoPath, { + createReference(`${node.ref.ref}^`, node.ref.repoPath, { refType: 'revision', name: `${node.ref.name}^`, message: node.ref.message, @@ -704,60 +931,74 @@ export class ViewCommands { ); } - @debug() + @log() private resetToCommit(node: CommitNode | FileRevisionAsCommitNode) { - if (!(node instanceof CommitNode) && !(node instanceof FileRevisionAsCommitNode)) return Promise.resolve(); + if (!node.isAny('commit', 'file-commit')) return Promise.resolve(); return RepoActions.reset(node.repoPath, node.ref); } - @debug() + @log() + private resetToTip(node: BranchNode) { + if (!node.is('branch')) return Promise.resolve(); + + return RepoActions.reset( + node.repoPath, + createReference(node.ref.ref, node.repoPath, { refType: 'revision', name: node.ref.name }), + ); + } + + @log() private restore(node: ViewRefFileNode) { if (!(node instanceof ViewRefFileNode)) return Promise.resolve(); return CommitActions.restoreFile(node.file, node.ref); } - @debug() - private revealWorktreeInExplorer(node: WorktreeNode) { - if (!(node instanceof WorktreeNode)) return undefined; + @log() + private revealRepositoryInExplorer(node: RepositoryNode) { + if (!node.is('repository')) return undefined; + + return revealInFileExplorer(node.repo.uri); + } + + @log() + private revealWorktreeInExplorer(nodeOrUrl: WorktreeNode | string) { + if (typeof nodeOrUrl === 'string') return revealInFileExplorer(Uri.parse(nodeOrUrl)); + if (!nodeOrUrl.is('worktree')) return undefined; - return WorktreeActions.revealInFileExplorer(node.worktree); + return revealInFileExplorer(nodeOrUrl.worktree.uri); } - @debug() + @log() private revert(node: CommitNode | FileRevisionAsCommitNode) { - if (!(node instanceof CommitNode) && !(node instanceof FileRevisionAsCommitNode)) return Promise.resolve(); + if (!node.isAny('commit', 'file-commit')) return Promise.resolve(); return RepoActions.revert(node.repoPath, node.ref); } - @debug() + @log() private setAsDefault(node: RemoteNode) { - if (!(node instanceof RemoteNode)) return Promise.resolve(); + if (!node.is('remote')) return Promise.resolve(); return node.setAsDefault(); } - @debug() + @log() private setBranchComparison(node: ViewNode, comparisonType: Exclude) { - if (!(node instanceof CompareBranchNode)) return undefined; + if (!node.is('compare-branch')) return undefined; return node.setComparisonType(comparisonType); } - @debug() + @log() private setShowRelativeDateMarkers(enabled: boolean) { return configuration.updateEffective('views.showRelativeDateMarkers', enabled); } - @debug() + @log() private async stageFile(node: CommitFileNode | FileRevisionAsCommitNode | StatusFileNode) { - if ( - !(node instanceof CommitFileNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof StatusFileNode) - ) { + if (!node.isAny('commit-file', 'file-commit') && !node.is('status-file')) { return; } @@ -765,132 +1006,155 @@ export class ViewCommands { void node.triggerChange(); } - @debug() + @log() private async stageDirectory(node: FolderNode) { - if (!(node instanceof FolderNode) || !node.relativePath) return; + if (!node.is('folder') || !node.relativePath) return; await this.container.git.stageDirectory(node.repoPath, node.relativePath); void node.triggerChange(); } - @debug() + @log() private star(node: BranchNode | RepositoryNode | RepositoryFolderNode) { - if ( - !(node instanceof BranchNode) && - !(node instanceof RepositoryNode) && - !(node instanceof RepositoryFolderNode) - ) { + if (!node.isAny('branch', 'repository', 'repo-folder')) { return Promise.resolve(); } return node.star(); } - @debug() + @log() private switch(node?: ViewNode) { return RepoActions.switchTo(getNodeRepoPath(node)); } - @debug() + @log() private switchTo(node?: ViewNode) { if (node instanceof ViewRefNode) { - return RepoActions.switchTo( - node.repoPath, - node instanceof BranchNode && node.branch.current ? undefined : node.ref, - ); + return RepoActions.switchTo(node.repoPath, node.is('branch') && node.branch.current ? undefined : node.ref); } return RepoActions.switchTo(getNodeRepoPath(node)); } - @debug() + @log() private async undoCommit(node: CommitNode | FileRevisionAsCommitNode) { - if (!(node instanceof CommitNode) && !(node instanceof FileRevisionAsCommitNode)) return; - - const repo = await this.container.git.getOrOpenScmRepository(node.repoPath); - const commit = await repo?.getCommit('HEAD'); - - if (commit?.hash !== node.ref.ref) { - void window.showWarningMessage( - `Commit ${GitReference.toString(node.ref, { - capitalize: true, - icon: false, - })} cannot be undone, because it is no longer the most recent commit.`, - ); + if (!node.isAny('commit', 'file-commit')) return; - return; - } - - await executeCoreGitCommand(CoreGitCommands.UndoCommit, node.repoPath); + await CommitActions.undoCommit(this.container, node.ref); } - @debug() + @log() private unsetAsDefault(node: RemoteNode) { - if (!(node instanceof RemoteNode)) return Promise.resolve(); + if (!node.is('remote')) return Promise.resolve(); return node.setAsDefault(false); } - @debug() + @log() private async unstageFile(node: CommitFileNode | FileRevisionAsCommitNode | StatusFileNode) { - if ( - !(node instanceof CommitFileNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof StatusFileNode) - ) { - return; - } + if (!node.isAny('commit-file', 'file-commit', 'status-file')) return; - await this.container.git.unStageFile(node.repoPath, node.file.path); + await this.container.git.unstageFile(node.repoPath, node.file.path); void node.triggerChange(); } - @debug() + @log() private async unstageDirectory(node: FolderNode) { - if (!(node instanceof FolderNode) || !node.relativePath) return; + if (!node.is('folder') || !node.relativePath) return; - await this.container.git.unStageDirectory(node.repoPath, node.relativePath); + await this.container.git.unstageDirectory(node.repoPath, node.relativePath); void node.triggerChange(); } - @debug() + @log() private unstar(node: BranchNode | RepositoryNode | RepositoryFolderNode) { - if ( - !(node instanceof BranchNode) && - !(node instanceof RepositoryNode) && - !(node instanceof RepositoryFolderNode) - ) { - return Promise.resolve(); + if (!node.isAny('branch', 'repository', 'repo-folder')) return Promise.resolve(); + return node.unstar(); + } + + @log() + private async compareHeadWith(node: ViewRefNode | ViewRefFileNode) { + if (node instanceof ViewRefFileNode) { + return this.compareFileWith(node.repoPath, node.uri, node.ref.ref, undefined, 'HEAD'); } - return node.unstar(); + if (!(node instanceof ViewRefNode)) return Promise.resolve(); + + const [ref1, ref2] = await CommitActions.getOrderedComparisonRefs( + this.container, + node.repoPath, + 'HEAD', + node.ref.ref, + ); + return this.container.searchAndCompareView.compare(node.repoPath, ref1, ref2); } - @debug() - private compareHeadWith(node: ViewRefNode) { + @log() + private compareBranchWithHead(node: BranchNode) { if (!(node instanceof ViewRefNode)) return Promise.resolve(); - return this.container.searchAndCompareView.compare(node.repoPath, 'HEAD', node.ref); + return this.container.searchAndCompareView.compare(node.repoPath, node.ref, 'HEAD'); + } + + @log() + private async compareWithMergeBase(node: BranchNode) { + if (!node.is('branch')) return Promise.resolve(); + + const branch = await this.container.git.getBranch(node.repoPath); + if (branch == null) return undefined; + + const commonAncestor = await this.container.git.getMergeBase(node.repoPath, branch.ref, node.ref.ref); + if (commonAncestor == null) return undefined; + + return this.container.searchAndCompareView.compare(node.repoPath, node.ref.ref, { + ref: commonAncestor, + label: `${branch.ref} (${shortenRevision(commonAncestor)})`, + }); } - @debug() + @log() + private async openChangedFileDiffsWithMergeBase(node: BranchNode) { + if (!node.is('branch')) return Promise.resolve(); + + const branch = await this.container.git.getBranch(node.repoPath); + if (branch == null) return undefined; + + const commonAncestor = await this.container.git.getMergeBase(node.repoPath, branch.ref, node.ref.ref); + if (commonAncestor == null) return undefined; + + return CommitActions.openComparisonChanges( + this.container, + { repoPath: node.repoPath, lhs: commonAncestor, rhs: node.ref.ref }, + { + title: `Changes between ${branch.ref} (${shortenRevision(commonAncestor)}) ${ + GlyphChars.ArrowLeftRightLong + } ${shortenRevision(node.ref.ref, { strings: { working: 'Working Tree' } })}`, + }, + ); + } + + @log() private compareWithUpstream(node: BranchNode) { - if (!(node instanceof BranchNode)) return Promise.resolve(); - if (node.branch.upstream == null) return Promise.resolve(); + if (!node.is('branch') || node.branch.upstream == null) return Promise.resolve(); return this.container.searchAndCompareView.compare(node.repoPath, node.ref, node.branch.upstream.name); } - @debug() - private compareWorkingWith(node: ViewRefNode) { + @log() + private compareWorkingWith(node: ViewRefNode | ViewRefFileNode) { + if (node instanceof ViewRefFileNode) { + return this.compareFileWith(node.repoPath, node.uri, node.ref.ref, undefined, ''); + } + if (!(node instanceof ViewRefNode)) return Promise.resolve(); return this.container.searchAndCompareView.compare(node.repoPath, '', node.ref); } - @debug() + @log() private async compareAncestryWithWorking(node: BranchNode) { - if (!(node instanceof BranchNode)) return undefined; + if (!node.is('branch')) return undefined; const branch = await this.container.git.getBranch(node.repoPath); if (branch == null) return undefined; @@ -898,28 +1162,51 @@ export class ViewCommands { const commonAncestor = await this.container.git.getMergeBase(node.repoPath, branch.ref, node.ref.ref); if (commonAncestor == null) return undefined; - return this.container.searchAndCompareView.compare( - node.repoPath, - { ref: commonAncestor, label: `ancestry with ${node.ref.ref} (${GitRevision.shorten(commonAncestor)})` }, - '', - ); + return this.container.searchAndCompareView.compare(node.repoPath, '', { + ref: commonAncestor, + label: `${branch.ref} (${shortenRevision(commonAncestor)})`, + }); } - @debug() - private compareWithSelected(node: ViewRefNode) { - if (!(node instanceof ViewRefNode)) return; + @log() + private compareWithSelected(node: ViewRefNode | ViewRefFileNode) { + if (!(node instanceof ViewRefNode) && !(node instanceof ViewRefFileNode)) return; this.container.searchAndCompareView.compareWithSelected(node.repoPath, node.ref); } - @debug() - private selectForCompare(node: ViewRefNode) { - if (!(node instanceof ViewRefNode)) return; + @log() + private selectForCompare(node: ViewRefNode | ViewRefFileNode) { + if (!(node instanceof ViewRefNode) && !(node instanceof ViewRefFileNode)) return; this.container.searchAndCompareView.selectForCompare(node.repoPath, node.ref); } - @debug() + private async compareFileWith( + repoPath: string, + lhsUri: Uri, + lhsRef: string, + rhsUri: Uri | undefined, + rhsRef: string, + ) { + if (rhsUri == null) { + rhsUri = await this.container.git.getWorkingUri(repoPath, lhsUri); + } + + return executeCommand(Commands.DiffWith, { + repoPath: repoPath, + lhs: { + sha: lhsRef, + uri: lhsUri, + }, + rhs: { + sha: rhsRef, + uri: rhsUri ?? lhsUri, + }, + }); + } + + @log() private compareFileWithSelected(node: ViewRefFileNode) { if (this._selectedFile == null || !(node instanceof ViewRefFileNode) || node.ref == null) { return Promise.resolve(); @@ -933,24 +1220,14 @@ export class ViewCommands { const selected = this._selectedFile; this._selectedFile = undefined; - void setContext(ContextKeys.ViewsCanCompareFile, false); + void setContext('gitlens:views:canCompare:file', false); - return executeCommand(Commands.DiffWith, { - repoPath: selected.repoPath, - lhs: { - sha: selected.ref, - uri: selected.uri!, - }, - rhs: { - sha: node.ref.ref, - uri: node.uri, - }, - }); + return this.compareFileWith(selected.repoPath!, selected.uri!, selected.ref, node.uri, node.ref.ref); } private _selectedFile: CompareSelectedInfo | undefined; - @debug() + @log() private selectFileForCompare(node: ViewRefFileNode) { if (!(node instanceof ViewRefFileNode) || node.ref == null) return; @@ -959,44 +1236,58 @@ export class ViewCommands { repoPath: node.repoPath, uri: node.uri, }; - void setContext(ContextKeys.ViewsCanCompareFile, true); + void setContext('gitlens:views:canCompare:file', true); } - @debug() - private async openAllChanges(node: CommitNode | StashNode | ResultsFilesNode, options?: TextDocumentShowOptions) { - if (!(node instanceof CommitNode) && !(node instanceof StashNode) && !(node instanceof ResultsFilesNode)) { - return undefined; - } + @log() + private async openAllChanges( + node: + | BranchTrackingStatusFilesNode + | BranchTrackingStatusNode + | CompareResultsNode + | CommitNode + | ResultsFilesNode + | StashNode, + options?: TextDocumentShowOptions & { title?: string }, + individually?: boolean, + ) { + if (node.isAny('compare-results', 'results-files', 'tracking-status', 'tracking-status-files')) { + const comparison = await node.getFilesComparison(); + if (!comparison?.files.length) return undefined; - if (node instanceof ResultsFilesNode) { - const { files: diff } = await node.getFilesQueryResults(); - if (diff == null || diff.length === 0) return undefined; + if (comparison.title != null) { + options = { ...options, title: comparison.title }; + } - return CommitActions.openAllChanges( - diff, - { - repoPath: node.repoPath, - ref1: node.ref1, - ref2: node.ref2, - }, + return (individually ? CommitActions.openAllChangesIndividually : CommitActions.openAllChanges)( + comparison.files, + { repoPath: comparison.repoPath, lhs: comparison.ref1, rhs: comparison.ref2 }, options, ); } - return CommitActions.openAllChanges(node.commit, options); + if (!node.isAny('commit', 'stash')) return undefined; + + return (individually ? CommitActions.openAllChangesIndividually : CommitActions.openAllChanges)( + node.commit, + options, + ); } - @debug() - private openChanges(node: ViewRefFileNode | MergeConflictFileNode | StatusFileNode) { - if ( - !(node instanceof ViewRefFileNode) && - !(node instanceof MergeConflictFileNode) && - !(node instanceof StatusFileNode) - ) { - return; - } + @log() + private openCommitOnRemote(node: ViewRefNode, nodes?: ViewRefNode[], clipboard?: boolean) { + const refs = nodes?.length ? nodes.map(n => n.ref) : [node.ref]; - if (node instanceof MergeConflictFileNode) { + return executeCommand(Commands.OpenOnRemote, { + repoPath: refs[0].repoPath, + resource: refs.map(r => ({ type: RemoteResourceType.Commit, sha: r.ref })), + clipboard: clipboard, + }); + } + + @log() + private openChanges(node: ViewRefFileNode | MergeConflictFileNode | StatusFileNode) { + if (node.is('conflict-file')) { void executeCommand(Commands.DiffWith, { lhs: { sha: node.status.HEAD.ref, @@ -1017,6 +1308,8 @@ export class ViewCommands { return; } + if (!(node instanceof ViewRefFileNode) && !node.is('status-file')) return; + const command = node.getCommand(); if (command?.arguments == null) return; @@ -1044,82 +1337,39 @@ export class ViewCommands { // }); } - @debug() + @log() private async openAllChangesWithWorking( - node: CommitNode | StashNode | ResultsFilesNode, - options?: TextDocumentShowOptions, + node: + | BranchTrackingStatusFilesNode + | BranchTrackingStatusNode + | CompareResultsNode + | CommitNode + | ResultsFilesNode + | StashNode, + options?: TextDocumentShowOptions & { title?: string }, + individually?: boolean, ) { - if (!(node instanceof CommitNode) && !(node instanceof StashNode) && !(node instanceof ResultsFilesNode)) { - return undefined; - } - - if (node instanceof ResultsFilesNode) { - const { files: diff } = await node.getFilesQueryResults(); - if (diff == null || diff.length === 0) return undefined; - - return CommitActions.openAllChangesWithWorking( - diff, - { - repoPath: node.repoPath, - ref: node.ref1 || node.ref2, - }, - options, - ); + if (node.isAny('compare-results', 'results-files', 'tracking-status', 'tracking-status-files')) { + const comparison = await node.getFilesComparison(); + if (!comparison?.files.length) return undefined; + + return ( + individually + ? CommitActions.openAllChangesWithWorkingIndividually + : CommitActions.openAllChangesWithWorking + )(comparison.files, { repoPath: comparison.repoPath, ref: comparison.ref1 || comparison.ref2 }, options); } - return CommitActions.openAllChangesWithWorking(node.commit, options); - - // options = { preserveFocus: false, preview: false, ...options }; - - // let repoPath: string; - // let files; - // let ref: string; - - // if (node instanceof ResultsFilesNode) { - // const { diff } = await node.getFilesQueryResults(); - // if (diff == null || diff.length === 0) return; - - // repoPath = node.repoPath; - // files = diff; - // ref = node.ref1 || node.ref2; - // } else { - // repoPath = node.commit.repoPath; - // files = node.commit.files; - // ref = node.commit.sha; - // } + if (!node.isAny('commit', 'stash')) return undefined; - // if (files.length > 20) { - // const result = await window.showWarningMessage( - // `Are you sure you want to open all ${files.length} files?`, - // { title: 'Yes' }, - // { title: 'No', isCloseAffordance: true }, - // ); - // if (result == null || result.title === 'No') return; - // } - - // for (const file of files) { - // if (file.status === 'A' || file.status === 'D') continue; - - // const args: DiffWithWorkingCommandArgs = { - // showOptions: options, - // }; - - // const uri = GitUri.fromFile(file, repoPath, ref); - // await executeCommand(Commands.DiffWithWorking, uri, args); - // } + return ( + individually ? CommitActions.openAllChangesWithWorkingIndividually : CommitActions.openAllChangesWithWorking + )(node.commit, options); } - @debug() + @log() private async openChangesWithWorking(node: ViewRefFileNode | MergeConflictFileNode | StatusFileNode) { - if ( - !(node instanceof ViewRefFileNode) && - !(node instanceof MergeConflictFileNode) && - !(node instanceof StatusFileNode) - ) { - return Promise.resolve(); - } - - if (node instanceof StatusFileNode) { + if (node.is('status-file')) { return executeEditorCommand(Commands.DiffWithWorking, undefined, { uri: node.uri, showOptions: { @@ -1127,7 +1377,9 @@ export class ViewCommands { preview: true, }, }); - } else if (node instanceof MergeConflictFileNode) { + } + + if (node.is('conflict-file')) { return executeEditorCommand(Commands.DiffWithWorking, undefined, { uri: node.baseUri, showOptions: { @@ -1135,7 +1387,9 @@ export class ViewCommands { preview: true, }, }); - } else if (node instanceof FileRevisionAsCommitNode && node.commit.file?.hasConflicts) { + } + + if (node.is('file-commit') && node.commit.file?.hasConflicts) { const baseUri = await node.getConflictBaseUri(); if (baseUri != null) { return executeEditorCommand(Commands.DiffWithWorking, undefined, { @@ -1148,33 +1402,32 @@ export class ViewCommands { } } + if (!(node instanceof ViewRefFileNode)) return Promise.resolve(); + return CommitActions.openChangesWithWorking(node.file, { repoPath: node.repoPath, - ref: node.ref.ref, + ref: node.is('results-file') ? node.ref2 : node.ref.ref, }); } - @debug() + @log() private async openPreviousChangesWithWorking(node: ViewRefFileNode) { if (!(node instanceof ViewRefFileNode)) return Promise.resolve(); return CommitActions.openChangesWithWorking(node.file, { repoPath: node.repoPath, - ref: `${node.ref.ref}^`, + ref: node.is('results-file') ? node.ref1 : `${node.ref.ref}^`, }); } - @debug() + @log() private openFile( node: ViewRefFileNode | MergeConflictFileNode | StatusFileNode | FileHistoryNode | LineHistoryNode, options?: TextDocumentShowOptions, ) { if ( !(node instanceof ViewRefFileNode) && - !(node instanceof MergeConflictFileNode) && - !(node instanceof StatusFileNode) && - !(node instanceof FileHistoryNode) && - !(node instanceof LineHistoryNode) + !node.isAny('conflict-file', 'status-file', 'file-history', 'line-history') ) { return Promise.resolve(); } @@ -1186,23 +1439,45 @@ export class ViewCommands { }); } - @debug() - private async openFiles(node: CommitNode | StashNode | ResultsFilesNode) { - if (!(node instanceof CommitNode) && !(node instanceof StashNode) && !(node instanceof ResultsFilesNode)) { - return undefined; + @log() + private async openFiles( + node: BranchTrackingStatusFilesNode | CompareResultsNode | CommitNode | StashNode | ResultsFilesNode, + ) { + if (node.isAny('compare-results', 'results-files', 'tracking-status', 'tracking-status-files')) { + const comparison = await node.getFilesComparison(); + if (!comparison?.files.length) return undefined; + + return CommitActions.openFiles(comparison.files, { + repoPath: comparison.repoPath, + ref: comparison.ref1 || comparison.ref2, + }); } + if (!node.isAny('commit', 'stash')) return undefined; + + return CommitActions.openFiles(node.commit); + } - if (node instanceof ResultsFilesNode) { - const { files: diff } = await node.getFilesQueryResults(); - if (diff == null || diff.length === 0) return undefined; + @log() + private async openOnlyChangedFiles( + node: BranchTrackingStatusFilesNode | CompareResultsNode | CommitNode | StashNode | ResultsFilesNode, + ) { + if ( + node.is('compare-results') || + node.is('results-files') || + node.is('tracking-status') || + node.is('tracking-status-files') + ) { + const comparison = await node.getFilesComparison(); + if (!comparison?.files.length) return undefined; - return CommitActions.openFiles(diff, node.repoPath, node.ref1 || node.ref2); + return CommitActions.openOnlyChangedFiles(comparison.files); } + if (!node.isAny('commit', 'stash')) return undefined; - return CommitActions.openFiles(node.commit); + return CommitActions.openOnlyChangedFiles(node.commit); } - @debug() + @log() private async openRevision( node: | CommitFileNode @@ -1213,14 +1488,7 @@ export class ViewCommands { | StatusFileNode, options?: OpenFileAtRevisionCommandArgs, ) { - if ( - !(node instanceof CommitFileNode) && - !(node instanceof FileRevisionAsCommitNode) && - !(node instanceof ResultsFileNode) && - !(node instanceof StashFileNode) && - !(node instanceof MergeConflictFileNode) && - !(node instanceof StatusFileNode) - ) { + if (!node.isAny('commit-file', 'file-commit', 'results-file', 'stash-file', 'conflict-file', 'status-file')) { return Promise.resolve(); } @@ -1228,13 +1496,13 @@ export class ViewCommands { let uri = options.revisionUri; if (uri == null) { - if (node instanceof ResultsFileNode || node instanceof MergeConflictFileNode) { + if (node.isAny('results-file', 'conflict-file')) { uri = this.container.git.getRevisionUri(node.uri); } else { uri = node.commit.file?.status === 'D' ? this.container.git.getRevisionUri( - (await node.commit.getPreviousSha()) ?? GitRevision.deletedOrMissing, + (await node.commit.getPreviousSha()) ?? deletedOrMissing, node.commit.file.path, node.commit.repoPath, ) @@ -1245,19 +1513,121 @@ export class ViewCommands { return CommitActions.openFileAtRevision(uri, options.showOptions ?? { preserveFocus: true, preview: false }); } - @debug() - private async openRevisions(node: CommitNode | StashNode | ResultsFilesNode, _options?: TextDocumentShowOptions) { - if (!(node instanceof CommitNode) && !(node instanceof StashNode) && !(node instanceof ResultsFilesNode)) { - return undefined; + @log() + private async openRevisions( + node: BranchTrackingStatusFilesNode | CompareResultsNode | CommitNode | StashNode | ResultsFilesNode, + options?: TextDocumentShowOptions, + ) { + if (node.isAny('compare-results', 'results-files', 'tracking-status', 'tracking-status-files')) { + const comparison = await node.getFilesComparison(); + if (!comparison?.files.length) return undefined; + + return CommitActions.openFilesAtRevision(comparison.files, { + repoPath: comparison.repoPath, + lhs: comparison.ref2, + rhs: comparison.ref1, + }); } + if (!node.isAny('commit', 'stash')) return undefined; - if (node instanceof ResultsFilesNode) { - const { files: diff } = await node.getFilesQueryResults(); - if (diff == null || diff.length === 0) return undefined; + return CommitActions.openFilesAtRevision(node.commit, options); + } + + @log() + private async setResultsCommitsFilter(node: ViewNode, filter: boolean) { + if (!node?.isAny('compare-results', 'compare-branch')) return; + + const repo = this.container.git.getRepository(node.repoPath); + if (repo == null) return; + + if (filter) { + let authors = node.getState('filterCommits'); + if (authors == null) { + const current = await this.container.git.getCurrentUser(repo.uri); + authors = current != null ? [current] : undefined; + } + + const result = await showContributorsPicker( + this.container, + repo, + 'Filter Commits', + repo.virtual ? 'Choose a contributor to show commits from' : 'Choose contributors to show commits from', + { + appendReposToTitle: true, + clearButton: true, + multiselect: !repo.virtual, + picked: c => authors?.some(u => matchContributor(c, u)) ?? false, + }, + ); + if (result == null) return; - return CommitActions.openFilesAtRevision(diff, node.repoPath, node.ref1, node.ref2); + if (result.length === 0) { + filter = false; + node.deleteState('filterCommits'); + } else { + node.storeState('filterCommits', result); + } + } else if (repo != null) { + node.deleteState('filterCommits'); + } else { + node.deleteState('filterCommits'); } - return CommitActions.openFilesAtRevision(node.commit); + void node.triggerChange(true); + } +} + +async function copyNode(type: ClipboardType, active: ViewNode | undefined, selection: ViewNode[]): Promise { + selection = Array.isArray(selection) ? selection : active != null ? [active] : []; + if (selection.length === 0) return; + + const data = ( + await mapAsync( + selection, + n => n.toClipboard?.(type), + s => Boolean(s?.trim()), + ) + ).join('\n'); + await env.clipboard.writeText(data); +} + +async function copyNodeUrl(active: ViewNode | undefined, selection: ViewNode[]): Promise { + const urls = await getNodeUrls(active, selection); + if (urls.length === 0) return; + + await env.clipboard.writeText(urls.join('\n')); +} + +async function openNodeUrl(active: ViewNode | undefined, selection: ViewNode[]): Promise { + const urls = await getNodeUrls(active, selection); + if (urls.length === 0) return; + + if (urls.length > 10) { + const confirm = { title: 'Open' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `Are you sure you want to open ${urls.length} URLs?`, + { modal: true }, + confirm, + cancel, + ); + if (result !== confirm) return; + } + + for (const url of urls) { + if (url == null) continue; + + void openUrl(url); } } + +function getNodeUrls(active: ViewNode | undefined, selection: ViewNode[]): Promise { + selection = Array.isArray(selection) ? selection : active != null ? [active] : []; + if (selection.length === 0) return Promise.resolve([]); + + return mapAsync( + selection, + n => n.getUrl?.(), + s => Boolean(s?.trim()), + ); +} diff --git a/src/views/viewDecorationProvider.ts b/src/views/viewDecorationProvider.ts index 34c943a87e802..9fa5d2cb70e9e 100644 --- a/src/views/viewDecorationProvider.ts +++ b/src/views/viewDecorationProvider.ts @@ -1,7 +1,10 @@ -import type { CancellationToken, Event, FileDecoration, FileDecorationProvider, Uri } from 'vscode'; -import { Disposable, EventEmitter, ThemeColor, window } from 'vscode'; -import { GlyphChars } from '../constants'; -import { GitBranchStatus } from '../git/models/branch'; +import type { CancellationToken, Event, FileDecoration, FileDecorationProvider } from 'vscode'; +import { Disposable, EventEmitter, ThemeColor, Uri, window } from 'vscode'; +import { getQueryDataFromScmGitUri } from '../@types/vscode.git.uri'; +import { GlyphChars, Schemes } from '../constants'; +import type { Colors } from '../constants.colors'; +import type { GitBranchStatus } from '../git/models/branch'; +import type { GitFileStatus } from '../git/models/file'; export class ViewFileDecorationProvider implements FileDecorationProvider, Disposable { private readonly _onDidChange = new EventEmitter(); @@ -11,25 +14,7 @@ export class ViewFileDecorationProvider implements FileDecorationProvider, Dispo private readonly disposable: Disposable; constructor() { - this.disposable = Disposable.from( - // Register the current branch decorator separately (since we can only have 2 char's per decoration) - window.registerFileDecorationProvider({ - provideFileDecoration: (uri, token) => { - if (uri.scheme !== 'gitlens-view') return undefined; - - if (uri.authority === 'branch') { - return this.provideBranchCurrentDecoration(uri, token); - } - - if (uri.authority === 'remote') { - return this.provideRemoteDefaultDecoration(uri, token); - } - - return undefined; - }, - }), - window.registerFileDecorationProvider(this), - ); + this.disposable = Disposable.from(window.registerFileDecorationProvider(this)); } dispose(): void { @@ -37,153 +22,346 @@ export class ViewFileDecorationProvider implements FileDecorationProvider, Dispo } provideFileDecoration(uri: Uri, token: CancellationToken): FileDecoration | undefined { - if (uri.scheme !== 'gitlens-view') return undefined; - - switch (uri.authority) { - case 'branch': - return this.provideBranchStatusDecoration(uri, token); - case 'commit-file': - return this.provideCommitFileStatusDecoration(uri, token); + if (uri.scheme === Schemes.Git) { + const data = getQueryDataFromScmGitUri(uri); + if (data?.decoration != null) { + uri = Uri.parse(data?.decoration); + } } - return undefined; + return provideViewNodeDecoration(uri, token); } +} - provideCommitFileStatusDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { - const [, , status] = uri.path.split('/'); +function provideViewNodeDecoration(uri: Uri, token: CancellationToken): FileDecoration | undefined { + if (uri.scheme !== 'gitlens-view') return undefined; - switch (status) { - case '!': - return { - badge: 'I', - color: new ThemeColor('gitlens.decorations.ignoredForegroundColor'), - tooltip: 'Ignored', - }; - case '?': - return { - badge: 'U', - color: new ThemeColor('gitlens.decorations.untrackedForegroundColor'), - tooltip: 'Untracked', - }; - case 'A': - return { - badge: 'A', - color: new ThemeColor('gitlens.decorations.addedForegroundColor'), - tooltip: 'Added', - }; - case 'C': - return { - badge: 'C', - color: new ThemeColor('gitlens.decorations.copiedForegroundColor'), - tooltip: 'Copied', - }; - case 'D': - return { - badge: 'D', - color: new ThemeColor('gitlens.decorations.deletedForegroundColor'), - tooltip: 'Deleted', - }; - case 'M': - return { - badge: 'M', - // color: new ThemeColor('gitlens.decorations.modifiedForegroundColor'), - tooltip: 'Modified', - }; - case 'R': - return { - badge: 'R', - color: new ThemeColor('gitlens.decorations.renamedForegroundColor'), - tooltip: 'Renamed', - }; - default: - return undefined; - } + switch (uri.authority) { + case 'branch': + return getBranchDecoration(uri, token); + case 'commit-file': + return getCommitFileStatusDecoration(uri, token); + case 'remote': + return getRemoteDecoration(uri, token); + case 'repositories': + return getRepositoriesDecoration(uri, token); + case 'repository': + return getRepositoryDecoration(uri, token); + case 'status': + return getStatusDecoration(uri, token); + case 'workspace': + return getWorkspaceDecoration(uri, token); + case 'worktree': + return getWorktreeDecoration(uri, token); } - provideBranchStatusDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { - const [, , status] = uri.path.split('/'); + return undefined; +} - switch (status as GitBranchStatus) { - case GitBranchStatus.Ahead: - return { - badge: '▲', - color: new ThemeColor('gitlens.decorations.branchAheadForegroundColor'), - tooltip: 'Ahead', - }; - case GitBranchStatus.Behind: - return { - badge: 'â–ŧ', - color: new ThemeColor('gitlens.decorations.branchBehindForegroundColor'), - tooltip: 'Behind', - }; - case GitBranchStatus.Diverged: - return { - badge: 'â–ŧ▲', - color: new ThemeColor('gitlens.decorations.branchDivergedForegroundColor'), - tooltip: 'Diverged', - }; - case GitBranchStatus.MissingUpstream: - return { - badge: '!', - color: new ThemeColor('gitlens.decorations.branchMissingUpstreamForegroundColor'), - tooltip: 'Missing Upstream', - }; - case GitBranchStatus.UpToDate: - return { - badge: '', - color: new ThemeColor('gitlens.decorations.branchUpToDateForegroundColor'), - tooltip: 'Up to Date', - }; - case GitBranchStatus.Unpublished: - return { - badge: '▲+', - color: new ThemeColor('gitlens.decorations.branchUnpublishedForegroundColor'), - tooltip: 'Unpublished', - }; - default: - return undefined; - } +interface BranchViewDecoration { + status: GitBranchStatus | 'unpublished'; + current?: boolean; + starred?: boolean; + worktree?: { opened: boolean }; + showStatusOnly?: boolean; +} + +function getBranchDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { + const state = getViewDecoration<'branch'>(uri); + + let decoration: FileDecoration; + + switch (state?.status) { + case 'ahead': + decoration = { + badge: '\u00a0\u00a0', + color: new ThemeColor('gitlens.decorations.branchAheadForegroundColor' satisfies Colors), + tooltip: 'Ahead', + }; + break; + case 'behind': + decoration = { + badge: '\u00a0\u00a0', + color: new ThemeColor('gitlens.decorations.branchBehindForegroundColor' satisfies Colors), + tooltip: 'Behind', + }; + break; + case 'diverged': + decoration = { + badge: '\u00a0\u00a0', + color: new ThemeColor('gitlens.decorations.branchDivergedForegroundColor' satisfies Colors), + tooltip: 'Diverged', + }; + break; + case 'missingUpstream': + decoration = { + badge: GlyphChars.Warning, + color: new ThemeColor('gitlens.decorations.branchMissingUpstreamForegroundColor' satisfies Colors), + tooltip: 'Missing Upstream', + }; + break; + case 'upToDate': + decoration = { + badge: '\u00a0\u00a0', + color: new ThemeColor('gitlens.decorations.branchUpToDateForegroundColor' satisfies Colors), + tooltip: 'Up to Date', + }; + break; + case 'unpublished': + decoration = { + badge: '\u00a0\u00a0', + color: new ThemeColor('gitlens.decorations.branchUnpublishedForegroundColor' satisfies Colors), + tooltip: 'Unpublished', + }; + break; + default: + decoration = { badge: '\u00a0\u00a0' }; + break; } - provideBranchCurrentDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { - const [, , status, current] = uri.path.split('/'); - - if (!current) return undefined; - - let color; - switch (status as GitBranchStatus) { - case GitBranchStatus.Ahead: - color = new ThemeColor('gitlens.decorations.branchAheadForegroundColor'); - break; - case GitBranchStatus.Behind: - color = new ThemeColor('gitlens.decorations.branchBehindForegroundColor'); - break; - case GitBranchStatus.Diverged: - color = new ThemeColor('gitlens.decorations.branchDivergedForegroundColor'); - break; - case GitBranchStatus.UpToDate: - color = new ThemeColor('gitlens.decorations.branchUpToDateForegroundColor'); - break; - case GitBranchStatus.Unpublished: - color = new ThemeColor('gitlens.decorations.branchUnpublishedForegroundColor'); - break; - } + if (state?.showStatusOnly) return decoration; + if (state?.current) { return { + ...decoration, badge: GlyphChars.Check, - color: color, - tooltip: 'Current Branch', + tooltip: 'Current', + }; + } + + if (state?.worktree?.opened) { + return { + ...decoration, + badge: '●', + tooltip: 'Opened Worktree', }; } - provideRemoteDefaultDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { - const [, isDefault] = uri.path.split('/'); + if (state?.starred) { + return { + ...decoration, + badge: '★', + tooltip: 'Favorited', + }; + } + + return decoration; +} + +interface CommitFileViewDecoration { + status: GitFileStatus; +} + +function getCommitFileStatusDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { + const state = getViewDecoration<'commit-file'>(uri); + + switch (state?.status) { + case '!': + return { + badge: 'I', + color: new ThemeColor('gitlens.decorations.ignoredForegroundColor' satisfies Colors), + tooltip: 'Ignored', + }; + case '?': + return { + badge: 'U', + color: new ThemeColor('gitlens.decorations.untrackedForegroundColor' satisfies Colors), + tooltip: 'Untracked', + }; + case 'A': + return { + badge: 'A', + color: new ThemeColor('gitlens.decorations.addedForegroundColor' satisfies Colors), + tooltip: 'Added', + }; + case 'C': + return { + badge: 'C', + color: new ThemeColor('gitlens.decorations.copiedForegroundColor' satisfies Colors), + tooltip: 'Copied', + }; + case 'D': + return { + badge: 'D', + color: new ThemeColor('gitlens.decorations.deletedForegroundColor' satisfies Colors), + tooltip: 'Deleted', + }; + case 'M': + return { + badge: 'M', + // Commented out until we can control the color to only apply to the badge, as the color is applied to the entire decoration and its too much + // https://github.com/microsoft/vscode/issues/182098 + // color: new ThemeColor('gitlens.decorations.modifiedForegroundColor' satisfies Colors), + tooltip: 'Modified', + }; + case 'R': + return { + badge: 'R', + color: new ThemeColor('gitlens.decorations.renamedForegroundColor' satisfies Colors), + tooltip: 'Renamed', + }; + } + + return undefined; +} + +interface RemoteViewDecoration { + default?: boolean; +} - if (!isDefault) return undefined; +function getRemoteDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { + const state = getViewDecoration<'remote'>(uri); + if (state?.default) { return { badge: GlyphChars.Check, tooltip: 'Default Remote', }; } + + return undefined; +} + +interface StatusViewDecoration { + status: 'merging' | 'rebasing'; + conflicts?: boolean; +} + +function getStatusDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { + const state = getViewDecoration<'status'>(uri); + + switch (state?.status) { + case 'rebasing': + case 'merging': + if (state?.conflicts) { + return { + badge: '!', + color: new ThemeColor( + 'gitlens.decorations.statusMergingOrRebasingConflictForegroundColor' satisfies Colors, + ), + }; + } + + return { + color: new ThemeColor('gitlens.decorations.statusMergingOrRebasingForegroundColor' satisfies Colors), + }; + } + + return undefined; +} + +interface RepositoriesViewDecoration { + currentWorkspace?: boolean; +} + +function getRepositoriesDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { + const state = getViewDecoration<'repositories'>(uri); + + if (state?.currentWorkspace) { + return { + badge: '●', + color: new ThemeColor('gitlens.decorations.workspaceCurrentForegroundColor' satisfies Colors), + tooltip: '', + }; + } + + return undefined; +} + +interface RepositoryViewDecoration { + state?: 'open' | 'missing'; + workspace?: boolean; +} + +function getRepositoryDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { + const state = getViewDecoration<'repository'>(uri); + if (!state?.workspace) return undefined; + + switch (state?.state) { + case 'open': + return { + badge: '●', + color: new ThemeColor('gitlens.decorations.workspaceRepoOpenForegroundColor' satisfies Colors), + tooltip: '', + }; + case 'missing': + return { + badge: '?', + color: new ThemeColor('gitlens.decorations.workspaceRepoMissingForegroundColor' satisfies Colors), + tooltip: '', + }; + } + + return undefined; +} + +interface WorkspaceViewDecoration { + current?: boolean; +} + +function getWorkspaceDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { + const state = getViewDecoration<'workspace'>(uri); + + if (state?.current) { + return { + badge: '●', + color: new ThemeColor('gitlens.decorations.workspaceCurrentForegroundColor' satisfies Colors), + tooltip: '', + }; + } + + return undefined; +} + +interface WorktreeViewDecoration { + hasChanges?: boolean; + missing?: boolean; +} + +function getWorktreeDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { + const state = getViewDecoration<'worktree'>(uri); + + if (state?.missing) { + return { + badge: GlyphChars.Warning, + color: new ThemeColor('gitlens.decorations.worktreeMissingForegroundColor' satisfies Colors), + tooltip: '', + }; + } + + if (state?.hasChanges) { + return { + badge: '●', + color: new ThemeColor('gitlens.decorations.worktreeHasUncommittedChangesForegroundColor' as Colors), + tooltip: 'Has Uncommitted Changes', + }; + } + + return undefined; +} + +type ViewDecorations = { + branch: BranchViewDecoration; + 'commit-file': CommitFileViewDecoration; + remote: RemoteViewDecoration; + repositories: RepositoriesViewDecoration; + repository: RepositoryViewDecoration; + status: StatusViewDecoration; + workspace: WorkspaceViewDecoration; + worktree: WorktreeViewDecoration; +}; + +export function createViewDecorationUri(type: T, state: ViewDecorations[T]): Uri { + const query = new URLSearchParams(); + query.set('state', JSON.stringify(state)); + + return Uri.parse(`gitlens-view://${type}?${query.toString()}`); +} + +function getViewDecoration(uri: Uri): ViewDecorations[T] | undefined { + const query = new URLSearchParams(uri.query); + const state = query.get('state'); + if (state == null) return undefined; + + return JSON.parse(state) as ViewDecorations[T]; } diff --git a/src/views/workspacesView.ts b/src/views/workspacesView.ts new file mode 100644 index 0000000000000..ecfa8c6c2d14d --- /dev/null +++ b/src/views/workspacesView.ts @@ -0,0 +1,339 @@ +import type { CancellationToken, ConfigurationChangeEvent, Disposable } from 'vscode'; +import { ProgressLocation, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; +import type { WorkspacesViewConfig } from '../config'; +import { previewBadge, urls } from '../constants'; +import { Commands } from '../constants.commands'; +import type { Container } from '../container'; +import { unknownGitUri } from '../git/gitUri'; +import type { Repository } from '../git/models/repository'; +import { ensurePlusFeaturesEnabled } from '../plus/gk/utils'; +import { gate } from '../system/decorators/gate'; +import { debug } from '../system/decorators/log'; +import { executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { openUrl, openWorkspace } from '../system/vscode/utils'; +import { ViewNode } from './nodes/abstract/viewNode'; +import { MessageNode } from './nodes/common'; +import { RepositoriesNode } from './nodes/repositoriesNode'; +import { RepositoryNode } from './nodes/repositoryNode'; +import type { WorkspaceMissingRepositoryNode } from './nodes/workspaceMissingRepositoryNode'; +import { WorkspaceNode } from './nodes/workspaceNode'; +import { disposeChildren, ViewBase } from './viewBase'; +import { registerViewCommand } from './viewCommands'; + +export class WorkspacesViewNode extends ViewNode<'workspaces', WorkspacesView> { + constructor(view: WorkspacesView) { + super('workspaces', unknownGitUri, view); + } + + private _children: (WorkspaceNode | MessageNode | RepositoriesNode)[] | undefined; + + async getChildren(): Promise { + if (this._children == null) { + const children: (WorkspaceNode | MessageNode | RepositoriesNode)[] = []; + + const { cloudWorkspaces, cloudWorkspaceInfo, localWorkspaces, localWorkspaceInfo } = + await this.view.container.workspaces.getWorkspaces(); + + if (cloudWorkspaces.length || localWorkspaces.length) { + children.push(new RepositoriesNode(this.view)); + + for (const workspace of cloudWorkspaces) { + children.push(new WorkspaceNode(this.uri, this.view, this, workspace)); + } + + if (cloudWorkspaceInfo != null) { + children.push(new MessageNode(this.view, this, cloudWorkspaceInfo)); + } + + for (const workspace of localWorkspaces) { + children.push(new WorkspaceNode(this.uri, this.view, this, workspace)); + } + + if (cloudWorkspaces.length === 0 && cloudWorkspaceInfo == null) { + children.push(new MessageNode(this.view, this, 'No cloud workspaces found.')); + } + + if (localWorkspaceInfo != null) { + children.push(new MessageNode(this.view, this, localWorkspaceInfo)); + } + } + + this._children = children; + } + + return this._children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Workspaces', TreeItemCollapsibleState.Expanded); + return item; + } + + @gate() + @debug() + override refresh() { + if (this._children == null) return; + + disposeChildren(this._children); + this._children = undefined; + } +} + +export class WorkspacesView extends ViewBase<'workspaces', WorkspacesViewNode, WorkspacesViewConfig> { + protected readonly configKey = 'workspaces'; + private _disposable: Disposable | undefined; + + constructor(container: Container) { + super(container, 'workspaces', 'Workspaces', 'workspacesView'); + + this.description = previewBadge; + this.disposables.push(container.workspaces.onDidResetWorkspaces(() => void this.refresh(true))); + } + + override dispose() { + this._disposable?.dispose(); + super.dispose(); + } + + protected getRoot() { + return new WorkspacesViewNode(this); + } + + override async show(options?: { preserveFocus?: boolean | undefined }): Promise { + if (!(await ensurePlusFeaturesEnabled())) return; + return super.show(options); + } + + async findWorkspaceNode(workspaceId: string, token?: CancellationToken) { + return this.findNode((n: any) => n.workspace?.id === workspaceId, { + allowPaging: false, + maxDepth: 2, + canTraverse: n => { + if (n instanceof WorkspacesViewNode) return true; + + return false; + }, + token: token, + }); + } + + async revealWorkspaceNode( + workspaceId: string, + options?: { + select?: boolean; + focus?: boolean; + expand?: boolean | number; + }, + ) { + return window.withProgress( + { + location: ProgressLocation.Notification, + title: `Revealing workspace ${workspaceId} in the side bar...`, + cancellable: true, + }, + async (_progress, token) => { + const node = await this.findWorkspaceNode(workspaceId, token); + if (node == null) return undefined; + + await this.ensureRevealNode(node, options); + + return node; + }, + ); + } + + protected registerCommands(): Disposable[] { + void this.container.viewCommands; + + return [ + registerViewCommand(this.getQualifiedCommand('info'), () => openUrl(urls.workspaces), this), + registerViewCommand( + this.getQualifiedCommand('copy'), + () => executeCommand(Commands.ViewsCopy, this.activeSelection, this.selection), + this, + ), + registerViewCommand( + this.getQualifiedCommand('refresh'), + () => { + this.container.workspaces.resetWorkspaces(); + }, + this, + ), + registerViewCommand(this.getQualifiedCommand('addRepos'), async (node: WorkspaceNode) => { + await this.container.workspaces.addCloudWorkspaceRepos(node.workspace.id); + void node.getParent()?.triggerChange(true); + }), + registerViewCommand(this.getQualifiedCommand('addReposFromLinked'), async (node: RepositoriesNode) => { + await this.container.workspaces.addMissingCurrentWorkspaceRepos({ force: true }); + void node.getParent()?.triggerChange(true); + }), + registerViewCommand( + this.getQualifiedCommand('convert'), + async (node: RepositoriesNode) => { + const repos: Repository[] = []; + for (const child of node.getChildren()) { + if (child instanceof RepositoryNode) { + repos.push(child.repo); + } + } + + if (repos.length === 0) return; + await this.container.workspaces.createCloudWorkspace({ repos: repos }); + void this.ensureRoot().triggerChange(true); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('create'), + async () => { + await this.container.workspaces.createCloudWorkspace(); + void this.ensureRoot().triggerChange(true); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('createLocal'), + async (node: WorkspaceNode) => { + await this.container.workspaces.saveAsCodeWorkspaceFile(node.workspace.id); + void this.ensureRoot().triggerChange(true); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('openLocal'), + async (node: WorkspaceNode) => { + await this.container.workspaces.openCodeWorkspaceFile(node.workspace.id, { + location: 'currentWindow', + }); + void this.ensureRoot().triggerChange(true); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('openLocalNewWindow'), + async (node: WorkspaceNode) => { + await this.container.workspaces.openCodeWorkspaceFile(node.workspace.id, { + location: 'newWindow', + }); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('changeAutoAddSetting'), + async () => { + await this.container.workspaces.chooseCodeWorkspaceAutoAddSetting({ current: true }); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('delete'), + async (node: WorkspaceNode) => { + await this.container.workspaces.deleteCloudWorkspace(node.workspace.id); + void node.getParent()?.triggerChange(true); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('locateAllRepos'), + async (node: WorkspaceNode) => { + if (node.workspace.type !== 'cloud') return; + + await window.withProgress( + { + location: ProgressLocation.Notification, + title: `Locating Repositories for '${node.workspace.name}'...`, + cancellable: true, + }, + (_progress, token) => + this.container.workspaces.locateAllCloudWorkspaceRepos(node.workspace.id, token), + ); + + void node.triggerChange(true); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('repo.locate'), + async (node: RepositoryNode | WorkspaceMissingRepositoryNode) => { + const descriptor = node.wsRepositoryDescriptor; + if (descriptor == null || node.workspace?.id == null) return; + + await this.container.workspaces.locateWorkspaceRepo(node.workspace.id, descriptor); + + void node.getParent()?.triggerChange(true); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('repo.openInNewWindow'), + (node: RepositoryNode) => { + const workspaceNode = node.getParent(); + if (workspaceNode == null || !(workspaceNode instanceof WorkspaceNode)) { + return; + } + + openWorkspace(node.repo.uri, { location: 'newWindow' }); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('repo.open'), + (node: RepositoryNode) => { + const workspaceNode = node.getParent(); + if (workspaceNode == null || !(workspaceNode instanceof WorkspaceNode)) { + return; + } + + openWorkspace(node.repo.uri, { location: 'currentWindow' }); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('repo.addToWindow'), + (node: RepositoryNode) => { + const workspaceNode = node.getParent(); + if (workspaceNode == null || !(workspaceNode instanceof WorkspaceNode)) { + return; + } + + openWorkspace(node.repo.uri, { location: 'addToWorkspace' }); + }, + this, + ), + registerViewCommand( + this.getQualifiedCommand('repo.remove'), + async (node: RepositoryNode | WorkspaceMissingRepositoryNode) => { + const descriptor = node.wsRepositoryDescriptor; + if (descriptor?.id == null || node.workspace?.id == null) return; + + await this.container.workspaces.removeCloudWorkspaceRepo(node.workspace.id, descriptor); + // TODO@axosoft-ramint Do we need the grandparent here? + void node.getParent()?.getParent()?.triggerChange(true); + }, + ), + ]; + } + + protected override filterConfigurationChanged(e: ConfigurationChangeEvent) { + const changed = super.filterConfigurationChanged(e); + if ( + !changed && + !configuration.changed(e, 'defaultDateFormat') && + !configuration.changed(e, 'defaultDateLocale') && + !configuration.changed(e, 'defaultDateShortFormat') && + !configuration.changed(e, 'defaultDateSource') && + !configuration.changed(e, 'defaultDateStyle') && + !configuration.changed(e, 'defaultGravatarsStyle') && + !configuration.changed(e, 'defaultTimeFormat') && + !configuration.changed(e, 'sortBranchesBy') && + !configuration.changed(e, 'sortContributorsBy') && + !configuration.changed(e, 'sortTagsBy') && + !configuration.changed(e, 'sortRepositoriesBy') + ) { + return false; + } + + return true; + } +} diff --git a/src/views/worktreesView.ts b/src/views/worktreesView.ts index 0a0657e74ed8a..969d1e437ccc2 100644 --- a/src/views/worktreesView.ts +++ b/src/views/worktreesView.ts @@ -1,22 +1,21 @@ -import type { CancellationToken, ConfigurationChangeEvent, Disposable, TreeViewVisibilityChangeEvent } from 'vscode'; -import { ProgressLocation, ThemeColor, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; -import type { WorktreesViewConfig } from '../configuration'; -import { configuration, ViewFilesLayout, ViewShowBranchComparison } from '../configuration'; -import { Commands } from '../constants'; +import type { CancellationToken, ConfigurationChangeEvent, Disposable } from 'vscode'; +import { ProgressLocation, TreeItem, TreeItemCollapsibleState, window } from 'vscode'; +import type { ViewFilesLayout, WorktreesViewConfig } from '../config'; +import { proBadge } from '../constants'; +import { Commands } from '../constants.commands'; import type { Container } from '../container'; import { PlusFeatures } from '../features'; import { GitUri } from '../git/gitUri'; import type { RepositoryChangeEvent } from '../git/models/repository'; -import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; +import { groupRepositories, RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import type { GitWorktree } from '../git/models/worktree'; -import { ensurePlusFeaturesEnabled } from '../plus/subscription/utils'; -import { getSubscriptionTimeRemaining, SubscriptionState } from '../subscription'; -import { executeCommand } from '../system/command'; +import { ensurePlusFeaturesEnabled } from '../plus/gk/utils'; import { gate } from '../system/decorators/gate'; -import { pluralize } from '../system/string'; -import { RepositoryNode } from './nodes/repositoryNode'; -import type { ViewNode } from './nodes/viewNode'; -import { RepositoriesSubscribeableNode, RepositoryFolderNode } from './nodes/viewNode'; +import { executeCommand } from '../system/vscode/command'; +import { configuration } from '../system/vscode/configuration'; +import { RepositoriesSubscribeableNode } from './nodes/abstract/repositoriesSubscribeableNode'; +import { RepositoryFolderNode } from './nodes/abstract/repositoryFolderNode'; +import type { ViewNode } from './nodes/abstract/viewNode'; import { WorktreeNode } from './nodes/worktreeNode'; import { WorktreesNode } from './nodes/worktreesNode'; import { ViewBase } from './viewBase'; @@ -47,9 +46,16 @@ export class WorktreesViewNode extends RepositoriesSubscribeableNode { +export class WorktreesView extends ViewBase<'worktrees', WorktreesViewNode, WorktreesViewConfig> { protected readonly configKey = 'worktrees'; constructor(container: Container) { - super(container, 'gitlens.views.worktrees', 'Worktrees', 'workspaceView'); - - this.disposables.push( - window.registerFileDecorationProvider({ - provideFileDecoration: (uri, _token) => { - if ( - uri.scheme !== 'gitlens-view' || - uri.authority !== 'worktree' || - !uri.path.includes('/changes') - ) { - return undefined; - } - - return { - badge: '●', - color: new ThemeColor('gitlens.decorations.worktreeView.hasUncommittedChangesForegroundColoSr'), - tooltip: 'Has Uncommitted Changes', - }; - }, - }), - ); + super(container, 'worktrees', 'Worktrees', 'worktreesView'); + + this.description = proBadge; } override get canReveal(): boolean { return this.config.reveal || !configuration.get('views.repositories.showWorktrees'); } + override get canSelectMany(): boolean { + return this.container.prereleaseOrDebugging; + } + override async show(options?: { preserveFocus?: boolean | undefined }): Promise { if (!(await ensurePlusFeaturesEnabled())) return; return super.show(options); } - private _visibleDisposable: Disposable | undefined; - protected override onVisibilityChanged(e: TreeViewVisibilityChangeEvent): void { - if (e.visible) { - void this.updateDescription(); - this._visibleDisposable?.dispose(); - this._visibleDisposable = this.container.subscription.onDidChange(() => void this.updateDescription()); - } else { - this._visibleDisposable?.dispose(); - this._visibleDisposable = undefined; - } - - super.onVisibilityChanged(e); - } - - private async updateDescription() { - const subscription = await this.container.subscription.getSubscription(); - - switch (subscription.state) { - case SubscriptionState.Free: - case SubscriptionState.FreePreviewTrialExpired: - case SubscriptionState.FreePlusTrialExpired: - this.description = '✨ GitLens+ feature'; - break; - case SubscriptionState.FreeInPreviewTrial: - case SubscriptionState.FreePlusInTrial: { - const days = getSubscriptionTimeRemaining(subscription, 'days')!; - this.description = `✨ GitLens Pro (Trial), ${days < 1 ? '<1 day' : pluralize('day', days)} left`; - break; - } - case SubscriptionState.VerificationRequired: - this.description = `✨ ${subscription.plan.effective.name} (Unverified)`; - break; - case SubscriptionState.Paid: - this.description = undefined; - } - } - protected getRoot() { return new WorktreesViewNode(this); } @@ -181,24 +136,24 @@ export class WorktreesView extends ViewBase { - // this.container.git.resetCaches('worktrees'); + this.container.git.resetCaches('worktrees'); return this.refresh(true); }, this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToAuto'), - () => this.setFilesLayout(ViewFilesLayout.Auto), + () => this.setFilesLayout('auto'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToList'), - () => this.setFilesLayout(ViewFilesLayout.List), + () => this.setFilesLayout('list'), this, ), registerViewCommand( this.getQualifiedCommand('setFilesLayoutToTree'), - () => this.setFilesLayout(ViewFilesLayout.Tree), + () => this.setFilesLayout('tree'), this, ), @@ -237,7 +192,9 @@ export class WorktreesView extends ViewBase n instanceof WorktreeNode && worktree.uri.toString() === url, { maxDepth: 2, canTraverse: n => { if (n instanceof WorktreesViewNode) return true; if (n instanceof WorktreesRepositoryNode) { - return n.id.startsWith(repoNodeId); + return n.repoPath === repoPath; } return false; @@ -269,7 +227,7 @@ export class WorktreesView extends ViewBase n instanceof RepositoryFolderNode && n.repoPath === repoPath, { maxDepth: 1, canTraverse: n => n instanceof WorktreesViewNode || n instanceof RepositoryFolderNode, }); @@ -296,7 +254,7 @@ export class WorktreesView extends ViewBase { + async (_progress, token) => { const node = await this.findWorktree(worktree, token); if (node == null) return undefined; @@ -318,7 +276,7 @@ export class WorktreesView extends ViewBase(options: GitCommandOptions, ...args: any[]) { - const response = await this.sendRequest(GitCommandRequestType, { options: options, args: args }); + async git(options: GitCommandOptions, ...args: any[]): Promise { + const response = await this.sendRequest(GitCommandRequestType, { + __type: 'gitlens', + options: options, + args: args, + }); if (response.isBuffer) { return Buffer.from(response.data, 'binary') as TOut; @@ -65,9 +69,30 @@ export class VslsGuestService implements Disposable { return response.data as TOut; } + @log() + async gitLogStreamTo( + repoPath: string, + sha: string, + limit: number, + options?: { configs?: readonly string[]; stdin?: string }, + ...args: string[] + ): Promise<[data: string[], count: number]> { + const response = await this.sendRequest(GitLogStreamToCommandRequestType, { + __type: 'gitlens', + repoPath: repoPath, + sha: sha, + limit: limit, + options: options, + args: args, + }); + + return [response.data, response.count]; + } + @log() async getRepositoriesForUri(uri: Uri): Promise { const response = await this.sendRequest(GetRepositoriesForUriRequestType, { + __type: 'gitlens', folderUri: uri.toString(), }); @@ -77,10 +102,9 @@ export class VslsGuestService implements Disposable { @debug() private sendRequest( requestType: RequestType, - request: TRequest, - _cancellation?: CancellationToken, + request: TRequest & { __type: string }, + cancellation?: CancellationToken, ): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return this._service.request(requestType.name, [request]); + return this._service.request(requestType.name, [request], cancellation); } } diff --git a/src/vsls/host.ts b/src/vsls/host.ts index 748e24800f434..8ea70f618b72a 100644 --- a/src/vsls/host.ts +++ b/src/vsls/host.ts @@ -1,22 +1,25 @@ +import { git, gitLogStreamTo } from '@env/providers'; import type { CancellationToken, WorkspaceFoldersChangeEvent } from 'vscode'; import { Disposable, Uri, workspace } from 'vscode'; -import { git } from '@env/providers'; import type { LiveShare, SharedService } from '../@types/vsls'; import type { Container } from '../container'; -import { Logger } from '../logger'; -import { getLogScope } from '../logScope'; import { debug, log } from '../system/decorators/log'; import { join } from '../system/iterable'; -import { isVslsRoot, normalizePath } from '../system/path'; +import { Logger } from '../system/logger'; +import { getLogScope } from '../system/logger.scope'; +import { normalizePath } from '../system/path'; +import { isVslsRoot } from '../system/vscode/path'; import type { GetRepositoriesForUriRequest, GetRepositoriesForUriResponse, GitCommandRequest, GitCommandResponse, + GitLogStreamToCommandRequest, + GitLogStreamToCommandResponse, RepositoryProxy, RequestType, } from './protocol'; -import { GetRepositoriesForUriRequestType, GitCommandRequestType } from './protocol'; +import { GetRepositoriesForUriRequestType, GitCommandRequestType, GitLogStreamToCommandRequestType } from './protocol'; const defaultWhitelistFn = () => true; const gitWhitelist = new Map boolean>([ @@ -42,6 +45,7 @@ const gitWhitelist = new Map boolean>([ ['status', defaultWhitelistFn], ['symbolic-ref', defaultWhitelistFn], ['tag', args => args[1] === '-l'], + ['worktree', args => args[1] === 'list'], ]); const leadingSlashRegex = /^[/|\\]/; @@ -76,6 +80,7 @@ export class VslsHostService implements Disposable { this._disposable = Disposable.from(workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged, this)); this.onRequest(GitCommandRequestType, this.onGitCommandRequest.bind(this)); + this.onRequest(GitLogStreamToCommandRequestType, this.onGitLogStreamToCommandRequest.bind(this)); this.onRequest(GetRepositoriesForUriRequestType, this.onGetRepositoriesForUriRequest.bind(this)); this.onWorkspaceFoldersChanged(); @@ -90,9 +95,17 @@ export class VslsHostService implements Disposable { requestType: RequestType, handler: (request: TRequest, cancellation: CancellationToken) => Promise, ) { - this._service.onRequest(requestType.name, (args: any[], cancellation: CancellationToken) => - handler(args[0], cancellation), - ); + // eslint-disable-next-line prefer-arrow-callback + this._service.onRequest(requestType.name, function (args: any[], cancellation: CancellationToken) { + let request; + for (const arg of args) { + if (typeof arg === 'object' && '__type' in arg) { + request = arg; + break; + } + } + return handler(request ?? args[0], cancellation); + }); } @log() @@ -134,67 +147,18 @@ export class VslsHostService implements Disposable { request: GitCommandRequest, _cancellation: CancellationToken, ): Promise { - const { options, args } = request; - const fn = gitWhitelist.get(request.args[0]); - if (fn == null || !fn(request.args)) throw new Error(`Git ${request.args[0]} command is not allowed`); - - let isRootWorkspace = false; - if (options.cwd != null && options.cwd.length > 0 && this._sharedToLocalPaths != null) { - // This is all so ugly, but basically we are converting shared paths to local paths - if (this._sharedPathsRegex?.test(options.cwd)) { - options.cwd = normalizePath(options.cwd).replace(this._sharedPathsRegex, (match, shared: string) => { - if (!isRootWorkspace) { - isRootWorkspace = shared === '/~0'; - } - - const local = this._sharedToLocalPaths.get(shared); - return local != null ? local : shared; - }); - } else if (leadingSlashRegex.test(options.cwd)) { - const localCwd = this._sharedToLocalPaths.get('vsls:/~0'); - if (localCwd != null) { - isRootWorkspace = true; - options.cwd = normalizePath(this.container.git.getAbsoluteUri(options.cwd, localCwd).fsPath); - } - } - } + if (!fn?.(request.args)) throw new Error(`Git ${request.args[0]} command is not allowed`); - let files = false; - let i = -1; - for (const arg of args) { - i++; - if (arg === '--') { - files = true; - continue; - } - - if (!files) continue; - - if (typeof arg === 'string') { - // If we are the "root" workspace, then we need to remove the leading slash off the path (otherwise it will not be treated as a relative path) - if (isRootWorkspace && leadingSlashRegex.test(arg[0])) { - args.splice(i, 1, arg.substr(1)); - } - - if (this._sharedPathsRegex?.test(arg)) { - args.splice( - i, - 1, - normalizePath(arg).replace(this._sharedPathsRegex, (match, shared: string) => { - const local = this._sharedToLocalPaths.get(shared); - return local != null ? local : shared; - }), - ); - } - } - } + const { options, args } = request; + const [cwd, isRootWorkspace] = this.convertGitCommandCwd(options.cwd); + options.cwd = cwd; - let data = await git(options, ...args); + let data = await git(options, ...this.convertGitCommandArgs(args, isRootWorkspace)); if (typeof data === 'string') { - // And then we convert local paths to shared paths + // Convert local paths to shared paths if (this._localPathsRegex != null && data.length > 0) { - data = data.replace(this._localPathsRegex, (match, local: string) => { + data = data.replace(this._localPathsRegex, (_match, local: string) => { const shared = this._localToSharedPaths.get(normalizePath(local)); return shared != null ? shared : local; }); @@ -206,8 +170,35 @@ export class VslsHostService implements Disposable { return { data: data.toString('binary'), isBuffer: true }; } - // eslint-disable-next-line @typescript-eslint/require-await @log() + private async onGitLogStreamToCommandRequest( + request: GitLogStreamToCommandRequest, + _cancellation: CancellationToken, + ): Promise { + const { options, args } = request; + const [cwd, isRootWorkspace] = this.convertGitCommandCwd(request.repoPath); + + let [data, count] = await gitLogStreamTo( + cwd, + request.sha, + request.limit, + options, + ...this.convertGitCommandArgs(args, isRootWorkspace), + ); + if (this._localPathsRegex != null && data.length > 0) { + // Convert local paths to shared paths + data = data.map(d => + d.replace(this._localPathsRegex!, (_match, local: string) => { + const shared = this._localToSharedPaths.get(normalizePath(local)); + return shared != null ? shared : local; + }), + ); + } + return { data: data, count: count }; + } + + @log() + // eslint-disable-next-line @typescript-eslint/require-await private async onGetRepositoriesForUriRequest( request: GetRepositoriesForUriRequest, _cancellation: CancellationToken, @@ -230,9 +221,7 @@ export class VslsHostService implements Disposable { return { repositories: repositories }; } - @debug({ - exit: result => `returned ${result.toString(true)}`, - }) + @debug({ exit: true }) private convertLocalUriToShared(localUri: Uri) { const scope = getLogScope(); @@ -251,18 +240,82 @@ export class VslsHostService implements Disposable { if (new RegExp(`${localPath}$`, 'i').test(sharedPath)) { if (sharedPath.length === localPath.length) { const folder = workspace.getWorkspaceFolder(localUri)!; - sharedUri = sharedUri.with({ path: `/~${folder.index}` }); + sharedUri = sharedUri.with({ authority: '', path: `/~${folder.index}` }); } else { - sharedUri = sharedUri.with({ path: sharedPath.substr(0, sharedPath.length - localPath.length) }); + sharedUri = sharedUri.with({ + authority: '', + path: sharedPath.substring(0, sharedPath.length - localPath.length), + }); } } else if (!sharedPath.startsWith('/~')) { const folder = workspace.getWorkspaceFolder(localUri)!; - sharedUri = sharedUri.with({ path: `/~${folder.index}${sharedPath}` }); + sharedUri = sharedUri.with({ authority: '', path: `/~${folder.index}${sharedPath}` }); } return sharedUri; } + private convertGitCommandCwd(cwd: string): [cwd: string, root: boolean]; + private convertGitCommandCwd(cwd: string | undefined): [cwd: string | undefined, root: boolean]; + private convertGitCommandCwd(cwd: string | undefined): [cwd: string | undefined, root: boolean] { + let isRootWorkspace = false; + if (cwd != null && cwd.length > 0 && this._sharedToLocalPaths != null) { + // This is all so ugly, but basically we are converting shared paths to local paths + if (this._sharedPathsRegex?.test(cwd)) { + cwd = normalizePath(cwd).replace(this._sharedPathsRegex, (_match, shared: string) => { + if (!isRootWorkspace) { + isRootWorkspace = shared === '/~0'; + } + + const local = this._sharedToLocalPaths.get(shared); + return local != null ? local : shared; + }); + } else if (leadingSlashRegex.test(cwd)) { + const localCwd = this._sharedToLocalPaths.get('vsls:/~0'); + if (localCwd != null) { + isRootWorkspace = true; + cwd = normalizePath(this.container.git.getAbsoluteUri(cwd, localCwd).fsPath); + } + } + } + + return [cwd, isRootWorkspace]; + } + + private convertGitCommandArgs(args: any[], isRootWorkspace: boolean): any[] { + let files = false; + let i = -1; + for (const arg of args) { + i++; + if (arg === '--') { + files = true; + continue; + } + + if (!files) continue; + + if (typeof arg === 'string') { + // If we are the "root" workspace, then we need to remove the leading slash off the path (otherwise it will not be treated as a relative path) + if (isRootWorkspace && leadingSlashRegex.test(arg[0])) { + args.splice(i, 1, arg.substring(1)); + } + + if (this._sharedPathsRegex?.test(arg)) { + args.splice( + i, + 1, + normalizePath(arg).replace(this._sharedPathsRegex, (_match, shared: string) => { + const local = this._sharedToLocalPaths.get(shared); + return local != null ? local : shared; + }), + ); + } + } + } + + return args; + } + private convertSharedUriToLocal(sharedUri: Uri) { if (isVslsRoot(sharedUri.path)) { sharedUri = sharedUri.with({ path: `${sharedUri.path}/` }); @@ -273,7 +326,7 @@ export class VslsHostService implements Disposable { let localPath = localUri.path; const sharedPath = sharedUri.path; if (localPath.endsWith(sharedPath)) { - localPath = localPath.substr(0, localPath.length - sharedPath.length); + localPath = localPath.substring(0, localPath.length - sharedPath.length); } if (localPath.charCodeAt(localPath.length - 1) === slash) { diff --git a/src/vsls/protocol.ts b/src/vsls/protocol.ts index e38e0c3162ec5..1baa7e4dcd120 100644 --- a/src/vsls/protocol.ts +++ b/src/vsls/protocol.ts @@ -17,6 +17,24 @@ export interface GitCommandResponse { export const GitCommandRequestType = new RequestType('git'); +export interface GitLogStreamToCommandRequest { + repoPath: string; + sha: string; + limit: number; + options?: { configs?: readonly string[]; stdin?: string }; + args: string[]; +} + +export interface GitLogStreamToCommandResponse { + data: string[]; + count: number; +} + +export const GitLogStreamToCommandRequestType = new RequestType< + GitLogStreamToCommandRequest, + GitLogStreamToCommandResponse +>('git/logStreamTo'); + export interface RepositoryProxy { folderUri: string; /** @deprecated */ diff --git a/src/vsls/vsls.ts b/src/vsls/vsls.ts index 3f589743c011d..3fed0c156dee8 100644 --- a/src/vsls/vsls.ts +++ b/src/vsls/vsls.ts @@ -1,15 +1,15 @@ +import type { ConfigurationChangeEvent, Extension } from 'vscode'; import { Disposable, extensions, workspace } from 'vscode'; import type { LiveShare, LiveShareExtension, SessionChangeEvent } from '../@types/vsls'; -import { configuration } from '../configuration'; -import { ContextKeys, Schemes } from '../constants'; +import { Schemes } from '../constants'; import type { Container } from '../container'; -import { setContext } from '../context'; -import { Logger } from '../logger'; import { debug } from '../system/decorators/log'; -import { timeout } from '../system/decorators/timeout'; import { once } from '../system/event'; +import { Logger } from '../system/logger'; import type { Deferred } from '../system/promise'; import { defer } from '../system/promise'; +import { configuration } from '../system/vscode/configuration'; +import { setContext } from '../system/vscode/context'; import { VslsGuestService } from './guest'; import { VslsHostService } from './host'; @@ -43,7 +43,10 @@ export class VslsController implements Disposable { constructor(private readonly container: Container) { this._ready = defer(); - this._disposable = Disposable.from(once(container.onReady)(this.onReady, this)); + this._disposable = Disposable.from( + once(container.onReady)(this.onReady, this), + configuration.onDidChange(this.onConfigurationChanged, this), + ); } dispose() { @@ -59,6 +62,11 @@ export class VslsController implements Disposable { } private async initialize() { + if (!this.enabled) { + void setContext('gitlens:vsls', false); + return; + } + // If we have a vsls: workspace open, we might be a guest, so wait until live share transitions into a mode if (workspace.workspaceFolders?.some(f => f.uri.scheme === Schemes.Vsls)) { this.setReadonly(true); @@ -68,14 +76,14 @@ export class VslsController implements Disposable { this._api = this.getLiveShareApi(); const api = await this._api; if (api == null) { - void setContext(ContextKeys.Vsls, false); + void setContext('gitlens:vsls', false); // Tear it down if we can't talk to live share this._ready.fulfill(); return; } - void setContext(ContextKeys.Vsls, true); + void setContext('gitlens:vsls', true); this._disposable = Disposable.from( this._disposable, @@ -88,6 +96,12 @@ export class VslsController implements Disposable { } } + private onConfigurationChanged(e: ConfigurationChangeEvent) { + if (configuration.changed(e, 'liveshare.enabled')) { + void this.initialize(); + } + } + private async onLiveShareSessionChanged(api: LiveShare, e: SessionChangeEvent) { this._host?.dispose(); this._host = undefined; @@ -97,7 +111,7 @@ export class VslsController implements Disposable { switch (e.session.role) { case 1 /*Role.Host*/: this.setReadonly(false); - void setContext(ContextKeys.Vsls, 'host'); + void setContext('gitlens:vsls', 'host'); if (configuration.get('liveshare.allowGuestAccess')) { this._host = await VslsHostService.share(api, this.container); } @@ -108,7 +122,7 @@ export class VslsController implements Disposable { case 2 /*Role.Guest*/: this.setReadonly(true); - void setContext(ContextKeys.Vsls, 'guest'); + void setContext('gitlens:vsls', 'guest'); this._guest = await VslsGuestService.connect(api, this.container); this._ready.fulfill(); @@ -117,9 +131,11 @@ export class VslsController implements Disposable { default: this.setReadonly(false); - void setContext(ContextKeys.Vsls, true); + void setContext('gitlens:vsls', true); - this._ready = defer(); + if (!this._ready.pending) { + this._ready = defer(); + } break; } @@ -127,25 +143,38 @@ export class VslsController implements Disposable { private async getLiveShareApi(): Promise { try { - const extension = extensions.getExtension('ms-vsliveshare.vsliveshare'); + const extension = this.getLiveShareExtension(); if (extension != null) { - const vslsExtension = extension.isActive ? extension.exports : await extension.activate(); - return (await vslsExtension.getApi('1.0.4753')) ?? undefined; + const vsls = extension.isActive ? extension.exports : await extension.activate(); + return (await vsls.getApi('1.0.4753')) ?? undefined; } - } catch { + } catch (ex) { debugger; + Logger.error(ex); } return undefined; } + private getLiveShareExtension(): Extension | undefined { + return extensions.getExtension('ms-vsliveshare.vsliveshare'); + } + + get active() { + return configuration.get('liveshare.enabled') && this.getLiveShareExtension()?.isActive; + } + + get enabled() { + return configuration.get('liveshare.enabled'); + } + private _readonly: boolean = false; get readonly() { return this._readonly; } private setReadonly(value: boolean) { this._readonly = value; - void setContext(ContextKeys.Readonly, value ? true : undefined); + void setContext('gitlens:readonly', value ? true : undefined); } async guest() { @@ -193,12 +222,6 @@ export class VslsController implements Disposable { ); } - @debug() - @timeout(250) - maybeGetPresence(email: string | undefined): Promise { - return this.getContactPresence(email); - } - async invite(email: string | undefined) { if (email == null) return undefined; diff --git a/src/webviews/apps/.eslintrc.json b/src/webviews/apps/.eslintrc.json deleted file mode 100644 index a106b8919e85d..0000000000000 --- a/src/webviews/apps/.eslintrc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": ["../../../.eslintrc.base.json", "plugin:lit/recommended"], - "env": { - "browser": true - }, - "rules": { - "import/extensions": ["error", "never", { "js": "always" }] - }, - "parserOptions": { - "project": "src/webviews/apps/tsconfig.json" - } -} diff --git a/src/webviews/apps/commitDetails/commitDetails.html b/src/webviews/apps/commitDetails/commitDetails.html index 1f6b7c613769c..1edeae36b64ba 100644 --- a/src/webviews/apps/commitDetails/commitDetails.html +++ b/src/webviews/apps/commitDetails/commitDetails.html @@ -1,231 +1,27 @@ - + - - - -

- -
-
- -
-

Move into Secondary Side Bar

-

For a better experience, drag and drop this view into the Secondary Side Bar.

-
- - - - - - - - - - -
-
-
-
    -
  • - -
  • -
- - - - - - - -
-
-
-

- -

-
- - - Autolinks - - -
- -
- -
-
- -
-
-
- - - Files changed - - - - - -
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
-
- #{endOfBody} - + + + + + #{endOfBody} diff --git a/src/webviews/apps/commitDetails/commitDetails.scss b/src/webviews/apps/commitDetails/commitDetails.scss index 29afa686c7fe1..41ef21f90213f 100644 --- a/src/webviews/apps/commitDetails/commitDetails.scss +++ b/src/webviews/apps/commitDetails/commitDetails.scss @@ -1,515 +1,164 @@ -:root { - --gitlens-gutter-width: 20px; - --gitlens-scrollbar-gutter-width: 10px; -} - -// generic resets -html { - font-size: 62.5%; - // box-sizing: border-box; - font-family: var(--font-family); -} - -*, -*:before, -*:after { - box-sizing: border-box; -} - -body { - font-family: var(--font-family); - font-size: var(--font-size); - color: var(--color-foreground); - padding: 0; - - &.scrollable, - .scrollable { - border-color: transparent; - transition: border-color 1s linear; - - &:hover, - &:focus-within { - &.scrollable, - .scrollable { - border-color: var(--vscode-scrollbarSlider-background); - transition: none; - } - } - } - - &.preload { - &.scrollable, - .scrollable { - transition: none; - } - } -} - -::-webkit-scrollbar-corner { - background-color: transparent !important; -} - -::-webkit-scrollbar-thumb { - background-color: transparent; - border-color: inherit; - border-right-style: inset; - border-right-width: calc(100vw + 100vh); - border-radius: unset !important; - - &:hover { - border-color: var(--vscode-scrollbarSlider-hoverBackground); - } - - &:active { - border-color: var(--vscode-scrollbarSlider-activeBackground); - } -} - -a { - text-decoration: none; - &:hover { - text-decoration: underline; - } -} +@use '../shared/styles/details-base'; -ul { - list-style: none; - margin: 0; - padding: 0; +.vscode-high-contrast, +.vscode-dark { + --gl-color-background-counter: #fff; } -.bulleted { - list-style: disc; - padding-left: 1.2em; - > li + li { - margin-top: 0.25em; - } +.vscode-high-contrast-light, +.vscode-light { + --gl-color-background-counter: #000; } -.button { - --button-foreground: var(--vscode-button-foreground); - --button-background: var(--vscode-button-background); - --button-hover-background: var(--vscode-button-hoverBackground); - - display: inline-block; - border: none; - padding: 0.4rem; - font-family: inherit; - font-size: inherit; - line-height: 1.4; - text-align: center; - text-decoration: none; - user-select: none; - background: var(--button-background); - color: var(--button-foreground); - cursor: pointer; - - &:hover { - background: var(--button-hover-background); - } - - &:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 0.2rem; - } - - &--full { - width: 100%; - } - - code-icon { - pointer-events: none; - } +.commit-detail-panel { + height: 100vh; + display: flex; + flex-direction: column; + gap: 1rem; + overflow: auto; } -.button-container { - margin: 1rem auto 0; - text-align: left; - max-width: 30rem; - transition: max-width 0.2s ease-out; +main { + flex: 1 1 auto; + overflow: hidden; + display: flex; + flex-direction: column; } -.button-group { - display: inline-flex; - gap: 0.1rem; - width: 100%; - max-width: 30rem; +[hidden] { + display: none !important; } -.svg-themed { - --svg-outline: var(--color-foreground--50); - --svg-foreground: var(--color-link-foreground--lighten-20); - --svg-overlay: var(--color-highlight--25); - - &__outline { - stroke: var(--svg-outline); - } - - &__view { - fill: var(--svg-overlay); - stroke: var(--svg-foreground); - } - - &__line { - stroke: var(--svg-foreground); - fill: var(--svg-foreground); - } +gl-commit-details, +gl-wip-details { + display: contents; } -.switch { - margin-left: auto; - display: inline-flex; - flex-direction: row; - border-radius: 0.25em; - gap: 0.1rem; - - .vscode-high-contrast &, - .vscode-dark & { - background-color: var(--color-background--lighten-075); - } - .vscode-high-contrast-light &, - .vscode-light & { - background-color: var(--color-background--darken-075); - } - - &__option { - display: inline-flex; - justify-content: center; - align-items: flex-end; - border-radius: 0.25em; - color: inherit; - padding: 0.2rem 0.8rem; - text-decoration: none; - background: none; - border: none; - cursor: pointer; - - > * { - pointer-events: none; - } - - &:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } - - &:hover { - color: var(--vscode-foreground); - text-decoration: none; - .vscode-high-contrast &, - .vscode-dark & { - background-color: var(--color-background--lighten-10); - } - .vscode-high-contrast-light &, - .vscode-light & { - background-color: var(--color-background--darken-10); - } - } - - &.is-selected { - color: var(--vscode-foreground); - .vscode-high-contrast &, - .vscode-dark & { - background-color: var(--color-background--lighten-15); - } - .vscode-high-contrast-light &, - .vscode-light & { - background-color: var(--color-background--darken-15); - } - } - } +webview-pane-group { + height: 100%; + overflow: hidden; } -@media (min-width: 640px) { - .button-container { - max-width: 100%; - } +.popover-content { + background-color: var(--color-background--level-15); + padding: 0.8rem 1.2rem; } -// webview-specific styles -.change-list { - list-style: none; - - &__item { - // & + & { - // margin-top: 0.25rem; - // } - } - &__link { - width: 100%; - color: inherit; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - &__type { - } - &__filename { - } - &__path { - font-size: 0.9em; - } - &__actions { - flex: none; - } - &__action { - } -} - -.commit-stashed { - display: flex; - gap: 0.25rem 0.5rem; - justify-content: start; - align-items: center; - - &__media { - width: 3.6rem; - height: 3.6rem; - display: flex; - justify-content: center; - align-items: center; - } - &__media &__icon { - width: 2.8rem; - height: 2.8rem; - font-size: 2.8rem; - } - &__date { - font-size: 1.2rem; - } -} - -.commit-banner { +.inspect-header { display: flex; flex-direction: row; - align-items: flex-start; + align-items: flex-start; // center; justify-content: space-between; - padding: 1rem; gap: 0.4rem; - font-size: 1.2rem; - border-radius: 0.3rem; - margin: 1rem; - - .vscode-high-contrast &, - .vscode-dark & { - background-color: var(--color-background--lighten-075); - } - - .vscode-high-contrast-light &, - .vscode-light & { - background-color: var(--color-background--darken-075); - } - - &__message { - flex-basis: 60%; - margin: { - left: 0.6rem; - right: 0.6rem; - } - - h2 { - font-weight: normal; - font-size: inherit; - margin: { - top: 0; - bottom: 0.4rem; - } - } - p { - margin: 0; - opacity: 0.5; - transition: font-size ease 100ms; - - @media (max-width: 350px) { - font-size: 0.88em; - } - } - } - - &__media { - min-width: 10rem; - flex-basis: 40%; - max-width: 12rem; - margin-right: 0.6rem; - } + border-top: 2px solid var(--color-background--level-15); - &__icon { + &__tabs { flex: none; - - &:last-child { - transform: translateY(-0.4rem); - } + display: flex; + flex-direction: row; + // gap: -0.8rem; + align-items: flex-start; } -} -.commit-details { - &__top { - position: sticky; - top: 0; - z-index: 1; - padding: { - top: 1rem; - left: var(--gitlens-gutter-width); - right: var(--gitlens-scrollbar-gutter-width); - bottom: 0.5rem; - } - background-color: var(--vscode-sideBar-background); - - &-menu { - display: flex; - flex-direction: row; - align-items: flex-start; - justify-content: space-between; - } - } + &__tab { + position: relative; + appearance: none; + background-color: var(--color-background--level-10); + color: var(--color-foreground--85); + border: none; + border-radius: 0 0 0.5rem 0.5rem; + padding: 0.4rem 1.2rem; + cursor: pointer; - &__message { - font-size: 1.3rem; - border: 1px solid var(--vscode-input-border); - background: var(--vscode-input-background); - padding: 0.5rem; - margin: { - top: 1rem; - left: var(--gitlens-gutter-width); - right: var(--gitlens-scrollbar-gutter-width); - bottom: 1.75rem; + &:last-child { + margin-inline-start: -0.6rem; } - } - &__message-text { - flex: 1; - margin: 0; - display: block; - - overflow-y: auto; - overflow-x: hidden; - max-height: 9rem; - white-space: break-spaces; - - strong { - font-weight: 600; - font-size: 1.4rem; + &.is-active { + z-index: 1; + background-color: color-mix(in srgb, var(--gl-color-background-counter) 25%, var(--color-background)); + padding-block: 0.6rem; + box-shadow: 0 -2px 0 0 var(--vscode-button-hoverBackground); + color: var(--color-foreground); } - } - - &__commit-action { - display: inline-flex; - justify-content: center; - align-items: center; - height: 21px; - border-radius: 0.25em; - color: inherit; - padding: 0.2rem; - vertical-align: text-bottom; - text-decoration: none; - > * { - pointer-events: none; + &-tracking { + --gl-pill-foreground: currentColor; + --gl-pill-border: color-mix(in srgb, transparent 80%, var(--color-foreground)); + margin-inline: 0.2rem -0.4rem; } - &:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } + &-indicator { + --gl-indicator-size: 0.46rem; + position: absolute; + bottom: 0.825rem; + left: 2.1rem; + z-index: 1; - &:hover { - color: var(--vscode-foreground); - text-decoration: none; - .vscode-dark & { - background-color: var(--color-background--lighten-15); - } - .vscode-light & { - background-color: var(--color-background--darken-15); + &--ahead { + --gl-indicator-color: var(--vscode-gitlens-decorations\.branchAheadForegroundColor); } - } - - &.is-active { - .vscode-dark & { - background-color: var(--color-background--lighten-10); + &--behind { + --gl-indicator-color: var(--vscode-gitlens-decorations\.branchBehindForegroundColor); } - .vscode-light & { - background-color: var(--color-background--darken-10); + &--both { + --gl-indicator-color: var(--vscode-gitlens-decorations\.branchDivergedForegroundColor); } } - } - - &__sha { - margin: 0 0.5rem 0 0.25rem; - } - &__authors { - flex-basis: 100%; - } - &__author { - & + & { - margin-top: 0.5rem; + &.is-active &-indicator { + bottom: 1.025rem; } - } - &__rich { - padding: 0 var(--gitlens-scrollbar-gutter-width) 1.5rem var(--gitlens-gutter-width); - - > :first-child { - margin-top: 0; - } - > :last-child { - margin-top: 0.5rem; - margin-bottom: 0; - } - } - &__pull-request { - } - &__issue { - > :not(:first-child) { - margin-top: 0.5rem; + &-pulse { + position: absolute; + bottom: 0.2rem; + right: 0.4rem; + z-index: 2; } } - &__file { - --tree-level: 1; - padding: { - left: calc(var(--gitlens-gutter-width) * var(--tree-level)); - right: var(--gitlens-scrollbar-gutter-width); - top: 1px; - bottom: 1px; + &__content { + flex: 1; + min-width: 0; + margin: { + top: 0.3rem; + right: 0.3rem; } - line-height: 22px; - height: 22px; } - &__item-skeleton { - padding: { - left: var(--gitlens-gutter-width); - right: var(--gitlens-scrollbar-gutter-width); - top: 1px; - bottom: 1px; - } +} + +.section--message { + > :first-child:not(:last-child) { + margin-bottom: 0.4rem; } } -.commit-detail-panel { - $block: &; +.issue > :not(:first-child) { + margin-top: 0.5rem; +} - max-height: 100vh; - overflow: auto; - scrollbar-gutter: stable; - color: var(--vscode-sideBar-foreground); - background-color: var(--vscode-sideBar-background); +:root { + --gk-avatar-size: 1.6rem; +} - [aria-hidden='true'] { - display: none; - } +hr { + border: none; + border-top: 1px solid var(--color-foreground--25); +} - &__none { - padding: { - left: var(--gitlens-gutter-width); - right: var(--gitlens-scrollbar-gutter-width); - } - } +.md-code { + background: var(--vscode-textCodeBlock-background); + border-radius: 3px; + padding: 0px 4px 2px 4px; + font-family: var(--vscode-editor-font-family); +} - &__main { - padding-bottom: 1rem; - } +.inline-popover { + display: inline-block; } -@import '../shared/codicons'; -@import '../shared/glicons'; +.tooltip-hint { + white-space: nowrap; + border-bottom: 1px dashed currentColor; +} diff --git a/src/webviews/apps/commitDetails/commitDetails.ts b/src/webviews/apps/commitDetails/commitDetails.ts index 1f81b10f55eb7..6d3ecd0b39d95 100644 --- a/src/webviews/apps/commitDetails/commitDetails.ts +++ b/src/webviews/apps/commitDetails/commitDetails.ts @@ -1,727 +1,26 @@ /*global*/ -import { ViewFilesLayout } from '../../../config'; -import type { HierarchicalItem } from '../../../system/array'; -import { makeHierarchical } from '../../../system/array'; -import type { Serialized } from '../../../system/serialize'; -import type { CommitActionsParams, State } from '../../commitDetails/protocol'; -import { - AutolinkSettingsCommandType, - CommitActionsCommandType, - DidChangeNotificationType, - FileActionsCommandType, - messageHeadlineSplitterToken, - OpenFileCommandType, - OpenFileComparePreviousCommandType, - OpenFileCompareWorkingCommandType, - OpenFileOnRemoteCommandType, - PickCommitCommandType, - PinCommitCommandType, - PreferencesCommandType, - SearchCommitCommandType, -} from '../../commitDetails/protocol'; -import type { IpcMessage } from '../../protocol'; -import { onIpc } from '../../protocol'; +import type { Serialized } from '../../../system/vscode/serialize'; +import type { State } from '../../commitDetails/protocol'; import { App } from '../shared/appBase'; -import type { FileChangeListItem, FileChangeListItemDetail } from '../shared/components/list/file-change-list-item'; -import type { WebviewPane, WebviewPaneExpandedChangeEventDetail } from '../shared/components/webview-pane'; import { DOM } from '../shared/dom'; +import type { GlCommitDetailsApp } from './components/commit-details-app'; import './commitDetails.scss'; -import '../shared/components/actions/action-item'; -import '../shared/components/actions/action-nav'; -import '../shared/components/code-icon'; -import '../shared/components/commit/commit-identity'; -import '../shared/components/formatted-date'; -import '../shared/components/rich/issue-pull-request'; -import '../shared/components/skeleton-loader'; -import '../shared/components/commit/commit-stats'; -import '../shared/components/webview-pane'; -import '../shared/components/progress'; -import '../shared/components/list/list-container'; -import '../shared/components/list/list-item'; -import '../shared/components/list/file-change-list-item'; - -const uncommittedSha = '0000000000000000000000000000000000000000'; - -type CommitState = SomeNonNullable, 'selected'>; +import './components/commit-details-app'; +export type CommitState = SomeNonNullable, 'commit'>; export class CommitDetailsApp extends App> { constructor() { super('CommitDetailsApp'); } override onInitialize() { - this.log(`onInitialize()`); - this.renderContent(); - } - - override onBind() { - const disposables = [ - DOM.on('file-change-list-item', 'file-open-on-remote', e => - this.onOpenFileOnRemote(e.detail), - ), - DOM.on('file-change-list-item', 'file-open', e => - this.onOpenFile(e.detail), - ), - DOM.on('file-change-list-item', 'file-compare-working', e => - this.onCompareFileWithWorking(e.detail), - ), - DOM.on('file-change-list-item', 'file-compare-previous', e => - this.onCompareFileWithPrevious(e.detail), - ), - DOM.on('file-change-list-item', 'file-more-actions', e => - this.onFileMoreActions(e.detail), - ), - DOM.on('[data-action="dismiss-banner"]', 'click', e => this.onDismissBanner(e)), - DOM.on('[data-action="commit-actions"]', 'click', e => this.onCommitActions(e)), - DOM.on('[data-action="pick-commit"]', 'click', e => this.onPickCommit(e)), - DOM.on('[data-action="search-commit"]', 'click', e => this.onSearchCommit(e)), - DOM.on('[data-action="autolink-settings"]', 'click', e => this.onAutolinkSettings(e)), - DOM.on('[data-switch-value]', 'click', e => this.onToggleFilesLayout(e)), - DOM.on('[data-action="pin"]', 'click', e => this.onTogglePin(e)), - DOM.on( - '[data-region="rich-pane"]', - 'expanded-change', - e => this.onExpandedChange(e.detail), - ), - ]; - - return disposables; - } - - protected override onMessageReceived(e: MessageEvent) { - const msg = e.data as IpcMessage; - this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); - - switch (msg.method) { - // case DidChangeRichStateNotificationType.method: - // onIpc(DidChangeRichStateNotificationType, msg, params => { - // if (this.state.selected == null) return; - - // assertsSerialized(params); - - // const newState = { ...this.state }; - // if (params.formattedMessage != null) { - // newState.selected!.message = params.formattedMessage; - // } - // // if (params.pullRequest != null) { - // newState.pullRequest = params.pullRequest; - // // } - // // if (params.formattedMessage != null) { - // newState.autolinkedIssues = params.autolinkedIssues; - // // } - - // this.state = newState; - // this.renderRichContent(); - // }); - // break; - case DidChangeNotificationType.method: - onIpc(DidChangeNotificationType, msg, params => { - assertsSerialized(params.state); - - this.state = params.state; - this.renderContent(); - }); - break; - - default: - super.onMessageReceived?.(e); - } - } - - onDismissBanner(e: MouseEvent) { - const dismissed = this.state.preferences?.dismissed ?? []; - if (dismissed.includes('sidebar')) { - return; - } - dismissed.push('sidebar'); - this.state.preferences = { ...this.state.preferences, dismissed: dismissed }; - const parent = (e.target as HTMLElement)?.closest('[data-region="sidebar-banner"]') ?? undefined; - this.renderBanner(this.state as CommitState, parent); - - this.sendCommand(PreferencesCommandType, { dismissed: dismissed }); - } - - private onToggleFilesLayout(e: MouseEvent) { - const layout = ((e.target as HTMLElement)?.getAttribute('data-switch-value') as ViewFilesLayout) ?? undefined; - if (layout === this.state.preferences?.files?.layout) return; - - const files = { - ...this.state.preferences?.files, - layout: layout ?? ViewFilesLayout.Auto, - compact: this.state.preferences?.files?.compact ?? true, - threshold: this.state.preferences?.files?.threshold ?? 5, - }; - - this.state.preferences = { - ...this.state.preferences, - files: files, - }; - - this.renderFiles(this.state as CommitState); - - this.sendCommand(PreferencesCommandType, { files: files }); - } - - private onExpandedChange(e: WebviewPaneExpandedChangeEventDetail) { - this.state.preferences = { - ...this.state.preferences, - autolinksExpanded: e.expanded, - }; - - this.sendCommand(PreferencesCommandType, { autolinksExpanded: e.expanded }); - } - - private onTogglePin(e: MouseEvent) { - e.preventDefault(); - this.sendCommand(PinCommitCommandType, { pin: !this.state.pinned }); - } - - private onAutolinkSettings(e: MouseEvent) { - e.preventDefault(); - this.sendCommand(AutolinkSettingsCommandType, undefined); - } - - private onSearchCommit(_e: MouseEvent) { - this.sendCommand(SearchCommitCommandType, undefined); - } - - private onPickCommit(_e: MouseEvent) { - this.sendCommand(PickCommitCommandType, undefined); - } - - private onOpenFileOnRemote(e: FileChangeListItemDetail) { - this.sendCommand(OpenFileOnRemoteCommandType, e); - } - - private onOpenFile(e: FileChangeListItemDetail) { - this.sendCommand(OpenFileCommandType, e); - } - - private onCompareFileWithWorking(e: FileChangeListItemDetail) { - this.sendCommand(OpenFileCompareWorkingCommandType, e); - } - - private onCompareFileWithPrevious(e: FileChangeListItemDetail) { - this.sendCommand(OpenFileComparePreviousCommandType, e); - } - - private onFileMoreActions(e: FileChangeListItemDetail) { - this.sendCommand(FileActionsCommandType, e); - } - - private onCommitActions(e: MouseEvent) { - e.preventDefault(); - if (this.state.selected === undefined) { - e.stopPropagation(); - return; - } - - const action = (e.target as HTMLElement)?.getAttribute('data-action-type'); - if (action == null) return; - - this.sendCommand(CommitActionsCommandType, { action: action as CommitActionsParams['action'], alt: e.altKey }); - } - - renderCommit(state: Serialized): state is CommitState { - const hasSelection = state.selected !== undefined; - const $empty = document.getElementById('empty'); - const $main = document.getElementById('main'); - $empty?.setAttribute('aria-hidden', hasSelection ? 'true' : 'false'); - $main?.setAttribute('aria-hidden', hasSelection ? 'false' : 'true'); - - return hasSelection; - } - - renderRichContent() { - if (!this.renderCommit(this.state)) return; - - this.renderMessage(this.state); - this.renderPullRequestAndAutolinks(this.state); - } - - renderContent() { - if (!this.renderCommit(this.state)) return; - - this.renderBanner(this.state); - this.renderActions(this.state); - this.renderPin(this.state); - this.renderSha(this.state); - this.renderMessage(this.state); - this.renderAuthor(this.state); - this.renderStats(this.state); - this.renderFiles(this.state); - - // if (this.state.includeRichContent) { - this.renderPullRequestAndAutolinks(this.state); - // } - } - - renderBanner(state: CommitState, target?: HTMLElement) { - if (!state.preferences?.dismissed?.includes('sidebar')) { - return; - } - - if (!target) { - target = document.querySelector('[data-region="sidebar-banner"]') ?? undefined; - } - target?.remove(); - } - - renderActions(state: CommitState) { - const isUncommitted = state.selected?.sha === uncommittedSha; - const isHiddenForUncommitted = isUncommitted.toString(); - for (const $el of document.querySelectorAll('[data-action-type="sha"],[data-action-type="more"]')) { - $el.setAttribute('aria-hidden', isHiddenForUncommitted); - } - - document.querySelector('[data-action-type="scm"]')?.setAttribute('aria-hidden', (!isUncommitted).toString()); - } - - renderPin(state: CommitState) { - const $el = document.querySelector('[data-action="pin"]'); - if ($el == null) return; - - const label = state.pinned ? 'Unpin this Commit' : 'Pin this Commit'; - $el.setAttribute('aria-label', label); - $el.setAttribute('title', label); - $el.classList.toggle('is-active', state.pinned); - - const $icon = $el.querySelector('[data-region="commit-pin"]'); - $icon?.setAttribute('icon', state.pinned ? 'gl-pinned-filled' : 'pin'); - } - - renderSha(state: CommitState) { - const $els = [...document.querySelectorAll('[data-region="shortsha"]')]; - if ($els.length === 0) return; - - for (const $el of $els) { - $el.textContent = state.selected.shortSha; - } - } - - renderChoices() { - // - const $el = document.querySelector('[data-region="choices"]'); - if ($el == null) return; - - // if (this.state.commits?.length) { - // const $count = $el.querySelector('[data-region="choices-count"]'); - // if ($count != null) { - // $count.innerHTML = `${this.state.commits.length}`; - // } - - // const $list = $el.querySelector('[data-region="choices-list"]'); - // if ($list != null) { - // $list.innerHTML = this.state.commits - // .map( - // (item: CommitSummary) => ` - //
  • - // - //
  • - // `, - // ) - // .join(''); - // } - // $el.setAttribute('aria-hidden', 'false'); - // } else { - $el.setAttribute('aria-hidden', 'true'); - $el.innerHTML = ''; - // } - } - - renderStats(state: CommitState) { - const $el = document.querySelector('[data-region="stats"]'); - if ($el == null) return; - - if (state.selected.stats?.changedFiles == null) { - $el.innerHTML = ''; - return; - } - - if (typeof state.selected.stats.changedFiles === 'number') { - $el.innerHTML = /*html*/ ` - - `; - } else { - const { added, deleted, changed } = state.selected.stats.changedFiles; - $el.innerHTML = /*html*/ ` - - `; - } - } - - renderFiles(state: CommitState) { - const $el = document.querySelector('[data-region="files"]'); - if ($el == null) return; - - const layout = state.preferences?.files?.layout ?? ViewFilesLayout.Auto; - - const $toggle = document.querySelector('[data-switch-value]'); - if ($toggle) { - switch (layout) { - case ViewFilesLayout.Auto: - $toggle.setAttribute('data-switch-value', 'list'); - $toggle.setAttribute('icon', 'list-flat'); - $toggle.setAttribute('label', 'View as List'); - break; - case ViewFilesLayout.List: - $toggle.setAttribute('data-switch-value', 'tree'); - $toggle.setAttribute('icon', 'list-tree'); - $toggle.setAttribute('label', 'View as Tree'); - break; - case ViewFilesLayout.Tree: - $toggle.setAttribute('data-switch-value', 'auto'); - $toggle.setAttribute('icon', 'gl-list-auto'); - $toggle.setAttribute('label', 'View as Auto'); - break; - } - } - - if (!state.selected.files?.length) { - $el.innerHTML = ''; - return; - } - - let isTree: boolean; - if (layout === ViewFilesLayout.Auto) { - isTree = state.selected.files.length > (state.preferences?.files?.threshold ?? 5); - } else { - isTree = layout === ViewFilesLayout.Tree; - } - - const stashAttr = state.selected.isStash - ? 'stash ' - : state.selected.sha === uncommittedSha - ? 'uncommitted ' - : ''; - - if (isTree) { - const tree = makeHierarchical( - state.selected.files, - n => n.path.split('/'), - (...parts: string[]) => parts.join('/'), - this.state.preferences?.files?.compact ?? true, - ); - const flatTree = flattenHeirarchy(tree); - - $el.innerHTML = ` -
  • - - ${flatTree - .map(({ level, item }) => { - if (item.name === '') { - return ''; - } - - if (item.value == null) { - return /*html*/ ` - - - ${item.name} - - `; - } - - return /*html*/ ` - - `; - }) - .join('')} - -
  • `; - } else { - $el.innerHTML = /*html*/ ` -
  • - - ${state.selected.files - .map( - (file: Record) => /*html*/ ` - - `, - ) - .join('')} - -
  • `; - } - $el.setAttribute('aria-hidden', 'false'); - } - - renderAuthor(state: CommitState) { - const $el = document.querySelector('[data-region="author"]'); - if ($el == null) return; - - if (state.selected?.isStash === true) { - $el.innerHTML = /*html*/ ` -
    - - stashed -
    - `; - $el.setAttribute('aria-hidden', 'false'); - } else if (state.selected?.author != null) { - $el.innerHTML = /*html*/ ` - - `; - $el.setAttribute('aria-hidden', 'false'); - } else { - $el.innerHTML = ''; - $el.setAttribute('aria-hidden', 'true'); - } - } - - // renderCommitter(state: CommitState) { - // //
  • - // // - // //
  • - // const $el = document.querySelector('[data-region="committer"]'); - // if ($el == null) { - // return; - // } - - // if (state.selected.committer != null) { - // $el.innerHTML = ` - // - // `; - // $el.setAttribute('aria-hidden', 'false'); - // } else { - // $el.innerHTML = ''; - // $el.setAttribute('aria-hidden', 'true'); - // } - // } - - renderTitle(state: CommitState) { - // - const $el = document.querySelector('[data-region="commit-title"]'); - if ($el == null) return; - - $el.innerHTML = state.selected.shortSha; - } - - renderMessage(state: CommitState) { - const $el = document.querySelector('[data-region="message"]'); - if ($el == null) return; - - const index = state.selected.message.indexOf(messageHeadlineSplitterToken); - if (index === -1) { - $el.innerHTML = /*html*/ `${state.selected.message}`; - } else { - $el.innerHTML = /*html*/ `${state.selected.message.substring( - 0, - index, - )}
    ${state.selected.message.substring(index + 3)}`; - } - } - - renderPullRequestAndAutolinks(state: CommitState) { - const $el = document.querySelector('[data-region="rich-pane"]'); - if ($el == null) return; - - $el.expanded = this.state.preferences?.autolinksExpanded ?? true; - $el.loading = !state.includeRichContent; - - const $info = $el.querySelector('[data-region="rich-info"]'); - const $autolinks = $el.querySelector('[data-region="autolinks"]'); - const autolinkedIssuesCount = state.autolinkedIssues?.length ?? 0; - let autolinksCount = state.selected.autolinks?.length ?? 0; - let count = autolinksCount; - if (state.pullRequest != null || autolinkedIssuesCount || autolinksCount) { - let dedupedAutolinks = state.selected.autolinks; - if (dedupedAutolinks?.length && autolinkedIssuesCount) { - dedupedAutolinks = dedupedAutolinks.filter( - autolink => !state.autolinkedIssues?.some(issue => issue.url === autolink.url), - ); - } - - $autolinks?.setAttribute('aria-hidden', 'false'); - $info?.setAttribute('aria-hidden', 'true'); - this.renderAutolinks({ - ...state, - selected: { - ...state.selected, - autolinks: dedupedAutolinks, - }, - }); - this.renderPullRequest(state); - this.renderIssues(state); - - autolinksCount = dedupedAutolinks?.length ?? 0; - count = (state.pullRequest != null ? 1 : 0) + autolinkedIssuesCount + autolinksCount; - } else { - $autolinks?.setAttribute('aria-hidden', 'true'); - $info?.setAttribute('aria-hidden', 'false'); - } - - const $count = $el.querySelector('[data-region="autolink-count"]'); - if ($count == null) return; - - $count.innerHTML = `${state.includeRichContent || autolinksCount ? `${count} found ` : ''}${ - state.includeRichContent ? '' : 'â€Ļ' - }`; - } - - renderAutolinks(state: CommitState) { - const $el = document.querySelector('[data-region="custom-autolinks"]'); - if ($el == null) return; - - if (state.selected.autolinks?.length) { - $el.innerHTML = state.selected.autolinks - .map(autolink => { - let name = autolink.description ?? autolink.title; - if (name === undefined) { - name = `Custom Autolink ${autolink.prefix}${autolink.id}`; - } - return /*html*/ ` - - `; - }) - .join(''); - $el.setAttribute('aria-hidden', 'false'); - } else { - $el.innerHTML = ''; - $el.setAttribute('aria-hidden', 'true'); - } - } - - renderPullRequest(state: CommitState) { - const $el = document.querySelector('[data-region="pull-request"]'); - if ($el == null) return; - - if (state.pullRequest != null) { - $el.innerHTML = /*html*/ ` - - `; - $el.setAttribute('aria-hidden', 'false'); - } else { - $el.innerHTML = ''; - $el.setAttribute('aria-hidden', 'true'); - } - } - - renderIssues(state: CommitState) { - const $el = document.querySelector('[data-region="issue"]'); - if ($el == null) return; - - if (state.autolinkedIssues?.length) { - $el.innerHTML = state.autolinkedIssues - .map( - issue => /*html*/ ` - - `, - ) - .join(''); - $el.setAttribute('aria-hidden', 'false'); - } else { - $el.innerHTML = ''; - $el.setAttribute('aria-hidden', 'true'); - } - } -} - -function assertsSerialized(obj: unknown): asserts obj is Serialized {} - -function flattenHeirarchy(item: HierarchicalItem, level = 0): { level: number; item: HierarchicalItem }[] { - const flattened: { level: number; item: HierarchicalItem }[] = []; - if (item == null) return flattened; - - flattened.push({ level: level, item: item }); - - if (item.children != null) { - const children = Array.from(item.children.values()); - children.sort((a, b) => { - if (!a.value || !b.value) { - return (a.value ? 1 : -1) - (b.value ? 1 : -1); - } - - if (a.relativePath < b.relativePath) { - return -1; - } - - if (a.relativePath > b.relativePath) { - return 1; - } - - return 0; - }); - - children.forEach(child => { - flattened.push(...flattenHeirarchy(child, level + 1)); + const component = document.getElementById('app') as GlCommitDetailsApp; + component.state = this.state; + DOM.on>(component, 'state-changed', e => { + this.state = e.detail; + this.setState(this.state); }); } - - return flattened; -} - -function escapeHTMLString(value: string) { - return value.replace(/"/g, '"'); } new CommitDetailsApp(); diff --git a/src/webviews/apps/commitDetails/components/button.css.ts b/src/webviews/apps/commitDetails/components/button.css.ts new file mode 100644 index 0000000000000..0c53ef57f6905 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/button.css.ts @@ -0,0 +1,37 @@ +import { css } from 'lit'; + +export const buttonStyles = css` + .button-container { + margin: 1rem auto 0; + text-align: left; + max-width: 30rem; + transition: max-width 0.2s ease-out; + } + + @media (min-width: 640px) { + .button-container { + max-width: 100%; + } + } + + .button-group { + display: inline-flex; + gap: 0.1rem; + } + .button-group--single { + width: 100%; + max-width: 30rem; + } + + .button-group > *:not(:first-child), + .button-group > *:not(:first-child) gl-button { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + .button-group > *:not(:last-child), + .button-group > *:not(:last-child) gl-button { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +`; diff --git a/src/webviews/apps/commitDetails/components/commit-action.css.ts b/src/webviews/apps/commitDetails/components/commit-action.css.ts new file mode 100644 index 0000000000000..ec51e7c0c6615 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/commit-action.css.ts @@ -0,0 +1,67 @@ +import { css } from 'lit'; + +export const commitActionStyles = css` + .commit-action { + display: inline-flex; + justify-content: center; + align-items: center; + height: 21px; + border-radius: 0.25em; + color: inherit; + padding: 0.2rem; + vertical-align: text-bottom; + text-decoration: none; + gap: 0.2rem; + } + + .commit-action > * { + pointer-events: none; + } + + .commit-action:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .commit-action:hover { + color: var(--vscode-foreground); + text-decoration: none; + } + + :host-context(.vscode-dark) .commit-action:hover { + background-color: var(--color-background--lighten-15); + } + :host-context(.vscode-light) .commit-action:hover { + background-color: var(--color-background--darken-15); + } + + :host-context(.vscode-dark) .commit-action.is-active { + background-color: var(--color-background--lighten-10); + } + :host-context(.vscode-light) .commit-action.is-active { + background-color: var(--color-background--darken-10); + } + + .commit-action.is-disabled { + opacity: 0.5; + pointer-events: none; + } + + .commit-action.is-hidden { + display: none; + } + + .commit-action--emphasis-low:not(:hover, :focus, :active) { + opacity: 0.5; + } + + .pr--opened { + color: var(--vscode-gitlens-openPullRequestIconColor); + } + .pr--closed { + color: var(--vscode-gitlens-closedPullRequestIconColor); + } + .pr--merged { + color: var(--vscode-gitlens-mergedPullRequestIconColor); + } +`; diff --git a/src/webviews/apps/commitDetails/components/commit-details-app.ts b/src/webviews/apps/commitDetails/components/commit-details-app.ts new file mode 100644 index 0000000000000..fc9b31b3da6e8 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/commit-details-app.ts @@ -0,0 +1,735 @@ +import { Badge, defineGkElement } from '@gitkraken/shared-web-components'; +import { html, LitElement, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { ViewFilesLayout } from '../../../../config'; +import { pluralize } from '../../../../system/string'; +import type { Serialized } from '../../../../system/vscode/serialize'; +import type { DraftState, ExecuteCommitActionsParams, Mode, State } from '../../../commitDetails/protocol'; +import { + ChangeReviewModeCommand, + CreatePatchFromWipCommand, + DidChangeConnectedJiraNotification, + DidChangeDraftStateNotification, + DidChangeHasAccountNotification, + DidChangeNotification, + DidChangeWipStateNotification, + ExecuteCommitActionCommand, + ExecuteFileActionCommand, + ExplainRequest, + FetchCommand, + GenerateRequest, + NavigateCommand, + OpenFileCommand, + OpenFileComparePreviousCommand, + OpenFileCompareWorkingCommand, + OpenFileOnRemoteCommand, + OpenPullRequestChangesCommand, + OpenPullRequestComparisonCommand, + OpenPullRequestDetailsCommand, + OpenPullRequestOnRemoteCommand, + PickCommitCommand, + PinCommand, + PublishCommand, + PullCommand, + PushCommand, + SearchCommitCommand, + ShowCodeSuggestionCommand, + StageFileCommand, + SuggestChangesCommand, + SwitchCommand, + SwitchModeCommand, + UnstageFileCommand, + UpdatePreferencesCommand, +} from '../../../commitDetails/protocol'; +import type { IpcMessage } from '../../../protocol'; +import { ExecuteCommand } from '../../../protocol'; +import type { CreatePatchMetadataEventDetail } from '../../plus/patchDetails/components/gl-patch-create'; +import type { IssuePullRequest } from '../../shared/components/rich/issue-pull-request'; +import type { WebviewPane, WebviewPaneExpandedChangeEventDetail } from '../../shared/components/webview-pane'; +import type { Disposable } from '../../shared/dom'; +import { DOM } from '../../shared/dom'; +import { assertsSerialized, HostIpc } from '../../shared/ipc'; +import type { GlCommitDetails } from './gl-commit-details'; +import type { FileChangeListItemDetail } from './gl-details-base'; +import type { GlInspectNav } from './gl-inspect-nav'; +import type { CreatePatchEventDetail, GenerateState } from './gl-inspect-patch'; +import type { GlWipDetails } from './gl-wip-details'; +import '../../shared/components/code-icon'; +import '../../shared/components/indicators/indicator'; +import '../../shared/components/overlays/tooltip'; +import '../../shared/components/pills/tracking'; +import './gl-commit-details'; +import './gl-wip-details'; +import './gl-inspect-nav'; +import './gl-status-nav'; + +export const uncommittedSha = '0000000000000000000000000000000000000000'; + +interface ExplainState { + cancelled?: boolean; + error?: { message: string }; + summary?: string; +} + +@customElement('gl-commit-details-app') +export class GlCommitDetailsApp extends LitElement { + @property({ type: Object }) + state?: Serialized; + + @property({ type: Object }) + explain?: ExplainState; + + @property({ type: Object }) + generate?: GenerateState; + + @state() + draftState: DraftState = { inReview: false }; + + @state() + get isUncommitted() { + return this.state?.commit?.sha === uncommittedSha; + } + + get hasCommit() { + return this.state?.commit != null; + } + + @state() + get isStash() { + return this.state?.commit?.stashNumber != null; + } + + get wipStatus() { + const wip = this.state?.wip; + if (wip == null) return undefined; + + const branch = wip.branch; + if (branch == null) return undefined; + + const changes = wip.changes; + const working = changes?.files.length ?? 0; + const ahead = branch.tracking?.ahead ?? 0; + const behind = branch.tracking?.behind ?? 0; + const status = + behind > 0 && ahead > 0 + ? 'both' + : behind > 0 + ? 'behind' + : ahead > 0 + ? 'ahead' + : working > 0 + ? 'working' + : undefined; + + const branchName = wip.repositoryCount > 1 ? `${wip.repo.name}:${branch.name}` : branch.name; + + return { + branch: branchName, + upstream: branch.upstream?.name, + ahead: ahead, + behind: behind, + working: wip.changes?.files.length ?? 0, + status: status, + }; + } + + get navigation() { + if (this.state?.navigationStack == null) { + return { + back: false, + forward: false, + }; + } + + const actions = { + back: true, + forward: true, + }; + + if (this.state.navigationStack.count <= 1) { + actions.back = false; + actions.forward = false; + } else if (this.state.navigationStack.position === 0) { + actions.back = true; + actions.forward = false; + } else if (this.state.navigationStack.position === this.state.navigationStack.count - 1) { + actions.back = false; + actions.forward = true; + } + + return actions; + } + + private _disposables: Disposable[] = []; + private _hostIpc!: HostIpc; + + constructor() { + super(); + + defineGkElement(Badge); + } + + private indentPreference = 16; + private updateDocumentProperties() { + const preference = this.state?.preferences?.indent; + if (preference === this.indentPreference) return; + this.indentPreference = preference ?? 16; + + const rootStyle = document.documentElement.style; + rootStyle.setProperty('--gitlens-tree-indent', `${this.indentPreference}px`); + } + + override updated(changedProperties: Map) { + if (changedProperties.has('state')) { + this.updateDocumentProperties(); + if (this.state?.inReview != null && this.state.inReview != this.draftState.inReview) { + this.draftState.inReview = this.state.inReview; + } + } + } + + override connectedCallback() { + super.connectedCallback(); + + this._hostIpc = new HostIpc('commit-details'); + + this._disposables = [ + this._hostIpc.onReceiveMessage(e => this.onMessageReceived(e)), + this._hostIpc, + + DOM.on('gl-inspect-nav', 'gl-commit-actions', e => + this.onCommitActions(e), + ), + DOM.on('gl-status-nav', 'gl-branch-action', e => + this.onBranchAction(e.detail.action), + ), + DOM.on('[data-action="pick-commit"]', 'click', e => this.onPickCommit(e)), + DOM.on('[data-action="wip"]', 'click', e => this.onSwitchMode(e, 'wip')), + DOM.on('[data-action="details"]', 'click', e => this.onSwitchMode(e, 'commit')), + DOM.on('[data-action="search-commit"]', 'click', e => this.onSearchCommit(e)), + DOM.on('[data-action="files-layout"]', 'click', e => this.onToggleFilesLayout(e)), + DOM.on('gl-inspect-nav', 'gl-pin', () => this.onTogglePin()), + DOM.on('gl-inspect-nav', 'gl-back', () => this.onNavigate('back')), + DOM.on('gl-inspect-nav', 'gl-forward', () => this.onNavigate('forward')), + DOM.on('[data-action="create-patch"]', 'click', _e => this.onCreatePatchFromWip(true)), + DOM.on( + '[data-region="rich-pane"]', + 'expanded-change', + e => this.onExpandedChange(e.detail, 'autolinks'), + ), + DOM.on( + '[data-region="pullrequest-pane"]', + 'expanded-change', + e => this.onExpandedChange(e.detail, 'pullrequest'), + ), + DOM.on('[data-action="explain-commit"]', 'click', e => this.onExplainCommit(e)), + DOM.on('[data-action="switch-ai"]', 'click', e => this.onSwitchAiModel(e)), + DOM.on('gl-wip-details', 'create-patch', e => + this.onCreatePatchFromWip(e.detail.checked), + ), + + DOM.on('gl-commit-details', 'file-open-on-remote', e => + this.onOpenFileOnRemote(e.detail), + ), + DOM.on('gl-commit-details,gl-wip-details', 'file-open', e => + this.onOpenFile(e.detail), + ), + DOM.on('gl-commit-details', 'file-compare-working', e => + this.onCompareFileWithWorking(e.detail), + ), + DOM.on( + 'gl-commit-details,gl-wip-details', + 'file-compare-previous', + e => this.onCompareFileWithPrevious(e.detail), + ), + DOM.on('gl-commit-details', 'file-more-actions', e => + this.onFileMoreActions(e.detail), + ), + DOM.on('gl-wip-details', 'file-stage', e => + this.onStageFile(e.detail), + ), + DOM.on('gl-wip-details', 'file-unstage', e => + this.onUnstageFile(e.detail), + ), + DOM.on('gl-wip-details', 'data-action', e => + this.onBranchAction(e.detail.name), + ), + DOM.on('gl-wip-details', 'gl-inspect-create-suggestions', e => + this.onSuggestChanges(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-generate-title', e => + this.onCreateGenerateTitle(e.detail), + ), + DOM.on('gl-wip-details', 'gl-show-code-suggestion', e => + this.onShowCodeSuggestion(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-file-compare-previous', e => + this.onCompareFileWithPrevious(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-file-open', e => + this.onOpenFile(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-file-stage', e => + this.onStageFile(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-file-unstage', e => + this.onUnstageFile(e.detail), + ), + DOM.on('gl-wip-details', 'gl-patch-create-cancelled', () => + this.onDraftStateChanged(false), + ), + DOM.on( + 'gl-status-nav,issue-pull-request', + 'gl-issue-pull-request-details', + () => this.onBranchAction('open-pr-details'), + ), + ]; + } + + private onSuggestChanges(e: CreatePatchEventDetail) { + this._hostIpc.sendCommand(SuggestChangesCommand, e); + } + + private onShowCodeSuggestion(e: { id: string }) { + this._hostIpc.sendCommand(ShowCodeSuggestionCommand, e); + } + + private onMessageReceived(msg: IpcMessage) { + switch (true) { + // case DidChangeRichStateNotificationType.method: + // onIpc(DidChangeRichStateNotificationType, msg, params => { + // if (this.state.selected == null) return; + + // assertsSerialized(params); + + // const newState = { ...this.state }; + // if (params.formattedMessage != null) { + // newState.selected!.message = params.formattedMessage; + // } + // // if (params.pullRequest != null) { + // newState.pullRequest = params.pullRequest; + // // } + // // if (params.formattedMessage != null) { + // newState.autolinkedIssues = params.autolinkedIssues; + // // } + + // this.state = newState; + // this.setState(this.state); + + // this.renderRichContent(); + // }); + // break; + case DidChangeNotification.is(msg): + assertsSerialized(msg.params.state); + + this.state = msg.params.state; + this.dispatchEvent(new CustomEvent('state-changed', { detail: this.state })); + // this.setState(this.state); + // this.attachState(); + break; + + case DidChangeWipStateNotification.is(msg): + this.state = { ...this.state!, wip: msg.params.wip, inReview: msg.params.inReview }; + this.dispatchEvent(new CustomEvent('state-changed', { detail: this.state })); + // this.setState(this.state); + // this.attachState(); + break; + case DidChangeDraftStateNotification.is(msg): + this.onDraftStateChanged(msg.params.inReview, true); + break; + case DidChangeConnectedJiraNotification.is(msg): + this.state = { ...this.state!, hasConnectedJira: msg.params.hasConnectedJira }; + this.dispatchEvent(new CustomEvent('state-changed', { detail: this.state })); + break; + case DidChangeHasAccountNotification.is(msg): + this.state = { ...this.state!, hasAccount: msg.params.hasAccount }; + this.dispatchEvent(new CustomEvent('state-changed', { detail: this.state })); + break; + } + } + + override disconnectedCallback() { + this._disposables.forEach(d => d.dispose()); + this._disposables = []; + + super.disconnectedCallback(); + } + + renderTopInspect() { + if (this.state?.commit == null) return nothing; + + return html``; + } + + renderTopWip() { + if (this.state?.wip == null) return nothing; + + return html``; + } + + private renderRepoStatusContent(_isWip: boolean) { + const statusIndicator = this.wipStatus?.status; + return html` + + ${when( + this.wipStatus?.status != null, + () => + html``, + )} + ${when( + statusIndicator != null, + () => + html``, + )} + `; + // ${when( + // isWip !== true && statusIndicator != null, + // () => html``, + // )} + } + + renderWipTooltipContent() { + if (this.wipStatus == null) return 'Overview'; + + return html` + Overview of  ${this.wipStatus.branch} + ${when( + this.wipStatus.status === 'both', + () => + html`
    + ${this.wipStatus!.branch} is + ${pluralize('commit', this.wipStatus!.behind)} behind and + ${pluralize('commit', this.wipStatus!.ahead)} ahead of + ${this.wipStatus!.upstream ?? 'origin'}`, + )} + ${when( + this.wipStatus.status === 'behind', + () => + html`
    + ${this.wipStatus!.branch} is + ${pluralize('commit', this.wipStatus!.behind)} behind + ${this.wipStatus!.upstream ?? 'origin'}`, + )} + ${when( + this.wipStatus.status === 'ahead', + () => + html`
    + ${this.wipStatus!.branch} is + ${pluralize('commit', this.wipStatus!.ahead)} ahead of + ${this.wipStatus!.upstream ?? 'origin'}`, + )} + ${when( + this.wipStatus.working > 0, + () => + html`
    + ${pluralize('working change', this.wipStatus!.working)}`, + )} + `; + } + + renderTopSection() { + const isWip = this.state?.mode === 'wip'; + + return html` +
    + +
    + ${when( + this.state?.mode !== 'wip', + () => this.renderTopInspect(), + () => this.renderTopWip(), + )} +
    +
    + `; + } + + override render() { + const wip = this.state?.wip; + + return html` +
    + ${this.renderTopSection()} +
    + ${when( + this.state?.mode === 'commit', + () => + html``, + () => + html`) => + this.onDraftStateChanged(e.detail.inReview)} + >`, + )} +
    +
    + `; + } + + protected override createRenderRoot() { + return this; + } + + private onDraftStateChanged(inReview: boolean, silent = false) { + if (inReview === this.draftState.inReview) return; + this.draftState = { ...this.draftState, inReview: inReview }; + this.requestUpdate('draftState'); + + if (!silent) { + this._hostIpc.sendCommand(ChangeReviewModeCommand, { inReview: inReview }); + } + } + + private onBranchAction(name: string) { + switch (name) { + case 'pull': + this._hostIpc.sendCommand(PullCommand, undefined); + break; + case 'push': + this._hostIpc.sendCommand(PushCommand, undefined); + // this.onCommandClickedCore('gitlens.pushRepositories'); + break; + case 'fetch': + this._hostIpc.sendCommand(FetchCommand, undefined); + // this.onCommandClickedCore('gitlens.fetchRepositories'); + break; + case 'publish-branch': + this._hostIpc.sendCommand(PublishCommand, undefined); + // this.onCommandClickedCore('gitlens.publishRepository'); + break; + case 'switch': + this._hostIpc.sendCommand(SwitchCommand, undefined); + // this.onCommandClickedCore('gitlens.views.switchToBranch'); + break; + case 'open-pr-changes': + this._hostIpc.sendCommand(OpenPullRequestChangesCommand, undefined); + break; + case 'open-pr-compare': + this._hostIpc.sendCommand(OpenPullRequestComparisonCommand, undefined); + break; + case 'open-pr-remote': + this._hostIpc.sendCommand(OpenPullRequestOnRemoteCommand, undefined); + break; + case 'open-pr-details': + this._hostIpc.sendCommand(OpenPullRequestDetailsCommand, undefined); + break; + } + } + + private onCreatePatchFromWip(checked: boolean | 'staged' = true) { + if (this.state?.wip?.changes == null) return; + this._hostIpc.sendCommand(CreatePatchFromWipCommand, { changes: this.state.wip.changes, checked: checked }); + } + + private onCommandClickedCore(action?: string) { + const command = action?.startsWith('command:') ? action.slice(8) : action; + if (command == null) return; + + this._hostIpc.sendCommand(ExecuteCommand, { command: command }); + } + + private onSwitchAiModel(_e: MouseEvent) { + this.onCommandClickedCore('gitlens.switchAIModel'); + } + + async onExplainCommit(_e: MouseEvent) { + try { + const result = await this._hostIpc.sendRequest(ExplainRequest, undefined); + if (result.error) { + this.explain = { error: { message: result.error.message ?? 'Error retrieving content' } }; + } else if (result.summary) { + this.explain = { summary: result.summary }; + } else { + this.explain = undefined; + } + } catch (_ex) { + this.explain = { error: { message: 'Error retrieving content' } }; + } + } + + private async onCreateGenerateTitle(_e: CreatePatchMetadataEventDetail) { + try { + const result = await this._hostIpc.sendRequest(GenerateRequest, undefined); + + if (result.error) { + this.generate = { error: { message: result.error.message ?? 'Error retrieving content' } }; + } else if (result.title || result.description) { + this.generate = { + title: result.title, + description: result.description, + }; + // this.state = { + // ...this.state, + // create: { + // ...this.state.create!, + // title: result.title ?? this.state.create?.title, + // description: result.description ?? this.state.create?.description, + // }, + // }; + // this.setState(this.state); + } else { + this.generate = undefined; + } + } catch (_ex) { + this.generate = { error: { message: 'Error retrieving content' } }; + } + this.requestUpdate('generate'); + } + + private onToggleFilesLayout(e: MouseEvent) { + const layout = ((e.target as HTMLElement)?.dataset.filesLayout as ViewFilesLayout) ?? undefined; + if (layout === this.state?.preferences?.files?.layout) return; + + const files = { + ...this.state!.preferences?.files, + layout: layout ?? 'auto', + }; + + this.state = { ...this.state, preferences: { ...this.state!.preferences, files: files } } as any; + // this.attachState(); + + this._hostIpc.sendCommand(UpdatePreferencesCommand, { files: files }); + } + + private onExpandedChange(e: WebviewPaneExpandedChangeEventDetail, pane: string) { + let preferenceChange; + if (pane === 'autolinks') { + preferenceChange = { autolinksExpanded: e.expanded }; + } else if (pane === 'pullrequest') { + preferenceChange = { pullRequestExpanded: e.expanded }; + } + if (preferenceChange == null) return; + + this.state = { + ...this.state, + preferences: { ...this.state!.preferences, ...preferenceChange }, + } as any; + // this.attachState(); + + this._hostIpc.sendCommand(UpdatePreferencesCommand, preferenceChange); + } + + private onNavigate(direction: 'back' | 'forward') { + this._hostIpc.sendCommand(NavigateCommand, { direction: direction }); + } + + private onTogglePin() { + this._hostIpc.sendCommand(PinCommand, { pin: !this.state!.pinned }); + } + + private onPickCommit(_e: MouseEvent) { + this._hostIpc.sendCommand(PickCommitCommand, undefined); + } + + private onSearchCommit(_e: MouseEvent) { + this._hostIpc.sendCommand(SearchCommitCommand, undefined); + } + + private onSwitchMode(_e: MouseEvent, mode: Mode) { + this.state = { ...this.state, mode: mode } as any; + // this.attachState(); + + this._hostIpc.sendCommand(SwitchModeCommand, { mode: mode, repoPath: this.state!.commit?.repoPath }); + } + + private onOpenFileOnRemote(e: FileChangeListItemDetail) { + this._hostIpc.sendCommand(OpenFileOnRemoteCommand, e); + } + + private onOpenFile(e: FileChangeListItemDetail) { + this._hostIpc.sendCommand(OpenFileCommand, e); + } + + private onCompareFileWithWorking(e: FileChangeListItemDetail) { + this._hostIpc.sendCommand(OpenFileCompareWorkingCommand, e); + } + + private onCompareFileWithPrevious(e: FileChangeListItemDetail) { + this._hostIpc.sendCommand(OpenFileComparePreviousCommand, e); + } + + private onFileMoreActions(e: FileChangeListItemDetail) { + this._hostIpc.sendCommand(ExecuteFileActionCommand, e); + } + + private onStageFile(e: FileChangeListItemDetail): void { + this._hostIpc.sendCommand(StageFileCommand, e); + } + + private onUnstageFile(e: FileChangeListItemDetail): void { + this._hostIpc.sendCommand(UnstageFileCommand, e); + } + + private onCommitActions(e: CustomEvent<{ action: string; alt: boolean }>) { + if (this.state?.commit === undefined) { + return; + } + + this._hostIpc.sendCommand(ExecuteCommitActionCommand, { + action: e.detail.action as ExecuteCommitActionsParams['action'], + alt: e.detail.alt, + }); + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-commit-details.ts b/src/webviews/apps/commitDetails/components/gl-commit-details.ts new file mode 100644 index 0000000000000..36abe92b4747b --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-commit-details.ts @@ -0,0 +1,538 @@ +import { html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { when } from 'lit/directives/when.js'; +import type { Autolink } from '../../../../annotations/autolinks'; +import type { + ConnectCloudIntegrationsCommandArgs, + ManageCloudIntegrationsCommandArgs, +} from '../../../../commands/cloudIntegrations'; +import type { IssueOrPullRequest } from '../../../../git/models/issue'; +import type { PullRequestShape } from '../../../../git/models/pullRequest'; +import type { SupportedCloudIntegrationIds } from '../../../../plus/integrations/authentication/models'; +import type { IssueIntegrationId } from '../../../../plus/integrations/providers/models'; +import type { Serialized } from '../../../../system/vscode/serialize'; +import type { State } from '../../../commitDetails/protocol'; +import { messageHeadlineSplitterToken } from '../../../commitDetails/protocol'; +import type { TreeItemAction, TreeItemBase } from '../../shared/components/tree/base'; +import { uncommittedSha } from './commit-details-app'; +import type { File } from './gl-details-base'; +import { GlDetailsBase } from './gl-details-base'; +import '../../shared/components/button'; +import '../../shared/components/code-icon'; +import '../../shared/components/skeleton-loader'; +import '../../shared/components/webview-pane'; +import '../../shared/components/actions/action-item'; +import '../../shared/components/actions/action-nav'; +import '../../shared/components/commit/commit-identity'; +import '../../shared/components/commit/commit-stats'; +import '../../shared/components/overlays/popover'; +import '../../shared/components/overlays/tooltip'; +import '../../shared/components/rich/issue-pull-request'; + +interface ExplainState { + cancelled?: boolean; + error?: { message: string }; + summary?: string; +} + +@customElement('gl-commit-details') +export class GlCommitDetails extends GlDetailsBase { + override readonly tab = 'commit'; + + @property({ type: Object }) + state?: Serialized; + + @state() + get isStash() { + return this.state?.commit?.stashNumber != null; + } + + @state() + get shortSha() { + return this.state?.commit?.shortSha ?? ''; + } + + @state() + explainBusy = false; + + @property({ type: Object }) + explain?: ExplainState; + + get navigation() { + if (this.state?.navigationStack == null) { + return { + back: false, + forward: false, + }; + } + + const actions = { + back: true, + forward: true, + }; + + if (this.state.navigationStack.count <= 1) { + actions.back = false; + actions.forward = false; + } else if (this.state.navigationStack.position === 0) { + actions.back = true; + actions.forward = false; + } else if (this.state.navigationStack.position === this.state.navigationStack.count - 1) { + actions.back = false; + actions.forward = true; + } + + return actions; + } + + override updated(changedProperties: Map) { + if (changedProperties.has('explain')) { + this.explainBusy = false; + this.querySelector('[data-region="commit-explanation"]')?.scrollIntoView(); + } + } + + private renderEmptyContent() { + return html` +
    +

    Rich details for commits and stashes are shown as you navigate:

    + + + +

    Alternatively, show your work-in-progress, or search for or choose a commit

    + +

    + + Overview + +

    +

    + + Choose Commit... + + +

    +
    + `; + } + + private renderCommitMessage() { + const details = this.state?.commit; + if (details == null) return undefined; + + const message = details.message; + const index = message.indexOf(messageHeadlineSplitterToken); + return html` +
    + ${when( + !this.isStash, + () => html` + + `, + )} +
    + ${when( + index === -1, + () => + html`

    + ${unsafeHTML(message)} +

    `, + () => + html`

    + ${unsafeHTML(message.substring(0, index))}
    ${unsafeHTML(message.substring(index + 3))} +

    `, + )} +
    +
    + `; + } + + private renderJiraLink() { + if (this.state == null) return 'Jira issues'; + + const { hasAccount, hasConnectedJira } = this.state; + + let message = html`Connect to Jira Cloud + — ${hasAccount ? '' : 'sign up and '}get access to automatic rich Jira autolinks`; + + if (hasAccount && hasConnectedJira) { + message = html` Jira connected + — automatic rich Jira autolinks are enabled`; + } + + return html` + Jira issues + ${message} + `; + } + + private renderAutoLinks() { + if (this.isUncommitted) return undefined; + + const deduped = new Map< + string, + | { type: 'autolink'; value: Serialized } + | { type: 'issue'; value: Serialized } + | { type: 'pr'; value: Serialized } + >(); + + if (this.state?.commit?.autolinks != null) { + for (const autolink of this.state.commit.autolinks) { + deduped.set(autolink.id, { type: 'autolink', value: autolink }); + } + } + + if (this.state?.autolinkedIssues != null) { + for (const issue of this.state.autolinkedIssues) { + deduped.set(issue.id, { type: 'issue', value: issue }); + } + } + + if (this.state?.pullRequest != null) { + deduped.set(this.state.pullRequest.id, { type: 'pr', value: this.state.pullRequest }); + } + + const autolinks: Serialized[] = []; + const issues: Serialized[] = []; + const prs: Serialized[] = []; + + for (const item of deduped.values()) { + switch (item.type) { + case 'autolink': + autolinks.push(item.value); + break; + case 'issue': + issues.push(item.value); + break; + case 'pr': + prs.push(item.value); + break; + } + } + + const { hasAccount, hasConnectedJira } = this.state ?? {}; + const jiraIntegrationLink = hasConnectedJira + ? `command:gitlens.plus.cloudIntegrations.manage?${encodeURIComponent( + JSON.stringify({ + source: 'inspect', + detail: { + action: 'connect', + integration: 'jira', + }, + } satisfies ManageCloudIntegrationsCommandArgs), + )}` + : `command:gitlens.plus.cloudIntegrations.connect?${encodeURIComponent( + JSON.stringify({ + integrationIds: ['jira' as IssueIntegrationId.Jira] as SupportedCloudIntegrationIds[], + source: 'inspect', + detail: { + action: 'connect', + integration: 'jira', + }, + } satisfies ConnectCloudIntegrationsCommandArgs), + )}`; + return html` + + Autolinks + ${this.state?.includeRichContent || deduped.size ? `${deduped.size} found ` : ''}${this.state + ?.includeRichContent + ? '' + : 'â€Ļ'} + + + + + ${when( + this.state == null, + () => html` +
    + +
    + +
    +
    + +
    +
    + `, + () => { + if (deduped.size === 0) { + return html` +
    +

    +  Use + + autolinks + Configure autolinks + + to linkify external references, like ${this.renderJiraLink()} or Zendesk + tickets, in commit messages. +

    +
    + `; + } + return html` +
    + ${autolinks.length + ? html` + + ` + : undefined} + ${prs.length + ? html` +
    + ${prs.map( + pr => html` + +
    + `, + )} + + ` + : undefined} + ${issues.length + ? html` +
    + ${issues.map( + issue => html` + + `, + )} +
    + ` + : undefined} +
    + `; + }, + )} +
    + `; + } + + private renderExplainAi() { + if (this.state?.orgSettings.ai === false) return undefined; + + // TODO: add loading and response states + return html` + + Explain (AI) + + + + + +
    +

    Let AI assist in understanding the changes made with this commit.

    +

    + + Explain + Changes + +

    + ${when( + this.explain, + () => html` +
    + ${when( + this.explain?.error, + () => + html`

    + ${this.explain!.error!.message ?? 'Error retrieving content'} +

    `, + )} + ${when( + this.explain?.summary, + () => html`

    ${this.explain!.summary}

    `, + )} +
    + `, + )} +
    +
    + `; + } + + override render() { + if (this.state?.commit == null) { + return this.renderEmptyContent(); + } + + return html` + ${this.renderCommitMessage()} + + ${this.renderAutoLinks()} + ${this.renderChangedFiles( + this.isStash ? 'stash' : 'commit', + this.renderCommitStats(this.state.commit.stats), + )} + ${this.renderExplainAi()} + + `; + } + + onExplainChanges(e: MouseEvent | KeyboardEvent) { + if (this.explainBusy === true || (e instanceof KeyboardEvent && e.key !== 'Enter')) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + this.explainBusy = true; + } + + private renderCommitStats(stats?: NonNullable['commit']>['stats']) { + if (stats?.changedFiles == null) return undefined; + + if (typeof stats.changedFiles === 'number') { + return html``; + } + + const { added, deleted, changed } = stats.changedFiles; + return html``; + } + + override getFileActions(_file: File, _options?: Partial): TreeItemAction[] { + const actions = [ + { + icon: 'go-to-file', + label: 'Open file', + action: 'file-open', + }, + ]; + + if (this.isUncommitted) { + return actions; + } + + actions.push({ + icon: 'git-compare', + label: 'Open Changes with Working File', + action: 'file-compare-working', + }); + + if (!this.isStash) { + actions.push({ + icon: 'globe', + label: 'Open on remote', + action: 'file-open-on-remote', + }); + } + actions.push({ + icon: 'ellipsis', + label: 'Show more actions', + action: 'file-more-actions', + }); + return actions; + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-details-base.ts b/src/webviews/apps/commitDetails/components/gl-details-base.ts new file mode 100644 index 0000000000000..21a5c06ea8a6d --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-details-base.ts @@ -0,0 +1,524 @@ +import type { TemplateResult } from 'lit'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { TextDocumentShowOptions } from 'vscode'; +import type { HierarchicalItem } from '../../../../system/array'; +import { makeHierarchical } from '../../../../system/array'; +import { pluralize } from '../../../../system/string'; +import type { Preferences, State } from '../../../commitDetails/protocol'; +import type { + TreeItemAction, + TreeItemActionDetail, + TreeItemBase, + TreeItemCheckedDetail, + TreeItemSelectionDetail, + TreeModel, +} from '../../shared/components/tree/base'; +import '../../shared/components/webview-pane'; +import '../../shared/components/actions/action-item'; +import '../../shared/components/actions/action-nav'; +import '../../shared/components/tree/tree-generator'; + +type Files = Mutable['files']>>; +export type File = Files[0]; +type Mode = 'commit' | 'stash' | 'wip'; + +// Can only import types from 'vscode' +const BesideViewColumn = -2; /*ViewColumn.Beside*/ + +export interface FileChangeListItemDetail extends File { + showOptions?: TextDocumentShowOptions; +} + +export class GlDetailsBase extends LitElement { + readonly tab: 'wip' | 'commit' = 'commit'; + + @property({ type: Array }) + files?: Files; + + @property({ type: Boolean }) + isUncommitted = false; + + @property({ type: Object }) + preferences?: Preferences; + + @property({ attribute: 'empty-text' }) + emptyText? = 'No Files'; + + get fileLayout() { + return this.preferences?.files?.layout ?? 'auto'; + } + + get isCompact() { + return this.preferences?.files?.compact ?? true; + } + + get indentGuides(): 'none' | 'onHover' | 'always' { + return this.preferences?.indentGuides ?? 'none'; + } + + get filesChangedPaneLabel() { + const fileCount = this.files?.length ?? 0; + const filesLabel = fileCount > 0 ? pluralize('file', fileCount) : 'Files'; + return `${filesLabel} changed`; + } + + protected renderChangedFiles(mode: Mode, subtitle?: TemplateResult<1>) { + const fileCount = this.files?.length ?? 0; + const isTree = this.isTree(fileCount); + let value = 'tree'; + let icon = 'list-tree'; + let label = 'View as Tree'; + switch (this.fileLayout) { + case 'auto': + value = 'list'; + icon = 'gl-list-auto'; + label = 'View as List'; + break; + case 'list': + value = 'tree'; + icon = 'list-flat'; + label = 'View as Tree'; + break; + case 'tree': + value = 'auto'; + icon = 'list-tree'; + label = 'View as Auto'; + break; + } + + const treeModel = this.createTreeModel(mode, this.files ?? [], isTree, this.isCompact); + + return html` + + ${this.filesChangedPaneLabel} + ${subtitle} + + + + ${when( + fileCount > 0 && this.tab === 'wip', + () => + html`
    +

    + + Commit via SCM + +

    +
    `, + )} + ${this.renderTreeFileModel(treeModel)} +
    + `; + } + + protected onShareWipChanges(_e: Event, staged: boolean, hasFiles: boolean) { + if (!hasFiles) return; + const event = new CustomEvent('share-wip', { + detail: { + checked: staged, + }, + }); + this.dispatchEvent(event); + } + + protected override createRenderRoot() { + return this; + } + + // Tree Model changes + protected isTree(count: number) { + if (this.fileLayout === 'auto') { + return count > (this.preferences?.files?.threshold ?? 5); + } + return this.fileLayout === 'tree'; + } + + protected createTreeModel(mode: Mode, files: Files, isTree = false, compact = true): TreeModel[] { + if (!this.isUncommitted) { + return this.createFileTreeModel(mode, files, isTree, compact); + } + + const children = []; + const staged: Files = []; + const unstaged: Files = []; + for (const f of files) { + if (f.staged) { + staged.push(f); + } else { + unstaged.push(f); + } + } + + if (staged.length === 0 || unstaged.length === 0) { + children.push(...this.createFileTreeModel(mode, files, isTree, compact)); + } else { + if (staged.length) { + children.push({ + label: 'Staged Changes', + path: '', + level: 1, // isMulti ? 2 : 1, + branch: true, + checkable: false, + expanded: true, + checked: false, // change.checked !== false, + // disableCheck: true, + context: ['staged'], + children: this.createFileTreeModel(mode, staged, isTree, compact, { level: 2 }), + actions: this.getStagedActions(), + }); + } + + if (unstaged.length) { + children.push({ + label: 'Unstaged Changes', + path: '', + level: 1, // isMulti ? 2 : 1, + branch: true, + checkable: false, + expanded: true, + checked: false, // change.checked === true, + context: ['unstaged'], + children: this.createFileTreeModel(mode, unstaged, isTree, compact, { level: 2 }), + actions: this.getUnstagedActions(), + }); + } + } + + return children; + } + + protected sortChildren(children: TreeModel[]): TreeModel[] { + children.sort((a, b) => { + if (a.branch && !b.branch) return -1; + if (!a.branch && b.branch) return 1; + + if (a.label < b.label) return -1; + if (a.label > b.label) return 1; + + return 0; + }); + + return children; + } + + protected createFileTreeModel( + _mode: Mode, + files: Files, + isTree = false, + compact = true, + options: Partial = { level: 1 }, + ): TreeModel[] { + if (options.level === undefined) { + options.level = 1; + } + + if (!files.length) { + return [ + { + label: 'No changes', + path: '', + level: options.level, + branch: false, + checkable: false, + expanded: true, + checked: false, + }, + ]; + } + + const children: TreeModel[] = []; + if (isTree) { + const fileTree = makeHierarchical( + files, + n => n.path.split('/'), + (...parts: string[]) => parts.join('/'), + compact, + ); + if (fileTree.children != null) { + for (const child of fileTree.children.values()) { + const childModel = this.walkFileTree(child, { level: options.level }); + children.push(childModel); + } + } + } else { + for (const file of files) { + const child = this.fileToTreeModel(file, { level: options.level, branch: false }, true); + children.push(child); + } + } + + this.sortChildren(children); + + return children; + } + + protected walkFileTree(item: HierarchicalItem, options: Partial = { level: 1 }): TreeModel { + if (options.level === undefined) { + options.level = 1; + } + + let model: TreeModel; + if (item.value == null) { + model = this.folderToTreeModel(item.name, options); + } else { + model = this.fileToTreeModel(item.value, options); + } + + if (item.children != null) { + const children = []; + for (const child of item.children.values()) { + const childModel = this.walkFileTree(child, { ...options, level: options.level + 1 }); + children.push(childModel); + } + + if (children.length > 0) { + this.sortChildren(children); + model.branch = true; + model.children = children; + } + } + + return model; + } + + protected getStagedActions(_options?: Partial): TreeItemAction[] { + if (this.tab === 'wip') { + return [ + { + icon: 'gl-cloud-patch-share', + label: 'Share Staged Changes', + action: 'staged-create-patch', + }, + ]; + } + return []; + } + + protected getUnstagedActions(_options?: Partial): TreeItemAction[] { + if (this.tab === 'wip') { + return [ + { + icon: 'gl-cloud-patch-share', + label: 'Share Unstaged Changes', + action: 'unstaged-create-patch', + }, + ]; + } + return []; + } + + protected getFileActions(_file: File, _options?: Partial): TreeItemAction[] { + return []; + } + + protected fileToTreeModel( + file: File, + options?: Partial, + flat = false, + glue = '/', + ): TreeModel { + const pathIndex = file.path.lastIndexOf(glue); + const fileName = pathIndex !== -1 ? file.path.substring(pathIndex + 1) : file.path; + const filePath = flat && pathIndex !== -1 ? file.path.substring(0, pathIndex) : ''; + + return { + branch: false, + expanded: true, + path: file.path, + level: 1, + checkable: false, + checked: false, + icon: { type: 'status', name: file.status }, // 'file', + label: fileName, + description: flat === true ? filePath : undefined, + context: [file], + actions: this.getFileActions(file, options), + // decorations: [{ type: 'text', label: file.status }], + ...options, + }; + } + + protected folderToTreeModel(name: string, options?: Partial): TreeModel { + return { + branch: false, + expanded: true, + path: name, + level: 1, + checkable: false, + checked: false, + icon: 'folder', + label: name, + ...options, + }; + } + + protected renderTreeFileModel(treeModel: TreeModel[]) { + return html``; + } + + // Tree Model action events + // protected onTreeItemActionClicked?(_e: CustomEvent): void; + protected onTreeItemActionClicked(e: CustomEvent) { + if (!e.detail.context || !e.detail.action) return; + + const action = e.detail.action; + switch (action.action) { + // stage actions + case 'staged-create-patch': + this.onCreatePatch(e); + break; + case 'unstaged-create-patch': + this.onCreatePatch(e, true); + break; + // file actions + case 'file-open': + this.onOpenFile(e); + break; + case 'file-unstage': + this.onUnstageFile(e); + break; + case 'file-stage': + this.onStageFile(e); + break; + case 'file-compare-working': + this.onCompareWorking(e); + break; + case 'file-open-on-remote': + this.onOpenFileOnRemote(e); + break; + case 'file-more-actions': + this.onMoreActions(e); + break; + } + } + + protected onTreeItemChecked?(_e: CustomEvent): void; + + // protected onTreeItemSelected?(_e: CustomEvent): void; + protected onTreeItemSelected(e: CustomEvent) { + if (!e.detail.context) return; + + this.onComparePrevious(e); + } + onCreatePatch(_e: CustomEvent, isAll = false) { + const event = new CustomEvent('create-patch', { + detail: { + checked: isAll ? true : 'staged', + }, + }); + this.dispatchEvent(event); + } + onOpenFile(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-open', { + detail: this.getEventDetail(file, { + preview: !e.detail.dblClick, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + onOpenFileOnRemote(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-open-on-remote', { + detail: this.getEventDetail(file, { + preview: !e.detail.dblClick, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + onCompareWorking(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-compare-working', { + detail: this.getEventDetail(file, { + preview: !e.detail.dblClick, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + onComparePrevious(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-compare-previous', { + detail: this.getEventDetail(file, { + preview: !e.detail.dblClick, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + onMoreActions(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-more-actions', { + detail: this.getEventDetail(file), + }); + this.dispatchEvent(event); + } + + onStageFile(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-stage', { + detail: this.getEventDetail(file, { + preview: !e.detail.dblClick, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + onUnstageFile(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + const event = new CustomEvent('file-unstage', { + detail: this.getEventDetail(file, { + preview: !e.detail.dblClick, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }), + }); + this.dispatchEvent(event); + } + + private getEventDetail(file: File, showOptions?: TextDocumentShowOptions): FileChangeListItemDetail { + return { + path: file.path, + repoPath: file.repoPath, + status: file.status, + // originalPath: this.originalPath, + staged: file.staged, + showOptions: showOptions, + }; + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-inspect-nav.ts b/src/webviews/apps/commitDetails/components/gl-inspect-nav.ts new file mode 100644 index 0000000000000..e0023c30bb9c3 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-inspect-nav.ts @@ -0,0 +1,230 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { State } from '../../../commitDetails/protocol'; +import { commitActionStyles } from './commit-action.css'; + +@customElement('gl-inspect-nav') +export class GlInspectNav extends LitElement { + static override styles = [ + commitActionStyles, + css` + *, + *::before, + *::after { + box-sizing: border-box; + } + + :host { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.2rem; + } + + :host([pinned]) { + background-color: var(--color-alert-warningBackground); + box-shadow: 0 0 0 0.1rem var(--color-alert-warningBorder); + border-radius: 0.3rem; + } + + :host([pinned]) .commit-action:hover, + :host([pinned]) .commit-action.is-active { + background-color: var(--color-alert-warningHoverBackground); + } + + .group { + display: flex; + flex: none; + flex-direction: row; + max-width: 100%; + } + + .sha { + margin: 0 0.5rem 0 0.25rem; + } + `, + ]; + + @property({ type: Boolean, reflect: true }) + pinned = false; + + @property({ type: Boolean }) + uncommitted = false; + + @property({ type: Object }) + navigation?: State['navigationStack']; + + @property() + shortSha = ''; + + @property() + stashNumber?: string; + + get navigationState() { + if (this.navigation == null) { + return { + back: false, + forward: false, + }; + } + + const actions = { + back: true, + forward: true, + }; + + if (this.navigation.count <= 1) { + actions.back = false; + actions.forward = false; + } else if (this.navigation.position === 0) { + actions.back = true; + actions.forward = false; + } else if (this.navigation.position === this.navigation.count - 1) { + actions.back = false; + actions.forward = true; + } + + return actions; + } + + handleAction(e: Event) { + const targetEl = e.target as HTMLElement; + const action = targetEl.dataset.action; + if (action == null) return; + + if (action === 'commit-actions') { + const altKey = e instanceof MouseEvent ? e.altKey : false; + this.fireEvent('commit-actions', { action: targetEl.dataset.actionType, alt: altKey }); + } else { + this.fireEvent(action); + } + } + + fireEvent(type: string, detail?: Record) { + this.dispatchEvent(new CustomEvent(`gl-${type}`, { detail: detail })); + } + + override render() { + const pinLabel = this.pinned + ? html`Unpin this Commit
    Restores Automatic Following` + : html`Pin this Commit
    Suspends Automatic Following`; + + let forwardLabel = 'Forward'; + let backLabel = 'Back'; + if (this.navigation?.hint) { + if (!this.pinned) { + forwardLabel += ` - ${this.navigation.hint}`; + } else { + backLabel += ` - ${this.navigation.hint}`; + } + } + + return html` +
    + ${when( + !this.uncommitted, + () => html` + + + + ${this.stashNumber != null ? `#${this.stashNumber}` : this.shortSha} + + Copy ${this.stashNumber != null ? 'Stash Name' : 'SHA'}
    [âŒĨ] Copy Message
    +
    + `, + )} +
    +
    + ${pinLabel} + + ${when( + this.navigationState.forward, + () => html` + + `, + )} + + ${when( + this.uncommitted, + () => html` + + `, + )} + + ${when( + !this.uncommitted, + () => html` + + `, + )} +
    + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gl-inspect-nav': GlInspectNav; + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-inspect-patch.ts b/src/webviews/apps/commitDetails/components/gl-inspect-patch.ts new file mode 100644 index 0000000000000..64b7240588707 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-inspect-patch.ts @@ -0,0 +1,324 @@ +import { css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import type { DraftVisibility } from '../../../../gk/models/drafts'; +import type { Change, DraftUserSelection } from '../../../../plus/webviews/patchDetails/protocol'; +import type { Preferences, State } from '../../../commitDetails/protocol'; +import { GlElement } from '../../shared/components/element'; +import { buttonStyles } from './button.css'; +import '../../plus/patchDetails/components/gl-patch-create'; + +export interface CreatePatchState { + title?: string; + description?: string; + changes: Record; + creationError?: string; + visibility: DraftVisibility; + userSelections?: DraftUserSelection[]; +} + +export interface CreatePatchEventDetail { + title: string; + description?: string; + visibility: DraftVisibility; + changesets: Record; + userSelections: DraftUserSelection[] | undefined; +} + +export interface GenerateState { + cancelled?: boolean; + error?: { message: string }; + title?: string; + description?: string; +} + +@customElement('gl-inspect-patch') +export class InspectPatch extends GlElement { + static override styles = [ + buttonStyles, + css` + :host { + flex: 1; + } + + *, + *::before, + *::after { + box-sizing: border-box; + } + + a { + color: var(--vscode-textLink-foreground); + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + + gl-patch-create { + height: 100%; + display: block; + } + + .pane-groups { + display: flex; + flex-direction: column; + height: 100%; + } + .pane-groups__group { + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow: hidden; + } + .pane-groups__group webview-pane { + flex: none; + } + .pane-groups__group webview-pane[expanded] { + min-height: 0; + flex: 1; + } + + .pane-groups__group-fixed { + flex: none; + } + .pane-groups__group-fixed webview-pane::part(content) { + overflow: visible; + } + + .section { + padding: 0 var(--gitlens-scrollbar-gutter-width) 1.5rem var(--gitlens-gutter-width); + } + .section > :first-child { + margin-top: 0; + } + .section > :last-child { + margin-bottom: 0; + } + + .section--action { + border-top: 1px solid var(--vscode-sideBarSectionHeader-border); + padding-top: 1.5rem; + padding-bottom: 1.5rem; + } + .section--action > :first-child { + padding-top: 0; + } + + /* TODO: these form styles should be moved to a common location */ + .message-input { + padding-top: 0.8rem; + } + + .message-input__control { + flex: 1; + border: 1px solid var(--vscode-input-border); + background: var(--vscode-input-background); + padding: 0.5rem; + font-size: 1.3rem; + line-height: 1.4; + width: 100%; + border-radius: 0.2rem; + color: var(--vscode-input-foreground); + font-family: inherit; + } + + .message-input__control::placeholder { + color: var(--vscode-input-placeholderForeground); + } + + .message-input__control:invalid { + border-color: var(--vscode-inputValidation-errorBorder); + background-color: var(--vscode-inputValidation-errorBackground); + } + + .message-input__control:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .message-input__control:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; + } + + .message-input__control--text { + overflow: hidden; + white-space: nowrap; + opacity: 0.7; + } + + .message-input__action { + flex: none; + } + + .message-input__select { + flex: 1; + position: relative; + display: flex; + align-items: stretch; + } + .message-input__select-icon { + position: absolute; + left: 0; + top: 0; + display: flex; + width: 2.4rem; + height: 100%; + align-items: center; + justify-content: center; + pointer-events: none; + color: var(--vscode-foreground); + } + .message-input__select-caret { + position: absolute; + right: 0; + top: 0; + display: flex; + width: 2.4rem; + height: 100%; + align-items: center; + justify-content: center; + pointer-events: none; + color: var(--vscode-foreground); + } + + .message-input__select .message-input__control { + box-sizing: border-box; + appearance: none; + padding-left: 2.4rem; + padding-right: 2.4rem; + } + + .message-input__menu { + position: absolute; + top: 0.8rem; + right: 0; + } + + .section--action > :first-child .message-input__menu { + top: 0; + } + + .message-input--group { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0.6rem; + } + + .message-input--with-menu { + position: relative; + } + + textarea.message-input__control { + resize: vertical; + min-height: 4rem; + max-height: 40rem; + } + + .user-selection-container { + max-height: (2.4rem * 4); + overflow: auto; + } + + .user-selection { + --gk-avatar-size: 2rem; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.4rem; + height: 2.4rem; + } + .user-selection__avatar { + flex: none; + } + + .user-selection__info { + flex: 1; + min-width: 0; + white-space: nowrap; + } + + .user-selection__name { + overflow: hidden; + text-overflow: ellipsis; + } + + .user-selection__actions { + flex: none; + color: var(--gk-button-ghost-color); + } + .user-selection__actions gk-button::part(base) { + padding-right: 0; + padding-block: 0.4rem; + } + + .user-selection__actions gk-button code-icon { + pointer-events: none; + } + + .user-selection__check:not(.is-active) { + opacity: 0; + } + + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 0.8rem 1.2rem; + line-height: 1.2; + background-color: var(--color-alert-errorBackground); + border-left: 0.3rem solid var(--color-alert-errorBorder); + color: var(--color-alert-foreground); + } + + .alert code-icon { + margin-right: 0.4rem; + vertical-align: baseline; + } + + .alert__content { + font-size: 1.2rem; + line-height: 1.2; + text-align: left; + margin: 0; + } + `, + ]; + + @property({ type: Object }) + orgSettings?: State['orgSettings']; + + @property({ type: Object }) + preferences?: Preferences; + + @property({ type: Object }) + generate?: GenerateState; + + @property({ type: Object }) + createState?: CreatePatchState; + + get patchCreateState() { + return { + preferences: this.preferences, + orgSettings: this.orgSettings, + create: this.createState, + }; + } + + override render() { + return html` { + console.log('gl-patch-file-compare-working', e); + }} + @gl-patch-create-update-metadata=${(e: CustomEvent) => { + console.log('gl-patch-create-update-metadata', e); + }} + >`; + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-status-nav.ts b/src/webviews/apps/commitDetails/components/gl-status-nav.ts new file mode 100644 index 0000000000000..1c6d3f65b0bf7 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-status-nav.ts @@ -0,0 +1,166 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { State } from '../../../commitDetails/protocol'; +import { commitActionStyles } from './commit-action.css'; +import '../../shared/components/overlays/popover'; +import '../../shared/components/overlays/tooltip'; + +@customElement('gl-status-nav') +export class GlStatusNav extends LitElement { + static override styles = [ + commitActionStyles, + css` + *, + *::before, + *::after { + box-sizing: border-box; + } + + :host { + display: flex; + flex-direction: row; + /* flex-wrap: wrap; */ + align-items: center; + justify-content: space-between; + gap: 0.2rem; + } + + .tooltip--overflowed { + min-width: 0; + } + + .commit-action--overflowed { + width: 100%; + } + + .branch { + min-width: 0; + max-width: fit-content; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .group { + display: flex; + flex: none; + flex-direction: row; + min-width: 0; + max-width: 100%; + } + + .group:first-child { + min-width: 0; + flex: 0 1 auto; + } + + hr { + border: none; + border-top: 1px solid var(--color-foreground--25); + } + + .md-code { + background: var(--vscode-textCodeBlock-background); + border-radius: 3px; + padding: 0px 4px 2px 4px; + font-family: var(--vscode-editor-font-family); + } + `, + ]; + + @property({ type: Object }) + wip?: State['wip']; + + @property({ type: Object }) + preferences?: State['preferences']; + + override render() { + if (this.wip == null) return nothing; + + const changes = this.wip.changes; + const branch = this.wip.branch; + if (changes == null || branch == null) return nothing; + + let prIcon = 'git-pull-request'; + if (this.wip.pullRequest?.state) { + switch (this.wip.pullRequest?.state) { + case 'merged': + prIcon = 'git-merge'; + break; + case 'closed': + prIcon = 'git-pull-request-closed'; + break; + } + } + + return html` +
    + ${when( + this.wip.pullRequest != null, + () => + html` + #${this.wip!.pullRequest!.id} +
    + +
    +
    `, + )} + + this.handleAction(e, 'switch')} + > + ${when( + this.wip.pullRequest == null, + () => html``, + )}${branch.name} +
    + Switch to Another Branch... +
    + ${this.wip.branch?.name} +
    +
    +
    + + `; + } + + handleAction(e: MouseEvent, action: string) { + const altKey = e instanceof MouseEvent ? e.altKey : false; + this.dispatchEvent( + new CustomEvent(`gl-branch-action`, { + detail: { + action: action, + alt: altKey, + }, + }), + ); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gl-status-nav': GlStatusNav; + } +} diff --git a/src/webviews/apps/commitDetails/components/gl-wip-details.ts b/src/webviews/apps/commitDetails/components/gl-wip-details.ts new file mode 100644 index 0000000000000..7fc0d90a15707 --- /dev/null +++ b/src/webviews/apps/commitDetails/components/gl-wip-details.ts @@ -0,0 +1,392 @@ +import { Avatar, defineGkElement } from '@gitkraken/shared-web-components'; +import type { PropertyValueMap } from 'lit'; +import { css, html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { when } from 'lit/directives/when.js'; +import type { DraftState, State, Wip } from '../../../commitDetails/protocol'; +import type { TreeItemAction, TreeItemBase } from '../../shared/components/tree/base'; +import type { File } from './gl-details-base'; +import { GlDetailsBase } from './gl-details-base'; +import type { GenerateState } from './gl-inspect-patch'; +import '../../shared/components/button'; +import '../../shared/components/code-icon'; +import '../../shared/components/panes/pane-group'; +import '../../shared/components/pills/tracking'; +import './gl-inspect-patch'; + +@customElement('gl-wip-details') +export class GlWipDetails extends GlDetailsBase { + static override styles = [ + css` + :host { + --gk-avatar-size: 1.6rem; + } + `, + ]; + override readonly tab = 'wip'; + + @property({ type: Object }) + wip?: Wip; + + @property({ type: Object }) + orgSettings?: State['orgSettings']; + + @property({ type: Object }) + draftState?: DraftState; + + @property({ type: Object }) + generate?: GenerateState; + + @state() + get inReview() { + return this.draftState?.inReview ?? false; + } + + get isUnpublished() { + const branch = this.wip?.branch; + return branch?.upstream == null || branch.upstream.missing === true; + } + + get draftsEnabled() { + return this.orgSettings?.drafts === true; + } + + get filesCount() { + return this.files?.length ?? 0; + } + + get branchState() { + const branch = this.wip?.branch; + if (branch == null) return undefined; + + return { + ahead: branch.tracking?.ahead ?? 0, + behind: branch.tracking?.behind ?? 0, + }; + } + + @state() + patchCreateMetadata: { title: string | undefined; description: string | undefined } = { + title: undefined, + description: undefined, + }; + + get patchCreateState() { + const wip = this.wip!; + const key = wip.repo.uri; + const change = { + type: 'wip', + repository: { + name: wip.repo.name, + path: wip.repo.path, + uri: wip.repo.uri, + }, + files: wip.changes?.files ?? [], + checked: true, + }; + + return { + ...this.patchCreateMetadata, + changes: { + [key]: change, + }, + creationError: undefined, + visibility: 'public', + userSelections: undefined, + }; + } + + get codeSuggestions() { + return this.wip?.codeSuggestions ?? []; + } + + constructor() { + super(); + + defineGkElement(Avatar); + } + + protected override updated(changedProperties: PropertyValueMap | Map): void { + super.updated(changedProperties); + + if (changedProperties.has('generate')) { + this.patchCreateMetadata = { + title: this.generate?.title ?? this.patchCreateMetadata.title, + description: this.generate?.description ?? this.patchCreateMetadata.description, + }; + } + } + + override get filesChangedPaneLabel() { + return 'Working Changes'; + } + + renderSecondaryAction() { + if (!this.draftsEnabled || this.inReview) return undefined; + + let label = 'Share as Cloud Patch'; + let action = 'create-patch'; + const pr = this.wip?.pullRequest; + if (pr != null && pr.state === 'opened') { + // const isMe = pr.author.name.endsWith('(you)'); + // if (isMe) { + // label = 'Share with PR Participants'; + // action = 'create-patch'; + // } else { + // label = `Start Review for PR #${pr.id}`; + // action = 'create-patch'; + // } + + if (!this.inReview) { + label = 'Suggest Changes for PR'; + action = 'start-patch-review'; + } else { + label = 'Close Suggestion for PR'; + action = 'end-patch-review'; + } + + return html`

    + + this.onToggleReviewMode(!this.inReview)} + > + ${label} + + this.onDataActionClick('create-patch')} + > + + + +

    `; + } + + return html`

    + + this.onDataActionClick(action)} + > + ${label} + + +

    `; + } + + renderPrimaryAction() { + const canShare = this.draftsEnabled; + if (this.isUnpublished && canShare) { + return html`

    + + this.onDataActionClick('publish-branch')} + > + Publish Branch + + +

    `; + } + + if ((!this.isUnpublished && !canShare) || this.branchState == null) return undefined; + + const { ahead, behind } = this.branchState; + if (ahead === 0 && behind === 0) return undefined; + + const fetchLabel = behind > 0 ? 'Pull' : ahead > 0 ? 'Push' : 'Fetch'; + const fetchIcon = behind > 0 ? 'gl-repo-pull' : ahead > 0 ? 'gl-repo-push' : 'gl-repo-fetch'; + + return html`

    + + this.onDataActionClick(fetchLabel.toLowerCase())} + > + ${fetchLabel} + + + +

    `; + } + + renderActions() { + const primaryAction = this.renderPrimaryAction(); + const secondaryAction = this.renderSecondaryAction(); + if (primaryAction == null && secondaryAction == null) return nothing; + + return html`
    ${primaryAction}${secondaryAction}
    `; + } + + renderSuggestedChanges() { + if (this.codeSuggestions.length === 0) return nothing; + // src="${this.issue!.author.avatarUrl}" + // title="${this.issue!.author.name} (author)" + return html` + + + + Code Suggestions + + ${repeat( + this.codeSuggestions, + draft => draft.id, + draft => html` + this.onShowCodeSuggestion(draft.id)} + > + + ${draft.title} + + + `, + )} + + `; + } + + renderPullRequest() { + if (this.wip?.pullRequest == null) return nothing; + + return html` + + Pull Request #${this.wip?.pullRequest?.id} + + this.onDataActionClick('open-pr-changes')} + > + this.onDataActionClick('open-pr-compare')} + > + this.onDataActionClick('open-pr-remote')} + > + +
    + +
    + ${this.renderSuggestedChanges()} +
    + `; + } + + renderIncomingOutgoing() { + if (this.branchState == null || (this.branchState.ahead === 0 && this.branchState.behind === 0)) return nothing; + + return html` + + Incoming / Outgoing + + + + Incoming Changes + ${this.branchState.behind ?? 0} + + + + Outgoing Changes + ${this.branchState.ahead ?? 0} + + + + `; + } + + renderPatchCreation() { + if (!this.inReview) return nothing; + + return html` { + // this.onDataActionClick('create-patch'); + console.log('gl-patch-create-patch', e); + void this.dispatchEvent(new CustomEvent('gl-inspect-create-suggestions', { detail: e.detail })); + }} + >`; + } + + override render() { + if (this.wip == null) return nothing; + + return html` + ${this.renderActions()} + + ${this.renderPullRequest()} + ${when(this.inReview === false, () => this.renderChangedFiles('wip'))}${this.renderPatchCreation()} + + `; + } + + override getFileActions(file: File, _options?: Partial): TreeItemAction[] { + const openFile = { + icon: 'go-to-file', + label: 'Open file', + action: 'file-open', + }; + if (file.staged === true) { + return [openFile, { icon: 'remove', label: 'Unstage changes', action: 'file-unstage' }]; + } + return [openFile, { icon: 'plus', label: 'Stage changes', action: 'file-stage' }]; + } + + onDataActionClick(name: string) { + void this.dispatchEvent(new CustomEvent('data-action', { detail: { name: name } })); + } + + onToggleReviewMode(inReview: boolean) { + this.dispatchEvent(new CustomEvent('draft-state-changed', { detail: { inReview: inReview } })); + } + + onShowCodeSuggestion(id: string) { + this.dispatchEvent(new CustomEvent('gl-show-code-suggestion', { detail: { id: id } })); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gl-wip-details': GlWipDetails; + } +} diff --git a/src/webviews/apps/home/components/card-section.ts b/src/webviews/apps/home/components/card-section.ts deleted file mode 100644 index 5206ca47ac49c..0000000000000 --- a/src/webviews/apps/home/components/card-section.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { attr, css, customElement, FASTElement, html, when } from '@microsoft/fast-element'; -import { numberConverter } from '../../shared/components/converters/number-converter'; -import '../../shared/components/code-icon'; - -const template = html``; - -const styles = css` - * { - box-sizing: border-box; - } - - :host { - display: block; - padding: 1.2rem; - background-color: var(--card-background); - margin-bottom: 1rem; - border-radius: 0.4rem; - background-repeat: no-repeat; - background-size: cover; - transition: aspect-ratio linear 100ms, background-color linear 100ms; - } - - :host(:hover) { - background-color: var(--card-hover-background); - } - - header { - display: flex; - flex-direction: row; - justify-content: space-between; - gap: 0.4rem; - margin-bottom: 1rem; - } - - .dismiss { - width: 2rem; - height: 2rem; - padding: 0; - font-size: var(--vscode-editor-font-size); - line-height: 2rem; - font-family: inherit; - border: none; - color: inherit; - background: none; - text-align: left; - cursor: pointer; - opacity: 0.5; - flex: none; - text-align: center; - } - - .dismiss:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 0.2rem; - } - - .heading { - text-transform: uppercase; - } - - .description { - margin-left: 0.2rem; - text-transform: none; - /* color needs to come from some sort property */ - color: #b68cd8; - } -`; - -@customElement({ name: 'card-section', template: template, styles: styles }) -export class CardSection extends FASTElement { - @attr({ attribute: 'no-heading', mode: 'boolean' }) - noHeading = false; - - @attr({ attribute: 'heading-level', converter: numberConverter }) - headingLevel = 2; - - @attr({ mode: 'boolean' }) - dismissable = false; - - @attr({ mode: 'boolean' }) - expanded = true; - - handleDismiss(_e: Event) { - this.$emit('dismiss'); - } -} diff --git a/src/webviews/apps/home/components/feature-nav.ts b/src/webviews/apps/home/components/feature-nav.ts new file mode 100644 index 0000000000000..377e7aa4c0b83 --- /dev/null +++ b/src/webviews/apps/home/components/feature-nav.ts @@ -0,0 +1,412 @@ +import { consume } from '@lit/context'; +import { css, html } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { State } from '../../../home/protocol'; +import { GlElement } from '../../shared/components/element'; +import { linkBase } from '../../shared/components/styles/lit/base.css'; +import { stateContext } from '../context'; +import { homeBaseStyles, navListStyles } from '../home.css'; + +@customElement('gl-feature-nav') +export class GlFeatureNav extends GlElement { + static override styles = [linkBase, homeBaseStyles, navListStyles, css``]; + + @property({ type: Object }) + private badgeSource = { source: 'home', detail: 'badge' }; + + @consume({ context: stateContext, subscribe: true }) + @state() + private _state!: State; + + get orgAllowsDrafts() { + return this._state.orgSettings.drafts; + } + + private get blockRepoFeatures() { + if (!this._state) return true; + + const { + repositories: { openCount, hasUnsafe, trusted }, + } = this._state; + return !trusted || openCount === 0 || hasUnsafe; + } + + private onRepoFeatureClicked(e: MouseEvent) { + if (this.blockRepoFeatures) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + + return true; + } + + override render() { + return html` + ${when( + this.blockRepoFeatures, + () => html` +

    + Features which need a repository are currently + unavailable +

    + `, + )} + + + + + + `; + } +} diff --git a/src/webviews/apps/home/components/header-card.ts b/src/webviews/apps/home/components/header-card.ts deleted file mode 100644 index 45d248f292a84..0000000000000 --- a/src/webviews/apps/home/components/header-card.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { attr, css, customElement, FASTElement, html, ref, volatile, when } from '@microsoft/fast-element'; -import { SubscriptionState } from '../../../../subscription'; -import { pluralize } from '../../../../system/string'; -import { numberConverter } from '../../shared/components/converters/number-converter'; -import '../../shared/components/code-icon'; -import '../../shared/components/overlays/pop-over'; - -const template = html` -
    GitLens Logo
    -

    - ${when(x => x.name === '', html`GitLens 13`)} - ${when(x => x.name !== '', html`${x => x.name}`)} -

    - -
    -
    -
    - - ${when( - x => x.state === SubscriptionState.FreePreviewTrialExpired, - html`Extend Pro Trial`, - )} - ${when( - x => - x.state === SubscriptionState.FreeInPreviewTrial || - x.state === SubscriptionState.FreePlusInTrial || - x.state === SubscriptionState.FreePlusTrialExpired, - html`Upgrade to Pro`, - )} - ${when( - x => x.state === SubscriptionState.VerificationRequired, - html` - Verify Refresh - `, - )} - -`; - -const styles = css` - * { - box-sizing: border-box; - } - - :host { - position: relative; - display: grid; - /* - padding: 1rem 1rem 1.2rem; - background-color: var(--card-background); - border-radius: 0.4rem; - */ - padding: 1rem 0 1.2rem; - gap: 0 0.8rem; - grid-template-columns: 3.4rem auto; - grid-auto-flow: column; - } - - a { - color: var(--vscode-textLink-foreground); - text-decoration: none; - } - a:focus { - outline-color: var(--focus-border); - } - a:hover { - text-decoration: underline; - } - - .header-card__media { - grid-column: 1; - grid-row: 1 / span 2; - display: flex; - align-items: center; - } - - .header-card__image { - width: 100%; - aspect-ratio: 1 / 1; - border-radius: 50%; - } - - .header-card__title { - font-size: var(--vscode-font-size); - font-weight: 600; - margin: 0; - } - - .header-card__title.logo { - font-family: 'Segoe UI Semibold', var(--font-family); - font-size: 1.5rem; - } - - .header-card__account { - position: relative; - margin: 0; - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 0 0.4rem; - } - - .progress { - width: 100%; - overflow: hidden; - } - - :host-context(.vscode-high-contrast) .progress, - :host-context(.vscode-dark) .progress { - background-color: var(--color-background--lighten-15); - } - - :host-context(.vscode-high-contrast-light) .progress, - :host-context(.vscode-light) .progress { - background-color: var(--color-background--darken-15); - } - - .progress__indicator { - height: 4px; - background-color: var(--vscode-progressBar-background); - } - - .header-card__progress { - position: absolute; - bottom: 0; - left: 0; - /* - border-bottom-left-radius: 0.4rem; - border-bottom-right-radius: 0.4rem; - */ - } - - .brand { - color: var(--gitlens-brand-color-2); - } - .status { - color: var(--color-foreground--65); - } - - .status-label { - cursor: help; - } - - .status pop-over { - top: 1.6em; - left: 0; - } - .status-label:not(:hover) + pop-over:not(.is-pinned) { - display: none; - } - - .repo-access { - font-size: 1.1em; - margin-right: 0.2rem; - } - .repo-access:not(.is-pro) { - filter: grayscale(1) brightness(0.7); - } - - .actions { - position: absolute; - right: 0.1rem; - top: 0.1rem; - } - - .action { - display: inline-block; - padding: 0.2rem 0.6rem; - border-radius: 0.3rem; - color: var(--color-foreground--75); - } - :host-context(.vscode-high-contrast) .action.is-primary, - :host-context(.vscode-dark) .action.is-primary { - border: 1px solid var(--color-background--lighten-15); - } - - :host-context(.vscode-high-contrast-light) .action.is-primary, - :host-context(.vscode-light) .action.is-primary { - border: 1px solid var(--color-background--darken-15); - } - - .action.is-icon { - display: inline-flex; - justify-content: center; - align-items: center; - width: 2.2rem; - height: 2.2rem; - padding: 0; - } - .action:hover { - text-decoration: none; - color: var(--color-foreground); - } - - :host-context(.vscode-high-contrast) .action:hover, - :host-context(.vscode-dark) .action:hover { - background-color: var(--color-background--lighten-10); - } - - :host-context(.vscode-high-contrast-light) .action:hover, - :host-context(.vscode-light) .action:hover { - background-color: var(--color-background--darken-10); - } - - pop-over .action { - margin-right: -0.2rem; - } - - .link-inline { - color: inherit; - text-decoration: underline; - } - .link-inline:hover { - color: var(--vscode-textLink-foreground); - } -`; - -@customElement({ name: 'header-card', template: template, styles: styles }) -export class HeaderCard extends FASTElement { - @attr - image = ''; - - @attr - name = ''; - - @attr({ converter: numberConverter }) - days = 0; - - @attr({ converter: numberConverter }) - steps = 4; - - @attr({ converter: numberConverter }) - completed = 0; - - @attr({ converter: numberConverter }) - state: SubscriptionState = SubscriptionState.Free; - - @attr - plan = ''; - - @attr({ attribute: 'pin-status', mode: 'boolean' }) - pinStatus = true; - - progressNode!: HTMLElement; - statusNode!: HTMLElement; - - override attributeChangedCallback(name: string, oldValue: string, newValue: string): void { - super.attributeChangedCallback(name, oldValue, newValue); - - if (oldValue === newValue || this.progressNode == null) { - return; - } - this.updateProgressWidth(); - } - - get daysRemaining() { - if (this.days < 1) { - return '<1 day'; - } - return pluralize('day', this.days); - } - - get progressNow() { - return this.completed + 1; - } - - get progressMax() { - return this.steps + 1; - } - - @volatile - get progress() { - return `${(this.progressNow / this.progressMax) * 100}%`; - } - - @volatile - get planName() { - switch (this.state) { - case SubscriptionState.Free: - case SubscriptionState.FreePreviewTrialExpired: - case SubscriptionState.FreePlusTrialExpired: - return 'GitLens Free'; - case SubscriptionState.FreeInPreviewTrial: - case SubscriptionState.FreePlusInTrial: - return 'GitLens Pro (Trial)'; - case SubscriptionState.VerificationRequired: - return `${this.plan} (Unverified)`; - default: - return this.plan; - } - } - - @volatile - get daysLeft() { - switch (this.state) { - case SubscriptionState.FreeInPreviewTrial: - case SubscriptionState.FreePlusInTrial: - return `, ${this.daysRemaining} left`; - default: - return ''; - } - } - - get hasAccount() { - switch (this.state) { - case SubscriptionState.Free: - case SubscriptionState.FreePreviewTrialExpired: - case SubscriptionState.FreeInPreviewTrial: - return false; - } - return true; - } - - get isPro() { - switch (this.state) { - case SubscriptionState.Free: - case SubscriptionState.FreePreviewTrialExpired: - case SubscriptionState.FreePlusTrialExpired: - case SubscriptionState.VerificationRequired: - return false; - } - return true; - } - - updateProgressWidth() { - this.progressNode.style.width = this.progress; - } - - dismissStatus(_e: MouseEvent) { - this.pinStatus = false; - this.$emit('dismiss-status'); - - window.requestAnimationFrame(() => { - this.statusNode?.focus(); - }); - } -} diff --git a/src/webviews/apps/home/components/home-nav.ts b/src/webviews/apps/home/components/home-nav.ts new file mode 100644 index 0000000000000..e56eb346863a2 --- /dev/null +++ b/src/webviews/apps/home/components/home-nav.ts @@ -0,0 +1,106 @@ +import { consume } from '@lit/context'; +import { css, html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { getApplicablePromo } from '../../../../plus/gk/account/promos'; +import type { State } from '../../../home/protocol'; +import { linkBase } from '../../shared/components/styles/lit/base.css'; +import { stateContext } from '../context'; +import { homeBaseStyles, inlineNavStyles } from '../home.css'; +import '../../shared/components/code-icon'; +import '../../shared/components/overlays/tooltip'; +import '../../shared/components/promo'; + +@customElement('gl-home-nav') +export class GlHomeNav extends LitElement { + static override styles = [ + linkBase, + homeBaseStyles, + inlineNavStyles, + css` + :host { + display: block; + } + `, + ]; + + @consume({ context: stateContext, subscribe: true }) + @state() + private _state!: State; + + override render() { + return html` + + + `; + } +} diff --git a/src/webviews/apps/home/components/onboarding.ts b/src/webviews/apps/home/components/onboarding.ts new file mode 100644 index 0000000000000..a435d12f36fe1 --- /dev/null +++ b/src/webviews/apps/home/components/onboarding.ts @@ -0,0 +1,105 @@ +import { consume } from '@lit/context'; +import { html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import type { State } from '../../../home/protocol'; +import { CollapseSectionCommand } from '../../../home/protocol'; +import { ipcContext } from '../../shared/context'; +import type { HostIpc } from '../../shared/ipc'; +import { stateContext } from '../context'; +import { alertStyles, buttonStyles, homeBaseStyles } from '../home.css'; +import '../../shared/components/button'; +import '../../shared/components/code-icon'; +import '../../shared/components/overlays/tooltip'; + +@customElement('gl-onboarding') +export class GlOnboarding extends LitElement { + static override styles = [alertStyles, homeBaseStyles, buttonStyles]; + + @consume({ context: stateContext, subscribe: true }) + @state() + private _state!: State; + + @consume({ context: ipcContext, subscribe: true }) + @state() + private _ipc!: HostIpc; + + private onSectionExpandClicked(e: MouseEvent, isToggle = false) { + if (isToggle) { + e.stopImmediatePropagation(); + } + const target = (e.target as HTMLElement).closest('[data-section-expand]') as HTMLElement; + const section = target?.dataset.sectionExpand; + if (section !== 'walkthrough') { + return; + } + + if (isToggle) { + this.updateCollapsedSections(!this._state.walkthroughCollapsed); + return; + } + + this.updateCollapsedSections(false); + } + + private updateCollapsedSections(toggle = this._state.walkthroughCollapsed) { + this._state.walkthroughCollapsed = toggle; + this.requestUpdate(); + this._ipc.sendCommand(CollapseSectionCommand, { + section: 'walkthrough', + collapsed: toggle, + }); + } + + override render() { + return html` +
    this.onSectionExpandClicked(e)} + > +

    Get Started with GitLens

    +
    +

    Explore all of the powerful features in GitLens

    +

    + Start Here (Welcome) + + Walkthrough + Tutorial + +

    +
    + this.onSectionExpandClicked(e, true)} + > + + + Collapse + + + + Expand + + +
    + `; + } +} diff --git a/src/webviews/apps/home/components/plus-banner.ts b/src/webviews/apps/home/components/plus-banner.ts deleted file mode 100644 index ce1cfb24e4e50..0000000000000 --- a/src/webviews/apps/home/components/plus-banner.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { attr, css, customElement, FASTElement, html, volatile, when } from '@microsoft/fast-element'; -import { SubscriptionState } from '../../../../subscription'; -import { pluralize } from '../../../../system/string'; -import { numberConverter } from '../../shared/components/converters/number-converter'; -import '../../shared/components/code-icon'; - -const template = html` - ${when( - x => x.state === SubscriptionState.Free, - html` -

    - Powerful, additional features - that enhance your GitLens experience. -

    - -

    - Try GitLens+ features on private repos -

    - `, - )} - ${when( - x => x.state === SubscriptionState.Paid, - html` -

    Welcome to ${x => x.planName}!

    -

    - You have access to - GitLens+ features - on any repo. -

    - `, - )} - ${when( - x => x.state === SubscriptionState.FreeInPreviewTrial, - html` -

    GitLens Pro Trial

    -

    - You have ${x => x.daysRemaining} left in your 3-day GitLens Pro trial. Don't worry if you need more - time, you can extend your trial for an additional free 7-days of - GitLens+ features on - private repos. -

    -

    - Upgrade to Pro -

    - `, - )} - ${when( - x => x.state === SubscriptionState.FreePlusInTrial, - html` -

    GitLens Pro Trial

    -

    - You have ${x => x.daysRemaining} left in your GitLens Pro trial. Once your trial ends, you'll continue - to have access to - GitLens+ features on - local and public repos, while upgrading to GitLens Pro gives you access on private repos. -

    - `, - )} - ${when( - x => x.state === SubscriptionState.FreePreviewTrialExpired, - html` -

    Extend Your GitLens Pro Trial

    -

    - Your free 3-day GitLens Pro trial has ended, extend your trial to get an additional free 7-days of - GitLens+ features on private repos. -

    -

    - Extend Pro Trial -

    - `, - )} - ${when( - x => x.state === SubscriptionState.FreePlusTrialExpired, - html` -

    GitLens Pro Trial Expired

    -

    - Your GitLens Pro trial has ended, please upgrade to GitLens Pro to continue to use GitLens+ features on - private repos. -

    -

    - Upgrade to Pro -

    - `, - )} - ${when( - x => x.state === SubscriptionState.VerificationRequired, - html` -

    Please verify your email

    -

    - Before you can also use GitLens+ features on private repos, please verify your email address. -

    -

    - Resend Verification Email -

    -

    - Refresh Verification Status -

    - `, - )} - ${when( - x => - [ - SubscriptionState.Free, - SubscriptionState.FreePreviewTrialExpired, - SubscriptionState.FreePlusTrialExpired, - ].includes(x.state), - html` -

    - ${when( - x => x.plus, - html`Hide GitLens+ features`, - )} - ${when( - x => !x.plus, - html`Restore GitLens+ features`, - )} -

    - `, - )} -`; - -const styles = css` - * { - box-sizing: border-box; - } - - :host { - display: block; - text-align: center; - } - - a { - color: var(--vscode-textLink-foreground); - text-decoration: none; - } - a:focus { - outline-color: var(--focus-border); - } - a:hover { - text-decoration: underline; - } - - h3, - p { - margin-top: 0; - } - - h3 a { - color: inherit; - text-decoration: underline; - text-decoration-color: var(--color-foreground--50); - } - - h3 a:hover { - text-decoration-color: inherit; - } - - .mb-1 { - margin-bottom: 0.4rem; - } - .mb-0 { - margin-bottom: 0; - } - - .minimal { - color: var(--color-foreground--50); - font-size: 1rem; - position: relative; - top: -0.2rem; - } -`; - -@customElement({ name: 'plus-banner', template: template, styles: styles }) -export class PlusBanner extends FASTElement { - @attr({ converter: numberConverter }) - days = 0; - - @attr({ converter: numberConverter }) - state: SubscriptionState = SubscriptionState.Free; - - @attr - plan = ''; - - @attr - visibility: 'local' | 'public' | 'mixed' | 'private' = 'public'; - - @attr({ mode: 'boolean' }) - plus = true; - - get daysRemaining() { - if (this.days < 1) { - return 'less than one day'; - } - return pluralize('day', this.days); - } - - get isFree() { - return ['local', 'public'].includes(this.visibility); - } - - @volatile - get planName() { - switch (this.state) { - case SubscriptionState.Free: - case SubscriptionState.FreePreviewTrialExpired: - case SubscriptionState.FreePlusTrialExpired: - return 'GitLens Free'; - case SubscriptionState.FreeInPreviewTrial: - case SubscriptionState.FreePlusInTrial: - return 'GitLens Pro (Trial)'; - case SubscriptionState.VerificationRequired: - return `${this.plan} (Unverified)`; - default: - return this.plan; - } - } - - fireAction(command: string) { - this.$emit('action', command); - } -} diff --git a/src/webviews/apps/home/components/plus-content.ts b/src/webviews/apps/home/components/plus-content.ts deleted file mode 100644 index 811d1a32981aa..0000000000000 --- a/src/webviews/apps/home/components/plus-content.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { attr, css, customElement, FASTElement, html, volatile, when } from '@microsoft/fast-element'; -import { SubscriptionState } from '../../../../subscription'; -import { pluralize } from '../../../../system/string'; -import { numberConverter } from '../../shared/components/converters/number-converter'; -import '../../shared/components/code-icon'; - -const template = html` -
    -
    - ${when( - x => x.state === SubscriptionState.Free, - html` -

    - GitLens+ features - are free for local and public repos, no account required, while upgrading to GitLens Pro gives you - access on private repos. -

    -

    All other GitLens features can always be used on any repo.

    - `, - )} - ${when( - x => x.state !== SubscriptionState.Free, - html`

    All other GitLens features can always be used on any repo

    `, - )} -
    -`; - -const styles = css` - * { - box-sizing: border-box; - } - - :host { - display: flex; - flex-direction: row; - padding: 0.8rem 1.2rem; - background-color: var(--color-alert-neutralBackground); - border-left: 0.3rem solid var(--color-foreground--50); - color: var(--color-alert-foreground); - } - - a { - color: var(--vscode-textLink-foreground); - text-decoration: none; - } - a:focus { - outline-color: var(--focus-border); - } - a:hover { - text-decoration: underline; - } - - p { - margin-top: 0; - } - - .icon { - display: none; - flex: none; - margin-right: 0.4rem; - } - - .icon code-icon { - font-size: 2.4rem; - margin-top: 0.2rem; - } - - .content { - font-size: 1.2rem; - line-height: 1.2; - text-align: left; - } - - .mb-1 { - margin-bottom: 0.8rem; - } - .mb-0 { - margin-bottom: 0; - } -`; - -@customElement({ name: 'plus-content', template: template, styles: styles }) -export class PlusContent extends FASTElement { - @attr({ converter: numberConverter }) - days = 0; - - @attr({ converter: numberConverter }) - state: SubscriptionState = SubscriptionState.Free; - - @attr - plan = ''; - - @attr - visibility: 'local' | 'public' | 'mixed' | 'private' = 'public'; - - get daysRemaining() { - if (this.days < 1) { - return 'less than one day'; - } - return pluralize('day', this.days); - } - - get isFree() { - return ['local', 'public'].includes(this.visibility); - } - - @volatile - get planName() { - switch (this.state) { - case SubscriptionState.Free: - case SubscriptionState.FreePreviewTrialExpired: - case SubscriptionState.FreePlusTrialExpired: - return 'GitLens Free'; - case SubscriptionState.FreeInPreviewTrial: - case SubscriptionState.FreePlusInTrial: - return 'GitLens Pro (Trial)'; - case SubscriptionState.VerificationRequired: - return `${this.plan} (Unverified)`; - default: - return this.plan; - } - } - - fireAction(command: string) { - this.$emit('action', command); - } -} diff --git a/src/webviews/apps/home/components/repo-alerts.ts b/src/webviews/apps/home/components/repo-alerts.ts new file mode 100644 index 0000000000000..22f8e921a2143 --- /dev/null +++ b/src/webviews/apps/home/components/repo-alerts.ts @@ -0,0 +1,138 @@ +import { consume } from '@lit/context'; +import { css, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { State } from '../../../home/protocol'; +import { GlElement } from '../../shared/components/element'; +import { linkBase } from '../../shared/components/styles/lit/base.css'; +import { stateContext } from '../context'; +import { alertStyles, homeBaseStyles } from '../home.css'; +import '../../shared/components/button'; + +@customElement('gl-repo-alerts') +export class GlRepoAlerts extends GlElement { + static override styles = [ + linkBase, + homeBaseStyles, + alertStyles, + css` + .alert { + margin-bottom: 0; + } + + .centered { + text-align: center; + } + + .one-line { + white-space: nowrap; + } + + gl-button.is-basic { + max-width: 300px; + width: 100%; + } + gl-button.is-basic + gl-button.is-basic { + margin-top: 1rem; + } + `, + ]; + + @consume({ context: stateContext, subscribe: true }) + @state() + private _state!: State; + + get alertVisibility() { + const sections = { + header: false, + untrusted: false, + noRepo: false, + unsafeRepo: false, + }; + if (this._state == null) { + return sections; + } + + if (!this._state.repositories.trusted) { + sections.header = true; + sections.untrusted = true; + } else if (this._state.repositories.openCount === 0) { + sections.header = true; + sections.noRepo = true; + } else if (this._state.repositories.hasUnsafe) { + sections.header = true; + sections.unsafeRepo = true; + } + + return sections; + } + + override render() { + if (this._state == null || !this.alertVisibility.header) { + return; + } + + return html` + ${when( + this.alertVisibility.noRepo, + () => html` +
    +

    No repository detected

    +
    +

    + To use GitLens, open a folder containing a git repository or clone from a URL from the + Explorer. +

    +

    + Open a Folder or Repository +

    +

    + If you have opened a folder with a repository, please let us know by + creating an Issue. +

    +
    +
    + `, + )} + ${when( + this.alertVisibility.unsafeRepo, + () => html` +
    +

    Unsafe repository

    +
    +

    + Unable to open any repositories as Git blocked them as potentially unsafe, due to the + folder(s) not being owned by the current user. +

    +

    + Manage in Source Control +

    +
    +
    + `, + )} + ${when( + this.alertVisibility.untrusted, + () => html` + + `, + )} + `; + } +} diff --git a/src/webviews/apps/home/components/stepped-section.ts b/src/webviews/apps/home/components/stepped-section.ts deleted file mode 100644 index 6619480349882..0000000000000 --- a/src/webviews/apps/home/components/stepped-section.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { attr, css, customElement, FASTElement, html } from '@microsoft/fast-element'; -import { numberConverter } from '../../shared/components/converters/number-converter'; -import '../../shared/components/code-icon'; - -const template = html``; - -const styles = css` - * { - box-sizing: border-box; - } - - :host { - display: grid; - gap: 0 0.8rem; - grid-template-columns: 16px auto; - grid-auto-flow: column; - margin-bottom: 2.4rem; - } - - .button { - width: 100%; - padding: 0.1rem 0 0 0; - font-size: var(--vscode-editor-font-size); - line-height: 1.6rem; - font-family: inherit; - border: none; - color: inherit; - background: none; - text-align: left; - text-transform: uppercase; - cursor: pointer; - } - - .button:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 0.2rem; - } - - .checkline { - position: relative; - grid-column: 1; - grid-row: 1 / span 2; - color: var(--vscode-textLink-foreground); - } - - :host(:not(:last-of-type)) .checkline:after { - content: ''; - position: absolute; - border-left: 0.1rem solid currentColor; - width: 0; - top: 1.6rem; - bottom: -2.4rem; - left: 50%; - transform: translateX(-50%); - opacity: 0.3; - } - - .checkbox { - cursor: pointer; - } - .checkbox code-icon { - pointer-events: none; - } - - .heading:hover ~ .checkline .check-icon, - .checkbox:hover .check-icon { - display: none; - } - - .check-hover-icon { - display: none; - } - .heading:hover ~ .checkline .check-hover-icon, - .checkbox:hover .check-hover-icon { - display: unset; - } - - .content { - margin-top: 1rem; - } - - .content.is-hidden { - display: none; - } - - .description { - margin-left: 0.6rem; - text-transform: none; - opacity: 0.5; - } -`; - -@customElement({ name: 'stepped-section', template: template, styles: styles }) -export class SteppedSection extends FASTElement { - @attr({ attribute: 'heading-level', converter: numberConverter }) - headingLevel = 2; - - @attr({ mode: 'boolean' }) - completed = false; - - handleClick(_e: Event) { - this.completed = !this.completed; - this.$emit('complete', this.completed); - } -} diff --git a/src/webviews/apps/home/context.ts b/src/webviews/apps/home/context.ts new file mode 100644 index 0000000000000..ff3a6b239bf29 --- /dev/null +++ b/src/webviews/apps/home/context.ts @@ -0,0 +1,4 @@ +import { createContext } from '@lit/context'; +import type { State } from '../../home/protocol'; + +export const stateContext = createContext('state'); diff --git a/src/webviews/apps/home/home.css.ts b/src/webviews/apps/home/home.css.ts new file mode 100644 index 0000000000000..4490b22b69dcf --- /dev/null +++ b/src/webviews/apps/home/home.css.ts @@ -0,0 +1,345 @@ +import { css } from 'lit'; + +export const homeBaseStyles = css` + * { + box-sizing: border-box; + } + + :not(:defined) { + visibility: hidden; + } + + [hidden] { + display: none !important; + } + + /* roll into shared focus style */ + :focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + b { + font-weight: 600; + } + + p { + margin-top: 0; + } + + ul { + margin-top: 0; + padding-left: 1.2em; + } +`; + +export const homeStyles = css` + .home { + padding: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 0.4rem; + overflow: hidden; + } + .home__header { + flex: none; + padding: 0 2rem; + position: relative; + } + .home__main { + flex: 1; + overflow: auto; + padding: 0.8rem 2rem; + } + .home__main > *:last-child { + margin-bottom: 0; + } + .home__nav { + flex: none; + padding: 0; + margin-block: 0.6rem -1rem; + } + .home__footer { + flex: none; + } + + gl-home-account-content { + margin-bottom: 0; + } +`; + +export const inlineNavStyles = css` + .inline-nav { + display: flex; + flex-direction: row; + justify-content: space-between; + } + .inline-nav__group { + display: flex; + flex-direction: row; + } + .inline-nav__link { + display: flex; + justify-content: center; + align-items: center; + width: 2.2rem; + height: 2.2rem; + color: inherit; + border-radius: 0.3rem; + } + .inline-nav__link .code-icon { + line-height: 1.6rem; + } + .inline-nav__link:hover { + color: inherit; + text-decoration: none; + } + :host-context(.vscode-dark) .inline-nav__link:hover { + background-color: var(--color-background--lighten-10); + } + :host-context(.vscode-light) .inline-nav__link:hover { + background-color: var(--color-background--darken-10); + } + @media (max-width: 370px) { + .inline-nav__link--text > :last-child { + display: none; + } + } + @media (min-width: 371px) { + .inline-nav__link--text { + flex: none; + padding-left: 0.3rem; + padding-right: 0.3rem; + gap: 0.2rem; + min-width: 2.2rem; + width: fit-content; + } + .inline-nav__link--text + .inline-nav__link--text { + margin-left: 0.2rem; + } + } + + .promo-banner { + text-align: center; + margin-bottom: 1rem; + } + .promo-banner--eyebrow { + color: var(--color-foreground--50); + margin-bottom: 0.2rem; + } +`; + +export const buttonStyles = css` + .button-container { + margin: 1rem auto 0; + text-align: left; + max-width: 30rem; + transition: max-width 0.2s ease-out; + } + + @media (min-width: 640px) { + .button-container { + max-width: 100%; + } + } + .button-container--trio > gl-button:first-child { + margin-bottom: 0.4rem; + } + + .button-group { + display: inline-flex; + gap: 0.4rem; + } + .button-group--single { + width: 100%; + max-width: 30rem; + } + .button-group gl-button { + margin-top: 0; + } + .button-group gl-button:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + .button-group gl-button:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +`; + +export const alertStyles = css` + .alert { + position: relative; + padding: 0.8rem 1.2rem; + line-height: 1.2; + margin-bottom: 1.2rem; + background-color: var(--color-alert-neutralBackground); + border-left: 0.3rem solid var(--color-alert-neutralBorder); + color: var(--color-alert-foreground); + } + .alert__title { + font-size: 1.4rem; + margin: 0; + } + .alert__description { + font-size: 1.2rem; + margin: 0.4rem 0 0; + } + .alert__description > :first-child { + margin-top: 0; + } + .alert__description > :last-child { + margin-bottom: 0; + } + .alert__close { + position: absolute; + top: 0.8rem; + right: 0.8rem; + color: inherit; + opacity: 0.64; + } + .alert__close:hover { + color: inherit; + opacity: 1; + } + .alert.is-collapsed { + cursor: pointer; + } + .alert.is-collapsed:hover { + background-color: var(--color-alert-neutralHoverBackground); + } + .alert.is-collapsed .alert__description, + .alert.is-collapsed .alert__close gl-tooltip:first-child, + .alert:not(.is-collapsed) .alert__close gl-tooltip:last-child { + display: none; + } + .alert--info { + background-color: var(--color-alert-infoBackground); + border-left-color: var(--color-alert-infoBorder); + } + .alert--warning { + background-color: var(--color-alert-warningBackground); + border-left-color: var(--color-alert-warningBorder); + } + .alert--danger { + background-color: var(--color-alert-errorBackground); + border-left-color: var(--color-alert-errorBorder); + } +`; + +export const navListStyles = css` + .nav-list { + margin-left: -2rem; + margin-right: -2rem; + display: flex; + flex-direction: column; + gap: 0.1rem; + align-items: stretch; + margin-bottom: 1.6rem; + } + .nav-list__item { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.8rem; + padding: 0.4rem 2rem; + } + .nav-list__item:hover, + .nav-list__item:focus-within { + background-color: var(--vscode-list-hoverBackground); + color: var(--vscode-list-hoverForeground); + } + .nav-list__item:has(:first-child:focus) { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + .nav-list__item:has(:active) { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + } + .nav-list__item:has(.is-disabled) { + cursor: not-allowed; + } + .nav-list__link { + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.8rem; + color: inherit; + } + .nav-list__link:hover, + .nav-list__link:focus { + color: inherit; + text-decoration: none; + } + .nav-list__link:focus { + outline: none; + } + .nav-list__link.is-disabled, + .nav-list__link.is-disabled:hover { + opacity: 0.5; + pointer-events: none; + text-decoration: none; + } + .nav-list__icon { + flex: none; + opacity: 0.5; + } + .nav-list__label { + flex: 1; + font-weight: 600; + } + .nav-list__desc { + color: var(--color-foreground--65); + font-variant: all-small-caps; + margin-left: 1rem; + } + .nav-list__group { + width: 100%; + display: flex; + justify-content: flex-start; + } + .nav-list__group .nav-list__label { + width: auto; + } + .nav-list__access { + flex: none; + position: relative; + left: 1.5rem; + font-size: x-small; + outline: none; + white-space: nowrap; + --gl-feature-badge-color: color-mix(in srgb, transparent 40%, currentColor); + --gl-feature-badge-border-color: color-mix(in srgb, transparent 40%, var(--color-foreground--50)); + } + .nav-list__item:hover .nav-list__label { + text-decoration: underline; + } + .nav-list__item:hover .is-disabled .nav-list__label { + text-decoration: none; + } + .nav-list__item:hover .nav-list__desc { + color: var(--color-foreground); + } + .nav-list__item:focus-within .nav-list__access, + .nav-list__item:hover .nav-list__access { + --gl-feature-badge-color: currentColor; + --gl-feature-badge-border-color: var(--color-foreground--50); + } + .nav-list__title { + padding: 0 2rem; + } + + .t-eyebrow { + text-transform: uppercase; + font-size: 1rem; + font-weight: 600; + color: var(--color-foreground--50); + margin: 0; + } + .t-eyebrow.sticky { + top: -8px; + } +`; diff --git a/src/webviews/apps/home/home.html b/src/webviews/apps/home/home.html index 8f8371747e018..de0b59853460d 100644 --- a/src/webviews/apps/home/home.html +++ b/src/webviews/apps/home/home.html @@ -1,354 +1,26 @@ - + - - - - skip to links - skip to main content -
    - -
    -
    - -
    -
    - -
    - - Welcome to GitLens 13 -

    - GitLens supercharges Git inside VS Code and unlocks the untapped knowledge within each - repository. -

    - - Get Started Tutorial Video - - -
    - - Features - always free and accessible -

    - GitLens is deeply integrated into many areas and aspects of VS Code, especially editors and - views. Learn more in the Feature Walkthrough. -

    - -
    -
    -

    - Find many features by opening the - Source Control Side Bar. -

    -

    - Click on - a layout option to set the location of your GitLens views. -

    -
    - -
    -
    - - GitLens+ Features - want even more from GitLens? - -
    - -
    -
    - -
    - - Integrations -

    GitLens provides issue and pull request auto-linking with many Git hosting services.

    -

    - Rich integrations with GitHub & GitLab provide more detailed hover information for auto-linked - issues and pull requests, pull requests associated with branches and commits, and avatars. -

    -
    -
    - -
    - - Focus View ✨ (preview) - Focus view Screenshot -

    - The - Focus View - provides you with a comprehensive list of all your most important work across your connected - GitHub repos. -

    -
    - - Commit Graph ✨ - Commit Graph illustration -

    - The - Commit Graph - helps you easily visualize and keep track of all work in progress. -

    -

    - Use the rich commit search to find exactly what you're looking for. It's powerful filters allow - you to search by a specific commit, message, author, a changed file or files, or even a specific - code change. -

    -
    - - Visual File History ✨ - Visual File History illustration -

    - The - Visual File History - allows you to quickly see the evolution of a file, including when changes were made, how large - they were, and who made them. -

    -

    - Use it to quickly find when the most impactful changes were made to a file or who best to talk - to about file changes and more. -

    -
    - - Worktrees ✨ - Worktrees illustration -

    - Worktrees - help you multitask by minimizing the context switching between branches, allowing you to easily - work on different branches of a repository simultaneously. -

    -

    - Avoid interrupting your work in progress when needing to review a pull request. Simply create a - new worktree and open it in a new VS Code window, all without impacting your other work -

    -
    -
    - -
    - - #{endOfBody} + + + + diff --git a/src/webviews/apps/home/home.scss b/src/webviews/apps/home/home.scss index e4a3e7f9a7334..58e0a808fe2d4 100644 --- a/src/webviews/apps/home/home.scss +++ b/src/webviews/apps/home/home.scss @@ -1,27 +1,14 @@ -:root { - --gitlens-z-inline: 1000; - --gitlens-z-sticky: 1100; - --gitlens-z-popover: 1200; - --gitlens-z-cover: 1300; - --gitlens-z-dialog: 1400; - --gitlens-z-modal: 1500; - --gitlens-brand-color: #914db3; - --gitlens-brand-color-2: #a16dc4; -} +@use '../shared/styles/properties'; +@use '../shared/styles/theme'; +@use '../shared/styles/scrollbars'; .vscode-high-contrast, .vscode-dark { - --progress-bar-color: var(--color-background--lighten-15); - --card-background: var(--color-background--lighten-075); - --card-hover-background: var(--color-background--lighten-10); --popover-bg: var(--color-background--lighten-15); } .vscode-high-contrast-light, .vscode-light { - --progress-bar-color: var(--color-background--darken-15); - --card-background: var(--color-background--darken-075); - --card-hover-background: var(--color-background--darken-10); --popover-bg: var(--color-background--darken-15); } @@ -47,579 +34,13 @@ html { } body { + padding: 0; background-color: var(--color-view-background); color: var(--color-view-foreground); font-family: var(--font-family); min-height: 100%; line-height: 1.4; font-size: var(--vscode-font-size); - - &.scrollable, - .scrollable { - border-color: transparent; - transition: border-color 1s linear; - } - - &:hover, - &:focus-within { - &.scrollable, - .scrollable { - border-color: var(--vscode-scrollbarSlider-background); - transition: none; - } - } - - &.preload { - &.scrollable, - .scrollable { - transition: none; - } - } -} - -::-webkit-scrollbar-corner { - background-color: transparent !important; -} - -::-webkit-scrollbar-thumb { - background-color: transparent; - border-color: inherit; - border-right-style: inset; - border-right-width: calc(100vw + 100vh); - border-radius: unset !important; - - &:hover { - border-color: var(--vscode-scrollbarSlider-hoverBackground); - } - - &:active { - border-color: var(--vscode-scrollbarSlider-activeBackground); - } -} - -:focus { - outline-color: var(--vscode-focusBorder); -} - -.sr-skip { - position: fixed; - z-index: var(--gitlens-z-popover); - top: 0.2rem; - left: 0.2rem; - display: inline-block; - padding: 0.2rem 0.4rem; - background-color: var(--color-view-background); -} -.sr-only, -.sr-only-focusable:not(:active):not(:focus) { - clip: rect(0 0 0 0); - clip-path: inset(50%); - width: 1px; - height: 1px; - overflow: hidden; - position: absolute; - white-space: nowrap; } -.home { - padding: 0; - height: 100%; - display: flex; - flex-direction: column; - gap: 0.4rem; - overflow: hidden; - - &__header { - flex: none; - padding: 0 2rem; - position: relative; - } - &__main { - flex: 1; - overflow: auto; - padding: 2rem 2rem 0.4rem; - - background: linear-gradient(var(--color-view-background) 33%, var(--color-view-background)), - linear-gradient(var(--color-view-background), var(--color-view-background) 66%) 0 100%, - linear-gradient(to bottom, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)), - linear-gradient(to top, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)) 0 100%; - background-color: var(--color-view-background); - background-repeat: no-repeat; - background-attachment: local, local, scroll, scroll; - background-size: 100% 12px, 100% 12px, 100% 6px, 100% 6px; - } - &__nav { - flex: none; - padding: 0 2rem; - margin-bottom: 0.6rem; - } -} - -.popover { - background-color: var(--color-background--lighten-15); - position: absolute; - top: 100%; - left: 5.2rem; - transform: translateY(0.8rem); - max-width: 30rem; - padding: 0.8rem 1.2rem 1.2rem; - z-index: 10; - - display: flex; - flex-direction: column; - gap: 0.4rem; - - &__top { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - opacity: 0.5; - } - - &__heading { - font-weight: 600; - } - - &__caret { - position: absolute; - bottom: 100%; - width: 0; - height: 0; - border-left: 0.8rem solid transparent; - border-right: 0.8rem solid transparent; - border-bottom: 0.8rem solid var(--color-background--lighten-15); - } -} - -h3 { - border: none; - color: var(--color-view-header-foreground); - font-size: 1.5rem; - font-weight: 600; - margin-bottom: 0; - white-space: nowrap; -} - -h4 { - font-size: 1.5rem; - font-weight: 400; - margin: 1rem 0; -} - -a { - text-decoration: none; - - &:focus { - outline-color: var(--focus-border); - } - - &:hover { - text-decoration: underline; - } -} - -b { - font-weight: 600; -} - -p { - margin-top: 0; -} - -ul { - margin-top: 0; - padding-left: 1.2em; -} - -.unlist { - list-style: none; - padding-left: 0; -} - -.icon-list { - list-style: none; - padding-left: 0; - - li { - position: relative; - padding-left: 2.2rem; - - > code-icon:first-child { - position: absolute; - left: 0; - top: 0.1rem; - font-size: 1.6rem; - color: var(--color-foreground--50); - } - } -} - -.button-container { - display: flex; - flex-direction: column; - margin-bottom: 1rem; -} - -.button-link { - code-icon { - margin-right: 0.4rem; - } -} - -.centered { - text-align: center; -} - -.foreground { - color: var(--color-view-foreground); -} - -.inline-nav { - display: flex; - flex-direction: row; - justify-content: space-between; - - &__group { - display: flex; - flex-direction: row; - } - - &__link { - display: flex; - justify-content: center; - align-items: center; - width: 2.2rem; - height: 2.2rem; - // line-height: 2.2rem; - color: inherit; - border-radius: 0.3rem; - - .codicon { - line-height: 1.6rem; - } - - &:hover { - color: inherit; - text-decoration: none; - - .vscode-dark & { - background-color: var(--color-background--lighten-10); - } - .vscode-light & { - background-color: var(--color-background--darken-10); - } - } - - &--text { - @media (max-width: 370px) { - > :last-child { - display: none; - } - } - - @media (min-width: 371px) { - flex: none; - padding: { - left: 0.3rem; - right: 0.3rem; - } - gap: 0.2rem; - min-width: 2.2rem; - width: fit-content; - - & + & { - margin-left: 0.2rem; - } - } - } - } -} - -.gl-plus-banner { - background-color: transparent; - background-position: left -30vw center; - background-size: 80vw; -} - -.plus-banner-text { - text-shadow: 0.1rem 0.1rem 0 var(--color-background), 0.1rem 0.1rem 0.2rem var(--color-background); -} - -.logo { - font-size: 1.8rem; - color: var(--gitlens-brand-color-2); - font-weight: 500; -} - -.description { - color: #b68cd8; - opacity: 0.6; -} - -.alert { - padding: 0.8rem 1.2rem; - line-height: 1.2; - margin-bottom: 1.2rem; - background-color: var(--color-alert-neutralBackground); - border-left: 0.3rem solid var(--color-alert-neutralBorder); - color: var(--color-alert-foreground); - - &__title { - font-size: 1.4rem; - margin: 0; - } - - &__description { - font-size: 1.2rem; - margin: 0.4rem 0 0; - } -} - -.activitybar-banner { - display: flex; - flex-direction: row-reverse; - justify-content: flex-end; - align-items: stretch; - gap: 1.6rem; - - @media (max-width: 280px) { - flex-direction: column; - align-items: center; - } - - ul { - display: flex; - flex-direction: column; - justify-content: center; - gap: clamp(0.1rem, 2vw, 1.2rem); - margin-bottom: 0; - } - - &__content { - // padding-top: 1.6rem; - display: flex; - flex-direction: column; - justify-content: center; - - > :last-child { - margin-bottom: 0; - } - } - - &__media { - position: relative; - flex: none; - width: 9.2rem; - } - - &__nav { - position: absolute; - top: 0; - left: 0.4rem; - width: 4.8rem; - height: 12.3rem; - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; - gap: 1.6rem; - - &-item { - position: absolute; - left: 0.5rem; - width: 4.6rem; - height: 3.2rem; - // background-color: #ff000066; - - &:first-of-type { - top: 2.2rem; - } - - &:last-of-type { - top: 7rem; - } - } - } - - #no-repo[aria-hidden='false'] ~ & { - display: none; - } -} - -#no-repo { - margin-bottom: 0; - - &[aria-hidden='true'] { - display: none; - } -} - -.video-banner { - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-end; - margin-bottom: 0.8rem; - background: no-repeat var(--video-banner-play) 1.2rem center, no-repeat var(--video-banner-bg) left center; - background-color: var(--card-background); - background-size: clamp(2.9rem, 8%, 6rem), cover; - background-blend-mode: normal, overlay; - aspect-ratio: var(--video-banner-ratio, 354 / 54); - padding: 0.4rem 1.2rem; - color: inherit; - line-height: 1.2; - font-size: clamp(var(--vscode-font-size), 4vw, 2.4rem); - transition: aspect-ratio linear 100ms, background-color linear 100ms, background-position linear 200ms; - border-radius: 0.4rem; - - @media (min-width: 277px) { - background-blend-mode: normal, normal; - background-position: center center, left center; - } - - @media (min-width: 564px) { - aspect-ratio: var(--video-banner-ratio, 354 / 40); - } - - &:hover { - background-color: var(--card-hover-background); - text-decoration: none; - color: inherit; - } - - small { - color: #8d778d; - } -} - -.link-minimal { - color: var(--color-foreground--50); - font-size: 1rem; - text-align: center; - position: relative; - top: 0.6rem; - - &:hover { - color: var(--color-foreground--50); - } -} - -vscode-button { - max-width: 300px; - width: 100%; - - & + & { - margin-top: 1rem; - } -} - -.link-minimal, -vscode-button { - align-self: center; - - @media (min-width: 640px) { - align-self: flex-start; - } -} - -@import '../shared/codicons'; - -// .codicon { -// position: relative; -// top: -2px; -// } - -.type-tight { - line-height: 1.2; -} - -.mb-1 { - margin-bottom: 0.4rem; -} -.mb-0 { - margin-bottom: 0; -} - -.hide { - display: none; -} - -.svg { - width: 100%; - height: auto; - - &__outline { - transition: all ease 250ms; - - .vscode-light &, - .vscode-high-contrast-light & { - stop-color: var(--color-background--darken-15); - } - - .vscode-dark &, - .vscode-high-contrast & { - stop-color: var(--color-background--lighten-15); - } - } - - &:hover &__outline, - .activitybar-banner__nav-item:focus ~ & &__outline, - .activitybar-banner__nav-item:hover ~ & &__outline { - .vscode-light &, - .vscode-high-contrast-light & { - stop-color: var(--color-background--darken-50); - } - - .vscode-dark &, - .vscode-high-contrast & { - stop-color: var(--color-background--lighten-50); - } - } - - &__bar { - fill: var(--vscode-activityBar-background); - } - - &__indicator { - fill: transparent; - &.is-active { - fill: var(--vscode-activityBar-activeBorder); - } - } - &__icon { - transition: all ease 100ms; - fill: var(--vscode-activityBar-inactiveForeground); - &.is-active { - fill: var(--vscode-activityBar-foreground); - } - } - &__arrow { - fill: transparent; - &.is-active { - fill: var(--vscode-textLink-foreground); - } - } - - .activitybar-banner__nav-item:first-of-type:focus ~ & &__icon:last-of-type, - .activitybar-banner__nav-item:first-of-type:hover ~ & &__icon:last-of-type, - .activitybar-banner__nav-item:last-of-type:focus ~ & &__icon:first-of-type, - .activitybar-banner__nav-item:last-of-type:hover ~ & &__icon:first-of-type { - fill: var(--vscode-activityBar-foreground); - } -} - -.plus-section-thumb { - border-radius: 0.6rem; -} - -@media (max-width: 280px) { - .not-small { - display: none; - } -} -@media (min-width: 281px) { - .only-small { - display: none; - } -} +@include scrollbars.scrollbarFix(); diff --git a/src/webviews/apps/home/home.ts b/src/webviews/apps/home/home.ts index ee3698eed5954..7c475e9932283 100644 --- a/src/webviews/apps/home/home.ts +++ b/src/webviews/apps/home/home.ts @@ -1,355 +1,44 @@ /*global*/ import './home.scss'; -import { provideVSCodeDesignSystem, vsCodeButton } from '@vscode/webview-ui-toolkit'; -import type { Disposable } from 'vscode'; -import { getSubscriptionTimeRemaining, SubscriptionState } from '../../../subscription'; +import { html } from 'lit'; +import { customElement } from 'lit/decorators.js'; import type { State } from '../../home/protocol'; -import { - CompleteStepCommandType, - DidChangeConfigurationType, - DidChangeExtensionEnabledType, - DidChangeLayoutType, - DidChangeSubscriptionNotificationType, - DismissBannerCommandType, - DismissSectionCommandType, - DismissStatusCommandType, -} from '../../home/protocol'; -import type { IpcMessage } from '../../protocol'; -import { ExecuteCommandType, onIpc } from '../../protocol'; -import { App } from '../shared/appBase'; -import { DOM } from '../shared/dom'; -import type { CardSection } from './components/card-section'; -import type { HeaderCard } from './components/header-card'; -import type { PlusBanner } from './components/plus-banner'; -import type { SteppedSection } from './components/stepped-section'; -import '../shared/components/code-icon'; -import '../shared/components/overlays/pop-over'; -import './components/card-section'; -import './components/header-card'; -import './components/plus-banner'; -import './components/plus-content'; -import './components/stepped-section'; - -export class HomeApp extends App { - private $steps!: SteppedSection[]; - private $cards!: CardSection[]; - - constructor() { - super('HomeApp'); - } - - protected override onInitialize() { - provideVSCodeDesignSystem().register(vsCodeButton()); - - this.$steps = [...document.querySelectorAll('stepped-section[id]')]; - this.$cards = [...document.querySelectorAll('card-section[id]')]; - - this.updateState(); - } - - protected override onBind(): Disposable[] { - const disposables = super.onBind?.() ?? []; - - disposables.push( - DOM.on('[data-action]', 'click', (e, target: HTMLElement) => this.onDataActionClicked(e, target)), - ); - disposables.push( - DOM.on('plus-banner', 'action', (e, target: HTMLElement) => - this.onPlusActionClicked(e, target), - ), - ); - disposables.push( - DOM.on('stepped-section', 'complete', (e, target: HTMLElement) => - this.onStepComplete(e, target), - ), - ); - disposables.push( - DOM.on('card-section', 'dismiss', (e, target: HTMLElement) => - this.onCardDismissed(e, target), - ), - ); - disposables.push( - DOM.on('header-card', 'dismiss-status', (e, target: HTMLElement) => - this.onStatusDismissed(e, target), - ), - ); - disposables.push( - DOM.on('[data-banner-dismiss]', 'click', (e, target: HTMLElement) => this.onBannerDismissed(e, target)), - ); - - return disposables; - } - - protected override onMessageReceived(e: MessageEvent) { - const msg = e.data as IpcMessage; - - switch (msg.method) { - case DidChangeSubscriptionNotificationType.method: - this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); - - onIpc(DidChangeSubscriptionNotificationType, msg, params => { - this.state.subscription = params.subscription; - this.state.completedActions = params.completedActions; - this.state.avatar = params.avatar; - this.state.pinStatus = params.pinStatus; - this.updateState(); - }); - break; - case DidChangeExtensionEnabledType.method: - this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); - - onIpc(DidChangeExtensionEnabledType, msg, params => { - this.state.extensionEnabled = params.extensionEnabled; - this.updateNoRepo(); - }); - break; - case DidChangeConfigurationType.method: - this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); - - onIpc(DidChangeConfigurationType, msg, params => { - this.state.plusEnabled = params.plusEnabled; - this.updatePlusContent(); - }); - break; - case DidChangeLayoutType.method: - this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); - - onIpc(DidChangeLayoutType, msg, params => { - this.state.layout = params.layout; - this.updateLayout(); - }); - break; - - default: - super.onMessageReceived?.(e); - break; - } - } - - private onStepComplete(e: CustomEvent, target: HTMLElement) { - const id = target.id; - const isComplete = e.detail ?? false; - this.state.completedSteps = toggleArrayItem(this.state.completedSteps, id, isComplete); - this.sendCommand(CompleteStepCommandType, { id: id, completed: isComplete }); - this.updateState(); - } - - private onCardDismissed(e: CustomEvent, target: HTMLElement) { - const id = target.id; - this.state.dismissedSections = toggleArrayItem(this.state.dismissedSections, id); - this.sendCommand(DismissSectionCommandType, { id: id }); - this.updateState(); - } - - private onStatusDismissed(_e: CustomEvent, _target: HTMLElement) { - this.state.pinStatus = false; - this.sendCommand(DismissStatusCommandType, undefined); - this.updateHeader(); - } - - private onBannerDismissed(_e: MouseEvent, target: HTMLElement) { - const key = target.getAttribute('data-banner-dismiss'); - if (key == null || this.state.dismissedBanners?.includes(key)) { - return; - } - this.state.dismissedBanners = this.state.dismissedBanners ?? []; - this.state.dismissedBanners.push(key); - this.sendCommand(DismissBannerCommandType, { id: key }); - this.updateBanners(); - } - - private onDataActionClicked(_e: MouseEvent, target: HTMLElement) { - const action = target.dataset.action; - this.onActionClickedCore(action); - } - - private onPlusActionClicked(e: CustomEvent, _target: HTMLElement) { - this.onActionClickedCore(e.detail); - } - - private onActionClickedCore(action?: string) { - if (action?.startsWith('command:')) { - this.sendCommand(ExecuteCommandType, { command: action.slice(8) }); - } - } - - private getDaysRemaining() { - if ( - ![SubscriptionState.FreeInPreviewTrial, SubscriptionState.FreePlusInTrial].includes( - this.state.subscription.state, - ) - ) { - return 0; - } - - return getSubscriptionTimeRemaining(this.state.subscription, 'days') ?? 0; - } - - private forceShowPlus() { - return [ - SubscriptionState.FreePreviewTrialExpired, - SubscriptionState.FreePlusTrialExpired, - SubscriptionState.VerificationRequired, - ].includes(this.state.subscription.state); - } - - private updateHeader(days = this.getDaysRemaining(), forceShowPlus = this.forceShowPlus()) { - const { subscription, completedSteps, avatar, pinStatus } = this.state; - - const $headerContent = document.getElementById('header-card') as HeaderCard; - if ($headerContent) { - if (avatar) { - $headerContent.setAttribute('image', avatar); - } - $headerContent.setAttribute('name', subscription.account?.name ?? ''); - - const steps = this.$steps?.length ?? 0; - let completed = completedSteps?.length ?? 0; - if (steps > 0 && completed > 0) { - const stepIds = this.$steps.map(el => el.id); - const availableCompleted = completedSteps!.filter(name => stepIds.includes(name)); - completed = availableCompleted.length; - - if (forceShowPlus && availableCompleted.includes('plus')) { - completed -= 1; - } - } - - $headerContent.setAttribute('steps', steps.toString()); - $headerContent.setAttribute('completed', completed.toString()); - $headerContent.setAttribute('state', subscription.state.toString()); - $headerContent.setAttribute('plan', subscription.plan.effective.name); - $headerContent.setAttribute('days', days.toString()); - $headerContent.pinStatus = pinStatus; - } - } - - private updateBanners() { - const $banners = [...document.querySelectorAll('[data-banner]')]; - if (!$banners.length) { - return; - } - - const { dismissedBanners } = this.state; - $banners.forEach($el => { - const key = $el.getAttribute('data-banner'); - if (key !== null && dismissedBanners?.includes(key)) { - $el.setAttribute('hidden', 'true'); - } else { - $el.removeAttribute('hidden'); - } - }); - } - - private updateNoRepo() { - const { extensionEnabled } = this.state; - - const $el = document.getElementById('no-repo'); - if ($el) { - $el.setAttribute('aria-hidden', extensionEnabled ? 'true' : 'false'); - } - } - - private updateLayout() { - const { layout } = this.state; - - const $els = [...document.querySelectorAll('[data-gitlens-layout]')]; - $els.forEach(el => { - const attr = el.getAttribute('data-gitlens-layout'); - el.classList.toggle('is-active', attr === layout); - }); - } - - private updatePlusContent(days = this.getDaysRemaining()) { - const { subscription, visibility, plusEnabled } = this.state; - - let $plusContent = document.getElementById('plus-banner'); - if ($plusContent) { - $plusContent.setAttribute('days', days.toString()); - $plusContent.setAttribute('state', subscription.state.toString()); - $plusContent.setAttribute('visibility', visibility); - $plusContent.setAttribute('plan', subscription.plan.effective.name); - $plusContent.setAttribute('plus', plusEnabled.toString()); - } - - $plusContent = document.getElementById('plus-content'); - if ($plusContent) { - $plusContent.setAttribute('days', days.toString()); - $plusContent.setAttribute('state', subscription.state.toString()); - $plusContent.setAttribute('visibility', visibility); - $plusContent.setAttribute('plan', subscription.plan.effective.name); - } - } - - private updateSteps(forceShowPlus = this.forceShowPlus()) { - if ( - this.$steps == null || - this.$steps.length === 0 || - this.state.completedSteps == null || - this.state.completedSteps.length === 0 - ) { - return; - } - - this.$steps.forEach(el => { - el.setAttribute( - 'completed', - (el.id === 'plus' && forceShowPlus) || this.state.completedSteps?.includes(el.id) !== true - ? 'false' - : 'true', - ); - }); - } - - private updateSections() { - if ( - this.$cards == null || - this.$cards.length === 0 || - this.state.dismissedSections == null || - this.state.dismissedSections.length === 0 - ) { - return; - } - - this.state.dismissedSections.forEach(id => { - const found = this.$cards.findIndex(el => el.id === id); - if (found > -1) { - this.$cards[found].remove(); - this.$cards.splice(found, 1); - } - }); - } - - private updateState() { - const { completedSteps, dismissedSections } = this.state; - - this.updateNoRepo(); - this.updateLayout(); - - const showRestoreWelcome = completedSteps?.length || dismissedSections?.length; - document.getElementById('restore-welcome')?.classList.toggle('hide', !showRestoreWelcome); - - const forceShowPlus = this.forceShowPlus(); - const days = this.getDaysRemaining(); - this.updateHeader(days, forceShowPlus); - this.updatePlusContent(days); - - this.updateSteps(forceShowPlus); - - this.updateSections(); - this.updateBanners(); +import { GlApp } from '../shared/app'; +import { scrollableBase } from '../shared/components/styles/lit/base.css'; +import type { HostIpc } from '../shared/ipc'; +import { homeBaseStyles, homeStyles } from './home.css'; +import { HomeStateProvider } from './stateProvider'; +import '../plus/shared/components/home-account-content'; +import './components/feature-nav'; +import './components/home-nav'; +import './components/repo-alerts'; +import './components/onboarding'; + +@customElement('gl-home-app') +export class GlHomeApp extends GlApp { + static override styles = [homeBaseStyles, scrollableBase, homeStyles]; + + private badgeSource = { source: 'home', detail: 'badge' }; + + protected override createStateProvider(state: State, ipc: HostIpc) { + return new HomeStateProvider(this, state, ipc); + } + + override render() { + return html` +
    + +
    + + +
    + +
    + + + +
    +
    + `; } } - -function toggleArrayItem(list: string[] = [], item: string, add = true) { - const hasStep = list.includes(item); - if (!hasStep && add) { - list.push(item); - } else if (hasStep && !add) { - list.splice(list.indexOf(item), 1); - } - - return list; -} - -new HomeApp(); diff --git a/src/webviews/apps/home/stateProvider.ts b/src/webviews/apps/home/stateProvider.ts new file mode 100644 index 0000000000000..eb60570b34174 --- /dev/null +++ b/src/webviews/apps/home/stateProvider.ts @@ -0,0 +1,65 @@ +import { ContextProvider } from '@lit/context'; +import type { ReactiveControllerHost } from 'lit'; +import type { State } from '../../home/protocol'; +import { + DidChangeIntegrationsConnections, + DidChangeOrgSettings, + DidChangeRepositories, + DidChangeSubscription, +} from '../../home/protocol'; +import type { Disposable } from '../shared/events'; +import type { HostIpc } from '../shared/ipc'; +import { stateContext } from './context'; + +type ReactiveElementHost = Partial & HTMLElement; + +export class HomeStateProvider implements Disposable { + private readonly disposable: Disposable; + private readonly provider: ContextProvider<{ __context__: State }, ReactiveElementHost>; + private readonly state: State; + + constructor( + host: ReactiveElementHost, + state: State, + private readonly _ipc: HostIpc, + ) { + this.state = state; + this.provider = new ContextProvider(host, { context: stateContext, initialValue: state }); + + this.disposable = this._ipc.onReceiveMessage(msg => { + switch (true) { + case DidChangeRepositories.is(msg): + this.state.repositories = msg.params; + this.state.timestamp = Date.now(); + + this.provider.setValue(this.state, true); + break; + case DidChangeSubscription.is(msg): + this.state.subscription = msg.params.subscription; + this.state.avatar = msg.params.avatar; + this.state.organizationsCount = msg.params.organizationsCount; + this.state.timestamp = Date.now(); + + this.provider.setValue(this.state, true); + break; + case DidChangeOrgSettings.is(msg): + this.state.orgSettings = msg.params.orgSettings; + this.state.timestamp = Date.now(); + + this.provider.setValue(this.state, true); + break; + + case DidChangeIntegrationsConnections.is(msg): + this.state.hasAnyIntegrationConnected = msg.params.hasAnyIntegrationConnected; + this.state.timestamp = Date.now(); + + this.provider.setValue(this.state, true); + break; + } + }); + } + + dispose() { + this.disposable.dispose(); + } +} diff --git a/src/webviews/apps/media/cyberweek-2023-small-dark.png b/src/webviews/apps/media/cyberweek-2023-small-dark.png new file mode 100644 index 0000000000000..7e131d3fc820f Binary files /dev/null and b/src/webviews/apps/media/cyberweek-2023-small-dark.png differ diff --git a/src/webviews/apps/media/cyberweek-2023-small-light.png b/src/webviews/apps/media/cyberweek-2023-small-light.png new file mode 100644 index 0000000000000..7e131d3fc820f Binary files /dev/null and b/src/webviews/apps/media/cyberweek-2023-small-light.png differ diff --git a/src/webviews/apps/media/cyberweek-2023-wide-dark.png b/src/webviews/apps/media/cyberweek-2023-wide-dark.png new file mode 100644 index 0000000000000..a11d6c00fe094 Binary files /dev/null and b/src/webviews/apps/media/cyberweek-2023-wide-dark.png differ diff --git a/src/webviews/apps/media/cyberweek-2023-wide-light.png b/src/webviews/apps/media/cyberweek-2023-wide-light.png new file mode 100644 index 0000000000000..f8f7068fbcca2 Binary files /dev/null and b/src/webviews/apps/media/cyberweek-2023-wide-light.png differ diff --git a/src/webviews/apps/media/getting-started.png b/src/webviews/apps/media/getting-started.png deleted file mode 100644 index c515b31f76a0f..0000000000000 Binary files a/src/webviews/apps/media/getting-started.png and /dev/null differ diff --git a/src/webviews/apps/media/gitlens-logo.png b/src/webviews/apps/media/gitlens-logo.png index 9f35e25a15c19..cb80282b376c7 100644 Binary files a/src/webviews/apps/media/gitlens-logo.png and b/src/webviews/apps/media/gitlens-logo.png differ diff --git a/src/webviews/apps/media/holiday-2023-large-dark.png b/src/webviews/apps/media/holiday-2023-large-dark.png new file mode 100644 index 0000000000000..d77ef3abef4ab Binary files /dev/null and b/src/webviews/apps/media/holiday-2023-large-dark.png differ diff --git a/src/webviews/apps/media/holiday-2023-large-light.png b/src/webviews/apps/media/holiday-2023-large-light.png new file mode 100644 index 0000000000000..66cdad2391794 Binary files /dev/null and b/src/webviews/apps/media/holiday-2023-large-light.png differ diff --git a/src/webviews/apps/media/holiday-2023-small-dark.png b/src/webviews/apps/media/holiday-2023-small-dark.png new file mode 100644 index 0000000000000..4540f387be3da Binary files /dev/null and b/src/webviews/apps/media/holiday-2023-small-dark.png differ diff --git a/src/webviews/apps/media/holiday-2023-small-light.png b/src/webviews/apps/media/holiday-2023-small-light.png new file mode 100644 index 0000000000000..168e13a2f538a Binary files /dev/null and b/src/webviews/apps/media/holiday-2023-small-light.png differ diff --git a/src/webviews/apps/media/video-button-bg.png b/src/webviews/apps/media/video-button-bg.png new file mode 100644 index 0000000000000..a63406425b00e Binary files /dev/null and b/src/webviews/apps/media/video-button-bg.png differ diff --git a/src/webviews/apps/plus/LICENSE.plus b/src/webviews/apps/plus/LICENSE.plus index 814b362324cca..7d3d6faa994dd 100644 --- a/src/webviews/apps/plus/LICENSE.plus +++ b/src/webviews/apps/plus/LICENSE.plus @@ -1,6 +1,6 @@ GitLens+ License -Copyright (c) 2021-2023 Axosoft, LLC dba GitKraken ("GitKraken") +Copyright (c) 2021-2024 Axosoft, LLC dba GitKraken ("GitKraken") With regard to the software set forth in or under any directory named "plus". diff --git a/src/webviews/apps/plus/account/account.html b/src/webviews/apps/plus/account/account.html new file mode 100644 index 0000000000000..d0a39ee02754b --- /dev/null +++ b/src/webviews/apps/plus/account/account.html @@ -0,0 +1,23 @@ + + + + + + + + + + + #{endOfBody} + + diff --git a/src/webviews/apps/plus/account/account.scss b/src/webviews/apps/plus/account/account.scss new file mode 100644 index 0000000000000..3aae808eddeac --- /dev/null +++ b/src/webviews/apps/plus/account/account.scss @@ -0,0 +1,73 @@ +@use '../../shared/styles/properties'; +@use '../../shared/styles/theme'; +@use '../../shared/styles/scrollbars'; + +:root { + --gitlens-z-inline: 1000; + --gitlens-z-sticky: 1100; + --gitlens-z-popover: 1200; + --gitlens-z-cover: 1300; + --gitlens-z-dialog: 1400; + --gitlens-z-modal: 1500; + --gitlens-brand-color: #914db3; + --gitlens-brand-color-2: #a16dc4; +} + +.vscode-high-contrast, +.vscode-dark { + --progress-bar-color: var(--color-background--lighten-15); + --card-background: var(--color-background--lighten-075); + --card-hover-background: var(--color-background--lighten-10); + --popover-bg: var(--color-background--lighten-15); +} + +.vscode-high-contrast-light, +.vscode-light { + --progress-bar-color: var(--color-background--darken-15); + --card-background: var(--color-background--darken-075); + --card-hover-background: var(--color-background--darken-10); + --popover-bg: var(--color-background--darken-15); +} + +* { + box-sizing: border-box; +} + +// avoids FOUC for elements not yet called with `define()` +:not(:defined) { + visibility: hidden; +} + +[hidden] { + display: none !important; +} + +html { + height: 100%; + font-size: 62.5%; + text-size-adjust: 100%; +} + +body { + background-color: var(--color-view-background); + color: var(--color-view-foreground); + font-family: var(--font-family); + min-height: 100%; + line-height: 1.4; + font-size: var(--vscode-font-size); +} + +@include scrollbars.scrollableBase(); + +:focus { + outline-color: var(--vscode-focusBorder); +} + +account-content { + margin-top: 0.3rem; + margin-bottom: 1.3rem; +} + +.account { + height: 100%; +} diff --git a/src/webviews/apps/plus/account/account.ts b/src/webviews/apps/plus/account/account.ts new file mode 100644 index 0000000000000..c8ef99ee3facf --- /dev/null +++ b/src/webviews/apps/plus/account/account.ts @@ -0,0 +1,72 @@ +/*global*/ +import './account.scss'; +import type { Disposable } from 'vscode'; +import type { State } from '../../../../plus/webviews/account/protocol'; +import { DidChangeSubscriptionNotification } from '../../../../plus/webviews/account/protocol'; +import type { IpcMessage } from '../../../protocol'; +import { ExecuteCommand } from '../../../protocol'; +import { App } from '../../shared/appBase'; +import { DOM } from '../../shared/dom'; +import type { AccountContent } from './components/account-content'; +import './components/account-content'; + +export class AccountApp extends App { + constructor() { + super('AccountApp'); + } + + protected override onInitialize() { + this.state = this.getState() ?? this.state; + this.updateState(); + } + + protected override onBind(): Disposable[] { + const disposables = super.onBind?.() ?? []; + + disposables.push( + DOM.on('[data-action]', 'click', (e, target: HTMLElement) => this.onDataActionClicked(e, target)), + ); + + return disposables; + } + + protected override onMessageReceived(msg: IpcMessage) { + switch (true) { + case DidChangeSubscriptionNotification.is(msg): + this.state.subscription = msg.params.subscription; + this.state.avatar = msg.params.avatar; + this.state.organizationsCount = msg.params.organizationsCount; + this.state.timestamp = Date.now(); + this.setState(this.state); + this.updateState(); + break; + + default: + super.onMessageReceived?.(msg); + break; + } + } + + private onDataActionClicked(_e: MouseEvent, target: HTMLElement) { + const action = target.dataset.action; + this.onActionClickedCore(action); + } + + private onActionClickedCore(action?: string) { + if (action?.startsWith('command:')) { + this.sendCommand(ExecuteCommand, { command: action.slice(8) }); + } + } + + private updateState() { + const { subscription, avatar, organizationsCount } = this.state; + + const $content = document.getElementById('account-content')! as AccountContent; + + $content.image = avatar ?? ''; + $content.subscription = subscription; + $content.organizationsCount = organizationsCount ?? 0; + } +} + +new AccountApp(); diff --git a/src/webviews/apps/plus/account/components/account-content.ts b/src/webviews/apps/plus/account/components/account-content.ts new file mode 100644 index 0000000000000..e44826535be20 --- /dev/null +++ b/src/webviews/apps/plus/account/components/account-content.ts @@ -0,0 +1,357 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import { urls } from '../../../../../constants'; +import type { Promo } from '../../../../../plus/gk/account/promos'; +import { getApplicablePromo } from '../../../../../plus/gk/account/promos'; +import type { Subscription } from '../../../../../plus/gk/account/subscription'; +import { + getSubscriptionPlanName, + getSubscriptionTimeRemaining, + hasAccountFromSubscriptionState, + SubscriptionPlanId, + SubscriptionState, +} from '../../../../../plus/gk/account/subscription'; +import { pluralize } from '../../../../../system/string'; +import { elementBase, linkBase } from '../../../shared/components/styles/lit/base.css'; +import '../../../shared/components/button'; +import '../../../shared/components/button-container'; +import '../../../shared/components/code-icon'; +import '../../../shared/components/promo'; + +@customElement('account-content') +export class AccountContent extends LitElement { + static override styles = [ + elementBase, + linkBase, + css` + :host { + display: block; + margin-bottom: 1.3rem; + } + + button-container { + margin-bottom: 1.3rem; + } + + .account { + position: relative; + display: grid; + gap: 0 0.8rem; + grid-template-columns: 3.4rem auto min-content; + grid-auto-flow: column; + margin-bottom: 1.3rem; + } + + .account--org { + font-size: 0.9em; + line-height: 1.2; + margin-top: -1rem; + } + + .account__media { + grid-column: 1; + grid-row: 1 / span 2; + display: flex; + align-items: center; + justify-content: center; + } + + .account--org .account__media { + color: var(--color-foreground--65); + } + + .account__image { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 50%; + } + + .account__details { + grid-row: 1 / span 2; + display: flex; + flex-direction: column; + justify-content: center; + } + + .account__title { + font-size: 1.5rem; + font-weight: 600; + margin: 0; + } + + .account--org .account__title { + font-size: 1.2rem; + font-weight: normal; + } + + .account__access { + position: relative; + margin: 0; + color: var(--color-foreground--65); + } + + .account__signout { + grid-row: 1 / span 2; + display: flex; + gap: 0.2rem; + flex-direction: row; + align-items: center; + justify-content: center; + } + + .account__badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.4rem; + height: 2.4rem; + line-height: 2.4rem; + font-size: 1rem; + font-weight: 600; + color: var(--color-foreground--65); + background-color: var(--vscode-toolbar-hoverBackground); + border-radius: 50%; + } + + .repo-access { + font-size: 1.1em; + margin-right: 0.2rem; + } + .repo-access:not(.is-pro) { + filter: grayscale(1) brightness(0.7); + } + + .special { + margin-top: 0.8rem; + text-align: center; + } + + .special-dim { + font-size: smaller; + opacity: 0.6; + } + `, + ]; + + @property() + image = ''; + + @property({ type: Number }) + organizationsCount = 0; + + @property({ attribute: false }) + subscription?: Subscription; + + private get daysRemaining() { + if (this.subscription == null) return 0; + + return getSubscriptionTimeRemaining(this.subscription, 'days') ?? 0; + } + + get hasAccount() { + return hasAccountFromSubscriptionState(this.state); + } + + get isReactivatedTrial() { + return ( + this.state === SubscriptionState.FreePlusInTrial && + (this.subscription?.plan.effective.trialReactivationCount ?? 0) > 0 + ); + } + + private get planId() { + return this.subscription?.plan.actual.id ?? SubscriptionPlanId.Pro; + } + + get planName() { + switch (this.state) { + case SubscriptionState.Free: + case SubscriptionState.FreePreviewTrialExpired: + case SubscriptionState.FreePlusTrialExpired: + case SubscriptionState.FreePlusTrialReactivationEligible: + return 'GitKraken Free'; + case SubscriptionState.FreeInPreviewTrial: + case SubscriptionState.FreePlusInTrial: + return 'GitKraken Pro (Trial)'; + case SubscriptionState.VerificationRequired: + return `${getSubscriptionPlanName(this.planId)} (Unverified)`; + default: + return getSubscriptionPlanName(this.planId); + } + } + + private get state() { + return this.subscription?.state; + } + + override render() { + return html`${this.renderAccountInfo()}${this.renderOrganization()}${this.renderAccountState()}`; + } + + private renderAccountInfo() { + if (!this.hasAccount) return nothing; + + return html` + + `; + } + + private renderOrganization() { + const organization = this.subscription?.activeOrganization?.name ?? ''; + if (!this.hasAccount || !organization) return nothing; + + return html` + + `; + } + + private renderAccountState() { + const promo = getApplicablePromo(this.state); + + switch (this.state) { + case SubscriptionState.Paid: + return html` + + Manage Account + Integrations + +

    + Your ${getSubscriptionPlanName(this.planId)} plan provides full access to all Pro features and + our DevEx platform, unleashing powerful Git visualization & + productivity capabilities everywhere you work: IDE, desktop, browser, and terminal. +

    + `; + + case SubscriptionState.VerificationRequired: + return html` +

    You must verify your email before you can access Pro features.

    + + Resend Email + + + + `; + + case SubscriptionState.FreePlusInTrial: { + const days = this.daysRemaining; + + return html` + ${this.isReactivatedTrial + ? html`

    + + See + what's new + in GitLens. +

    ` + : nothing} +

    + You have + ${days < 1 ? '<1 day' : pluralize('day', days, { infix: ' more ' })} left + in your Pro trial. Once your trial ends, you will only be able to use Pro features on + publicly-hosted repos. +

    + + Upgrade to Pro + + ${this.renderPromo(promo)} ${this.renderIncludesDevEx()} + `; + } + + case SubscriptionState.FreePlusTrialExpired: + return html` +

    Your Pro trial has ended. You can now only use Pro features on publicly-hosted repos.

    + + Upgrade to Pro + + ${this.renderPromo(promo)} ${this.renderIncludesDevEx()} + `; + + case SubscriptionState.FreePlusTrialReactivationEligible: + return html` +

    Reactivate your Pro trial and experience all the new Pro features — free for another 7 days!

    + + Reactivate Pro Trial + + ${this.renderIncludesDevEx()} + `; + + default: + return html` +

    + Sign up for access to Pro features and our + DevEx platform, or + sign in. +

    + + Sign Up + +

    Signing up starts your free 7-day Pro trial.

    + ${this.renderIncludesDevEx()} + `; + } + } + + private renderIncludesDevEx() { + return html` +

    + Includes access to our + DevEx platform, unleashing powerful Git visualization & productivity + capabilities everywhere you work: IDE, desktop, browser, and terminal. +

    + `; + } + + private renderPromo(promo: Promo | undefined) { + return html``; + } +} diff --git a/src/webviews/apps/plus/focus/components/branch-tag.css.ts b/src/webviews/apps/plus/focus/components/branch-tag.css.ts new file mode 100644 index 0000000000000..74e75105412a2 --- /dev/null +++ b/src/webviews/apps/plus/focus/components/branch-tag.css.ts @@ -0,0 +1,20 @@ +import { css } from 'lit'; + +export const repoBranchStyles = css` + .repo-branch { + display: flex; + flex-direction: column; + gap: 0 0.4rem; + } + + @media (max-width: 720px) { + .repo-branch { + flex-direction: row; + flex-wrap: wrap; + } + } + + .repo-branch__tag { + cursor: pointer; + } +`; diff --git a/src/webviews/apps/plus/focus/components/common.css.ts b/src/webviews/apps/plus/focus/components/common.css.ts new file mode 100644 index 0000000000000..b72f504d74f8a --- /dev/null +++ b/src/webviews/apps/plus/focus/components/common.css.ts @@ -0,0 +1,141 @@ +import { css } from 'lit'; + +export const rowBaseStyles = css` + :host { + display: block; + } + + p { + margin: 0; + } + + a { + color: var(--vscode-textLink-foreground); + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + a:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .actions gk-tooltip { + display: inline-block; + } + + .actions a { + box-sizing: border-box; + display: inline-flex; + justify-content: center; + align-items: center; + width: 3.2rem; + height: 3.2rem; + border-radius: 0.5rem; + color: inherit; + padding: 0.2rem; + vertical-align: text-bottom; + text-decoration: none; + cursor: pointer; + } + .actions a:hover { + background-color: var(--vscode-toolbar-hoverBackground); + } + .actions a:active { + background-color: var(--vscode-toolbar-activeBackground); + } + .actions a[tabindex='-1'] { + opacity: 0.5; + cursor: default; + } + + .actions a code-icon { + font-size: 1.6rem; + } + + .indicator-info { + color: var(--vscode-problemsInfoIcon-foreground); + } + .indicator-warning { + color: var(--vscode-problemsWarningIcon-foreground); + } + .indicator-error { + color: var(--vscode-problemsErrorIcon-foreground); + } + .indicator-neutral { + color: var(--color-alert-neutralBorder); + } + + .row-type { + --gk-badge-outline-padding: 0.3rem 0.8rem; + --gk-badge-font-size: 1.1rem; + opacity: 0.4; + vertical-align: middle; + } + + .title { + font-size: 1.4rem; + } + + .add-delete { + margin-left: 0.4rem; + margin-right: 0.2rem; + } + + .key { + z-index: 1; + position: relative; + } + + .date { + display: inline-block; + min-width: 1.6rem; + line-height: 2.4rem; + } + + gk-focus-row:not(:hover):not(:focus-within) gl-snooze:not([snoozed]), + gk-focus-row:not(:hover):not(:focus-within) .pin:not(.is-active) { + opacity: 0; + } +`; + +export const pinStyles = css` + .icon { + box-sizing: border-box; + display: inline-flex; + justify-content: center; + align-items: center; + width: 2.4rem; + height: 2.4rem; + } + + .pin { + color: inherit; + text-decoration: none; + cursor: pointer; + opacity: 0.4; + } + + .pin:hover { + opacity: 0.64; + text-decoration: none; + } + + .pin:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .pin.is-active { + opacity: 1; + } + + .pin-menu { + width: max-content; + } + + gk-tooltip gk-menu { + z-index: 10; + } +`; diff --git a/src/webviews/apps/plus/focus/components/date-styles.css.ts b/src/webviews/apps/plus/focus/components/date-styles.css.ts new file mode 100644 index 0000000000000..5d1a941a520d6 --- /dev/null +++ b/src/webviews/apps/plus/focus/components/date-styles.css.ts @@ -0,0 +1,10 @@ +import { css } from 'lit'; + +export const dateAgeStyles = css` + .indicator-warning { + color: var(--vscode-problemsWarningIcon-foreground); + } + .indicator-danger { + color: var(--vscode-problemsErrorIcon-foreground); + } +`; diff --git a/src/webviews/apps/plus/focus/components/focus-app.ts b/src/webviews/apps/plus/focus/components/focus-app.ts new file mode 100644 index 0000000000000..4a1f77e0764a6 --- /dev/null +++ b/src/webviews/apps/plus/focus/components/focus-app.ts @@ -0,0 +1,442 @@ +import { + Badge, + Button, + defineGkElement, + FocusContainer, + Input, + Menu, + MenuItem, + Popover, +} from '@gitkraken/shared-web-components'; +import { html, LitElement } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { map } from 'lit/directives/map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { when } from 'lit/directives/when.js'; +import type { Source } from '../../../../../constants.telemetry'; +import type { State } from '../../../../../plus/webviews/focus/protocol'; +import { debounce } from '../../../../../system/function'; +import { themeProperties } from './gk-theme.css'; +import '../../../shared/components/button'; +import '../../../shared/components/code-icon'; +import '../../../shared/components/feature-gate'; +import '../../../shared/components/feature-badge'; +import './gk-pull-request-row'; +import './gk-issue-row'; + +@customElement('gl-focus-app') +export class GlFocusApp extends LitElement { + static override styles = [themeProperties]; + private readonly tabFilters = ['prs', 'issues', 'snoozed']; + private readonly tabFilterOptions = [ + { label: 'Pull Requests', value: 'prs' }, + { label: 'Issues', value: 'issues' }, + { label: 'All', value: '' }, + { label: 'Snoozed', value: 'snoozed' }, + ]; + private readonly mineFilters = ['authored', 'assigned', 'review-requested', 'mentioned']; + private readonly mineFilterOptions = [ + { label: 'Mine', value: '' }, + { label: 'Opened by Me', value: 'authored' }, + { label: 'Assigned to Me', value: 'assigned' }, + { label: 'Needs my Review', value: 'review-requested' }, + { label: 'Mentions Me', value: 'mentioned' }, + ]; + + @state() + private selectedTabFilter?: string = 'prs'; + + @state() + private selectedMineFilter?: string; + + @state() + private searchText?: string; + + @property({ type: Object }) + state?: State; + + constructor() { + super(); + + defineGkElement(Button, Badge, Input, FocusContainer, Popover, Menu, MenuItem); + } + + get subscription() { + return this.state?.access.subscription?.current; + } + + get showSubscriptionGate() { + return this.state?.access.allowed === false; + } + + get showFeatureGate() { + return this.state?.access.allowed !== true; + } + + get showConnectionGate() { + return this.state?.access.allowed === true && !(this.state?.repos?.some(r => r.isConnected) ?? false); + } + + get mineFilterMenuLabel() { + if (this.selectedMineFilter != null && this.selectedMineFilter !== '') { + return this.mineFilterOptions.find(f => f.value === this.selectedMineFilter)?.label; + } + + return this.mineFilterOptions[0].label; + } + + get items() { + if (this.isLoading) { + return []; + } + + const items: { + isPullrequest: boolean; + rank: number; + state: Record; + tags: string[]; + isPinned?: string; + isSnoozed?: string; + }[] = []; + + this.state?.pullRequests?.forEach( + ({ + pullRequest, + reasons, + isCurrentBranch, + isCurrentWorktree, + hasWorktree, + hasLocalBranch, + rank, + enriched, + }) => { + const isPinned = enriched?.find(item => item.type === 'pin')?.id; + const isSnoozed = enriched?.find(item => item.type === 'snooze')?.id; + + items.push({ + isPullrequest: true, + state: { + pullRequest: pullRequest, + isCurrentBranch: isCurrentBranch, + isCurrentWorktree: isCurrentWorktree, + hasWorktree: hasWorktree, + hasLocalBranch: hasLocalBranch, + }, + rank: rank ?? 0, + tags: reasons, + isPinned: isPinned, + isSnoozed: isSnoozed, + }); + }, + ); + this.state?.issues?.forEach(({ issue, reasons, rank, enriched }) => { + const isPinned = enriched?.find(item => item.type === 'pin')?.id; + const isSnoozed = enriched?.find(item => item.type === 'snooze')?.id; + + items.push({ + isPullrequest: false, + rank: rank ?? 0, + state: { + issue: issue, + }, + tags: reasons, + isPinned: isPinned, + isSnoozed: isSnoozed, + }); + }); + + return items; + } + + get tabFilterOptionsWithCounts() { + const counts: Record = {}; + this.tabFilters.forEach(f => (counts[f] = 0)); + + this.items.forEach(({ isPullrequest, isSnoozed }) => { + const key = isSnoozed ? 'snoozed' : isPullrequest ? 'prs' : 'issues'; + if (counts[key] != null) { + counts[key]++; + } + }); + + return this.tabFilterOptions.map(o => { + return { + ...o, + count: o.value === '' ? this.items.length : counts[o.value], + }; + }); + } + + get filteredItems() { + if (this.items.length === 0) { + return this.items; + } + + const hasSearch = this.searchText != null && this.searchText !== ''; + const hasMineFilter = this.selectedMineFilter != null && this.selectedMineFilter !== ''; + const hasTabFilter = this.selectedTabFilter != null && this.selectedTabFilter !== ''; + if (!hasSearch && !hasMineFilter && !hasTabFilter) { + return this.items.filter(i => i.isSnoozed == null); + } + + const searchText = this.searchText?.toLowerCase(); + return this.items.filter(i => { + if (hasTabFilter) { + if ( + (i.isSnoozed != null && this.selectedTabFilter !== 'snoozed') || + (i.isSnoozed == null && this.selectedTabFilter == 'snoozed') || + (i.isPullrequest === true && this.selectedTabFilter === 'issues') || + (i.isPullrequest === false && this.selectedTabFilter === 'prs') + ) { + return false; + } + } else if (i.isSnoozed != null) { + return false; + } + + if (hasMineFilter && !i.tags.includes(this.selectedMineFilter!)) { + return false; + } + + if (hasSearch) { + if (i.state.issue && !i.state.issue.title.toLowerCase().includes(searchText)) { + return false; + } + + if (i.state.pullRequest && !i.state.pullRequest.title.toLowerCase().includes(searchText)) { + return false; + } + } + + return true; + }); + } + + get sortedItems() { + return this.filteredItems.sort((a, b) => { + if (a.isPinned === b.isPinned) { + return 0; + // return a.rank - b.rank; + } + return a.isPinned ? -1 : 1; + }); + } + + get isLoading() { + return this.state?.pullRequests == null || this.state?.issues == null; + } + + loadingContent() { + return html` +
    + Loading +
    + `; + } + + focusItemsContent() { + if (this.isLoading) { + return this.loadingContent(); + } + + if (this.sortedItems.length === 0) { + return html` +
    + None found +
    + `; + } + + return html` + ${repeat( + this.sortedItems, + (item, i) => + `item-${i}-${ + item.isPullrequest ? `pr-${item.state.pullRequest.id}` : `issue-${item.state.issue.id}` + }`, + ({ isPullrequest, rank, state, isPinned, isSnoozed }) => + when( + isPullrequest, + () => + html``, + () => + html``, + ), + )} + `; + } + + override render() { + if (this.state == null) { + return this.loadingContent(); + } + + return html` +
    +
    + + + +
    + +
    +

    + Launchpad + + — effortlessly view all of your GitHub pull requests and issues in a unified, + actionable view. +

    + +

    No GitHub remotes are connected

    +

    + This enables access to Pull Requests and Issues as well as provide additional information + inside hovers and the Inspect view, such as auto-linked issues and pull requests and + avatars. +

    + Connect to GitHub +
    + +
    +
    +
    + + + ${this.mineFilterMenuLabel} + + + ${map( + this.mineFilterOptions, + ({ label, value }, i) => html` + ${label} + `, + )} + + +
    +
    + + + +
    +
    +
    + + + + + + + Repo / Branch + ${this.focusItemsContent()} + +
    +
    +
    +
    + `; + } + + onSearchInput(e: Event) { + const input = e.target as HTMLInputElement; + const value = input.value; + + if (value === '' || value.length < 3) { + this.searchText = undefined; + return; + } + + this.searchText = value; + } + + onSelectMineFilter(e: CustomEvent<{ target: MenuItem }>) { + const target = e.detail?.target; + if (target?.dataset?.value != null) { + this.selectedMineFilter = target.dataset.value; + + const menuEl: Popover | null = target.closest('gk-popover'); + menuEl?.hidePopover(); + } + } + + protected override createRenderRoot() { + return this; + } +} diff --git a/src/webviews/apps/plus/focus/components/git-avatars.ts b/src/webviews/apps/plus/focus/components/git-avatars.ts deleted file mode 100644 index c9a90f43ea855..0000000000000 --- a/src/webviews/apps/plus/focus/components/git-avatars.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { css, customElement, FASTElement, html, observable, repeat, volatile, when } from '@microsoft/fast-element'; - -import '../../../shared/components/avatars/avatar-item'; -import '../../../shared/components/avatars/avatar-stack'; - -const template = html` - - ${repeat( - x => x.avatarItems, - html``, - )} - ${when( - x => x.avatarPlusItems != null, - html`+${x => x.avatarPlusItems?.length}`, - )} - -`; - -const styles = css``; - -interface AvatarShape { - name: string; - avatarUrl: string; - url: string; -} - -@customElement({ - name: 'git-avatars', - template: template, - styles: styles, -}) -export class GitAvatars extends FASTElement { - @observable - avatars: AvatarShape[] = []; - - @volatile - get avatarItems(): AvatarShape[] { - if (this.avatars.length <= 3) { - return this.avatars; - } - return this.avatars.slice(0, 2); - } - - @volatile - get avatarPlusItems(): AvatarShape[] | undefined { - const len = this.avatars.length; - if (len <= 3) { - return undefined; - } - return this.avatars.slice(2); - } - - @volatile - get avatarPlusLabel(): string | undefined { - if (this.avatarPlusItems == null) { - return undefined; - } - const len = this.avatarPlusItems.length; - return this.avatarPlusItems.reduce( - (all, current, i) => `${all}, ${len === i - 1 ? 'and ' : ''}${current.name}`, - '', - ); - } -} diff --git a/src/webviews/apps/plus/focus/components/gk-issue-row.ts b/src/webviews/apps/plus/focus/components/gk-issue-row.ts new file mode 100644 index 0000000000000..d4c6a6b0f6ffa --- /dev/null +++ b/src/webviews/apps/plus/focus/components/gk-issue-row.ts @@ -0,0 +1,183 @@ +import { + Avatar, + AvatarGroup, + defineGkElement, + FocusItem, + FocusRow, + RelativeDate, + Tag, + Tooltip, +} from '@gitkraken/shared-web-components'; +import { css, html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { when } from 'lit/directives/when.js'; +import type { IssueMember, IssueShape } from '../../../../../git/models/issue'; +import { elementBase } from '../../../shared/components/styles/lit/base.css'; +import { repoBranchStyles } from './branch-tag.css'; +import { pinStyles, rowBaseStyles } from './common.css'; +import { dateAgeStyles } from './date-styles.css'; +import { themeProperties } from './gk-theme.css'; +import { fromDateRange } from './helpers'; +import './snooze'; + +@customElement('gk-issue-row') +export class GkIssueRow extends LitElement { + static override styles = [ + themeProperties, + elementBase, + dateAgeStyles, + repoBranchStyles, + pinStyles, + rowBaseStyles, + css``, + ]; + + @property({ type: Number }) + public rank?: number; + + @property({ type: Object }) + public issue?: IssueShape; + + @property() + public pinned?: string; + + @property() + public snoozed?: string; + + constructor() { + super(); + + // Tooltip typing isn't being properly recognized as `typeof GkElement` + defineGkElement(Tag, FocusRow, FocusItem, AvatarGroup, Avatar, RelativeDate, Tooltip as any); + } + + get lastUpdatedDate() { + return new Date(this.issue!.updatedDate); + } + + get dateStyle() { + return `indicator-${fromDateRange(this.lastUpdatedDate).status}`; + } + + get assignees() { + const assignees = this.issue?.assignees; + if (assignees == null) { + return []; + } + const author: IssueMember | undefined = this.issue!.author; + if (author != null) { + return assignees.filter(assignee => assignee.avatarUrl !== author.avatarUrl); + } + + return assignees; + } + + override render() { + if (!this.issue) return undefined; + + return html` + + + + + ${this.pinned ? 'Unpin' : 'Pin'} + + + + + + + + +

    + ${this.issue.title} #${this.issue.id} + +

    +

    + Issue + + + ${this.issue.commentsCount} Comments + + + ${this.issue.thumbsUpCount} Thumbs Up +

    + + + ${when( + this.issue.author != null, + () => + html``, + )} + ${when( + this.assignees.length > 0, + () => html` + ${repeat( + this.assignees, + item => item.url, + item => + html``, + )} + `, + )} + + +
    + + + ${this.issue.repository?.repo} + +
    + +
    +
    + `; + } + + onSnoozeAction(e: CustomEvent<{ expiresAt: never; snooze: string } | { expiresAt?: string; snooze: never }>) { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent('snooze-item', { + detail: { + item: this.issue!, + expiresAt: e.detail.expiresAt, + snooze: this.snoozed, + }, + }), + ); + } + + onPinClick(e: Event) { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent('pin-item', { + detail: { item: this.issue!, pin: this.pinned }, + }), + ); + } +} diff --git a/src/webviews/apps/plus/focus/components/gk-pull-request-row.ts b/src/webviews/apps/plus/focus/components/gk-pull-request-row.ts new file mode 100644 index 0000000000000..6eaa026b801c8 --- /dev/null +++ b/src/webviews/apps/plus/focus/components/gk-pull-request-row.ts @@ -0,0 +1,329 @@ +import { + AdditionsDeletions, + Avatar, + AvatarGroup, + defineGkElement, + FocusItem, + FocusRow, + RelativeDate, + Tag, + Tooltip, +} from '@gitkraken/shared-web-components'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { when } from 'lit/directives/when.js'; +import type { PullRequestMember, PullRequestShape } from '../../../../../git/models/pullRequest'; +import { elementBase } from '../../../shared/components/styles/lit/base.css'; +import { repoBranchStyles } from './branch-tag.css'; +import { pinStyles, rowBaseStyles } from './common.css'; +import { dateAgeStyles } from './date-styles.css'; +import { themeProperties } from './gk-theme.css'; +import { fromDateRange } from './helpers'; +import './snooze'; + +@customElement('gk-pull-request-row') +export class GkPullRequestRow extends LitElement { + static override styles = [ + themeProperties, + elementBase, + dateAgeStyles, + repoBranchStyles, + pinStyles, + rowBaseStyles, + css``, + ]; + + @property({ type: Number }) + public rank?: number; + + @property({ type: Object }) + public pullRequest?: PullRequestShape; + + @property({ type: Boolean }) + public isCurrentBranch = false; + + @property({ type: Boolean }) + public isCurrentWorktree = false; + + @property({ type: Boolean }) + public hasWorktree = false; + + @property({ type: Boolean }) + public hasLocalBranch = false; + + @property() + public pinned?: string; + + @property() + public snoozed?: string; + + constructor() { + super(); + + // Tooltip typing isn't being properly recognized as `typeof GkElement` + defineGkElement( + Tag, + FocusRow, + FocusItem, + AvatarGroup, + Avatar, + RelativeDate, + AdditionsDeletions, + Tooltip as any, + ); + } + + get lastUpdatedDate() { + return new Date(this.pullRequest!.updatedDate); + } + + get assignees() { + const assignees = this.pullRequest?.assignees; + if (assignees == null) { + return []; + } + const author: PullRequestMember | undefined = this.pullRequest!.author; + if (author != null) { + return assignees.filter(assignee => assignee.name !== author.name); + } + + return assignees; + } + + get indicator() { + if (this.pullRequest == null) return ''; + + if (this.pullRequest.reviewDecision === 'ChangesRequested') { + return 'changes'; + } else if (this.pullRequest.reviewDecision === 'Approved' && this.pullRequest.mergeableState === 'Mergeable') { + return 'ready'; + } else if (this.pullRequest.mergeableState === 'Conflicting') { + return 'conflicting'; + } + + return ''; + } + + get dateStyle() { + return `indicator-${fromDateRange(this.lastUpdatedDate).status}`; + } + + get participants() { + const participants: { member: PullRequestMember; roles: string[] }[] = []; + function addMember(member: PullRequestMember, role: string) { + const participant = participants.find(p => p.member.name === member.name); + if (participant != null) { + participant.roles.push(role); + } else { + participants.push({ member: member, roles: [role] }); + } + } + + if (this.pullRequest?.author != null) { + addMember(this.pullRequest.author, 'author'); + } + + if (this.pullRequest?.assignees != null) { + this.pullRequest.assignees.forEach(m => addMember(m, 'assigned')); + } + + if (this.pullRequest?.reviewRequests != null) { + this.pullRequest.reviewRequests.forEach(m => addMember(m.reviewer, 'reviewer')); + } + + return participants; + } + + override render() { + if (!this.pullRequest) return undefined; + + return html` + + + + + ${this.pinned ? 'Unpin' : 'Pin'} + + + + + + + + ${when( + this.indicator === 'changes', + () => + html` + + changes requested + `, + )} + ${when( + this.indicator === 'ready', + () => + html` + + approved and ready to merge + `, + )} + ${when( + this.indicator === 'conflicting', + () => + html` + + cannot be merged due to merge conflicts + `, + )} + + +

    + ${this.pullRequest.title} + #${this.pullRequest.id} + +

    +

    + PR + + ${this.pullRequest.additions} + ${this.pullRequest.deletions} + + + + + ${this.pullRequest.commentsCount} + + Comments + +

    + + + ${when( + this.participants.length > 0, + () => html` + ${repeat( + this.participants, + item => item.member.url, + item => + html``, + )} + `, + )} + + +
    + + + ${this.pullRequest.refs?.isCrossRepository === true + ? html`${this.pullRequest.refs?.head.owner}:${this.pullRequest.refs?.head.branch}` + : this.pullRequest.refs?.head.branch} + + + + ${this.pullRequest.refs?.base.repo} + +
    + +
    +
    + `; + } + + onOpenBranchClick(_e: Event) { + this.dispatchEvent(new CustomEvent('open-branch', { detail: this.pullRequest! })); + } + + onOpenWorktreeClick(e: Event) { + if (this.isCurrentWorktree) { + e.preventDefault(); + e.stopImmediatePropagation(); + return; + } + this.dispatchEvent(new CustomEvent('open-worktree', { detail: this.pullRequest! })); + } + + onSwitchBranchClick(e: Event) { + if (this.isCurrentBranch || this.hasWorktree) { + e.preventDefault(); + e.stopImmediatePropagation(); + return; + } + this.dispatchEvent(new CustomEvent('switch-branch', { detail: this.pullRequest! })); + } + + onSnoozeAction(e: CustomEvent<{ expiresAt: never; snooze: string } | { expiresAt?: string; snooze: never }>) { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent('snooze-item', { + detail: { + item: this.pullRequest!, + expiresAt: e.detail.expiresAt, + snooze: this.snoozed, + }, + }), + ); + } + + onPinClick(e: Event) { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent('pin-item', { + detail: { item: this.pullRequest!, pin: this.pinned }, + }), + ); + } +} diff --git a/src/webviews/apps/plus/focus/components/gk-theme.css.ts b/src/webviews/apps/plus/focus/components/gk-theme.css.ts new file mode 100644 index 0000000000000..237f071ec1e15 --- /dev/null +++ b/src/webviews/apps/plus/focus/components/gk-theme.css.ts @@ -0,0 +1,32 @@ +import { css } from 'lit'; + +export const themeProperties = css` + :host { + --focus-color: var(--vscode-focusBorder); + --gk-focus-border-color: var(--focus-color); + + --gk-additions-color: var(--vscode-gitDecoration-addedResourceForeground); + --gk-deletions-color: var(--vscode-gitDecoration-deletedResourceForeground); + + --gk-avatar-background-color: var(--background-10); + --gk-tag-background-color: var(--background-10); + --gk-text-secondary-color: var(--color-foreground--85); + + --gk-menu-border-color: var(--vscode-menu-border); + --gk-menu-background-color: var(--vscode-menu-background); + --gk-menu-item-background-color-hover: var(--vscode-menu-selectionBackground); + --gk-menu-item-background-color-active: var(--vscode-menu-background); + + --gk-button-ghost-color: var(--color-foreground); + --gk-button-ghost-color-active: var(--color-foreground--85); + --gk-button-ghost-color-disabled: var(--color-foreground--50); + --gk-button-outline-color: var(--color-foreground); + --gk-button-outline-color-active: var(--background-10); + --gk-button-outline-color-disabled: var(--color-foreground--50); + + --gk-tooltip-background-color: var(--popover-bg); // var(--vscode-editorHoverWidget-background); + --gk-tooltip-font-color: var(--color-foreground); // var(--vscode-editorHoverWidget-foreground); + --gk-tooltip-font-weight: normal; + --gk-tooltip-font-size: 1.2rem; + } +`; diff --git a/src/webviews/apps/plus/focus/components/issue-row.ts b/src/webviews/apps/plus/focus/components/issue-row.ts deleted file mode 100644 index 213392ca0c204..0000000000000 --- a/src/webviews/apps/plus/focus/components/issue-row.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { css, customElement, FASTElement, html, observable, volatile, when } from '@microsoft/fast-element'; -import type { IssueShape } from '../../../../../git/models/issue'; -import { fromNow } from '../../../../../system/date'; -import { focusOutline, srOnly } from '../../../shared/components/styles/a11y'; -import { elementBase } from '../../../shared/components/styles/base'; -import { fromDateRange } from './helpers'; -import '../../../shared/components/table/table-cell'; -import '../../../shared/components/avatars/avatar-item'; -import '../../../shared/components/avatars/avatar-stack'; -import '../../../shared/components/code-icon'; -import './git-avatars'; - -const template = html` - -`; - -const styles = css` - ${elementBase} - - :host { - display: table-row; - } - - :host(:focus) { - ${focusOutline} - } - - a { - color: var(--vscode-textLink-foreground); - text-decoration: none; - } - - a:hover { - color: var(--vscode-textLink-activeForeground); - text-decoration: underline; - } - - a:focus { - ${focusOutline} - } - - code-icon { - font-size: inherit; - } - - .tag { - display: inline-block; - padding: 0.1rem 0.2rem; - background-color: var(--background-05); - color: var(--color-foreground--85); - white-space: nowrap; - } - .tag code-icon { - margin-right: 0.2rem; - } - - .status { - font-size: 1.6rem; - } - - .time { - } - - .icon-only { - } - - .stats { - } - - .actions { - text-align: right; - } - - .stat-added { - color: var(--vscode-gitDecoration-addedResourceForeground); - } - .stat-deleted { - color: var(--vscode-gitDecoration-deletedResourceForeground); - } - - .issue-open { - color: var(--vscode-gitlens-openAutolinkedIssueIconColor); - } - .issue-closed { - color: var(--vscode-gitlens-closedAutolinkedIssueIconColor); - } - - .indicator-info { - color: var(--vscode-problemsInfoIcon-foreground); - } - .indicator-warning { - color: var(--vscode-problemsWarningIcon-foreground); - } - .indicator-error { - color: var(--vscode-problemsErrorIcon-foreground); - } - .indicator-neutral { - color: var(--color-alert-neutralBorder); - } - - .pull-request-draft { - /* color: var(--vscode-pullRequests-draft); */ - color: var(--color-foreground--85); - } - .pull-request-open { - color: var(--vscode-gitlens-openPullRequestIconColor); - } - .pull-request-merged { - color: var(--vscode-gitlens-mergedPullRequestIconColor); - } - .pull-request-closed { - color: var(--vscode-gitlens-closedPullRequestIconColor); - } - .pull-request-notification { - color: var(--vscode-pullRequests-notification); - } - - ${srOnly} -`; - -@customElement({ - name: 'issue-row', - template: template, - styles: styles, -}) -export class IssueRow extends FASTElement { - @observable - public issue?: IssueShape; - - @observable - public reasons?: string[]; - - @volatile - get lastUpdatedDate() { - return new Date(this.issue!.date); - } - - @volatile - get lastUpdatedState() { - return fromDateRange(this.lastUpdatedDate); - } - - @volatile - get lastUpdated() { - return fromNow(this.lastUpdatedDate, true); - } - - @volatile - get lastUpdatedLabel() { - return fromNow(this.lastUpdatedDate); - } - - @volatile - get lastUpdatedClass() { - switch (this.lastUpdatedState.status) { - case 'danger': - return 'indicator-error'; - case 'warning': - return 'indicator-warning'; - default: - return ''; - } - } - - @volatile - get indicator() { - return ''; - } - - @volatile - get indicatorLabel() { - return undefined; - } -} diff --git a/src/webviews/apps/plus/focus/components/plus-content.ts b/src/webviews/apps/plus/focus/components/plus-content.ts deleted file mode 100644 index c5bf13211b67e..0000000000000 --- a/src/webviews/apps/plus/focus/components/plus-content.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { css, customElement, FASTElement, html, observable, volatile, when } from '@microsoft/fast-element'; -import type { Subscription } from '../../../../../subscription'; -import { SubscriptionState } from '../../../../../subscription'; -import { focusOutline } from '../../../shared/components/styles/a11y'; -import { elementBase } from '../../../shared/components/styles/base'; -import '../../../shared/components/code-icon'; - -const template = html` - ${when(x => x.state !== SubscriptionState.Free, html`
    `)} -
    - ${when( - x => x.state === SubscriptionState.Free, - html` - - -

    - Try the Focus View -

    - `, - )} - ${when( - x => x.state === SubscriptionState.FreePreviewTrialExpired, - html` -

    Extend Your GitLens Pro Trial

    -

    - Your free 3-day GitLens Pro trial has ended, extend your trial to get an additional free 7-days of - the Focus View and other GitLens+ features on private repos. -

    -

    - Extend Pro Trial -

    - `, - )} - ${when( - x => x.state === SubscriptionState.FreePlusTrialExpired, - html` -

    GitLens Pro Trial Expired

    -

    - Your GitLens Pro trial has ended, please upgrade to GitLens Pro to continue to use the Focus View - and other GitLens+ features on private repos. -

    -

    - Upgrade to Pro -

    - `, - )} - ${when( - x => x.state === SubscriptionState.VerificationRequired, - html` -

    Please verify your email

    -

    - Before you can also use the Focus View and other GitLens+ features on private repos, please verify - your email address. -

    -

    - Resend Verification Email -

    -

    - Refresh Verification Status -

    - `, - )} -
    - -
    -

    - All other - GitLens+ features - are free for local and public repos, no account required, while upgrading to GitLens Pro gives you access on - private repos. -

    -

    All other GitLens features can always be used on any repo.

    -
    -`; - -const styles = css` - ${elementBase} - - :host { - display: block; - /* text-align: center; */ - } - - :host(:focus) { - outline: none; - } - - a { - color: var(--vscode-textLink-foreground); - text-decoration: none; - } - - a:hover { - color: var(--vscode-textLink-activeForeground); - text-decoration: underline; - } - - a:focus { - ${focusOutline} - } - - h3, - p { - margin-top: 0; - } - - h3 a { - color: inherit; - text-decoration: underline; - text-decoration-color: var(--color-foreground--50); - } - - h3 a:hover { - text-decoration-color: inherit; - } - - .mb-1 { - margin-bottom: 0.6rem; - } - .mb-0 { - margin-bottom: 0; - } - - .main { - text-align: center; - margin: 3rem 0; - } - - .secondary { - font-size: 1.4rem; - } - - .divider { - display: block; - height: 0; - margin: 0.6rem; - border: none; - border-top: 0.1rem solid var(--vscode-menu-separatorBackground); - } -`; - -@customElement({ name: 'plus-content', template: template, styles: styles }) -export class PlusContent extends FASTElement { - @observable - subscription?: Subscription; - - @volatile - get state(): SubscriptionState { - return this.subscription?.state ?? SubscriptionState.Free; - } - - @volatile - get isPro() { - return ![ - SubscriptionState.Free, - SubscriptionState.FreePreviewTrialExpired, - SubscriptionState.FreePlusTrialExpired, - SubscriptionState.VerificationRequired, - ].includes(this.state); - } - - @volatile - get planName() { - const label = this.subscription?.plan.effective.name; - switch (this.state) { - case SubscriptionState.Free: - case SubscriptionState.FreePreviewTrialExpired: - case SubscriptionState.FreePlusTrialExpired: - return 'GitLens Free'; - case SubscriptionState.FreeInPreviewTrial: - case SubscriptionState.FreePlusInTrial: - return 'GitLens Pro (Trial)'; - case SubscriptionState.VerificationRequired: - return `${label} (Unverified)`; - default: - return label; - } - } - - fireAction(command: string) { - this.$emit('action', command); - } -} diff --git a/src/webviews/apps/plus/focus/components/pull-request-row.ts b/src/webviews/apps/plus/focus/components/pull-request-row.ts deleted file mode 100644 index 2d20da804525f..0000000000000 --- a/src/webviews/apps/plus/focus/components/pull-request-row.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { css, customElement, FASTElement, html, observable, volatile, when } from '@microsoft/fast-element'; -import type { PullRequestShape } from '../../../../../git/models/pullRequest'; -import { fromNow } from '../../../../../system/date'; -import { focusOutline, srOnly } from '../../../shared/components/styles/a11y'; -import { elementBase } from '../../../shared/components/styles/base'; -import { fromDateRange } from './helpers'; -import '../../../shared/components/table/table-cell'; -import '../../../shared/components/avatars/avatar-item'; -import '../../../shared/components/avatars/avatar-stack'; -import '../../../shared/components/code-icon'; -import './git-avatars'; - -const template = html` - -`; - -const styles = css` - ${elementBase} - - :host { - display: table-row; - } - - :host(:focus) { - ${focusOutline} - } - - a { - color: var(--vscode-textLink-foreground); - text-decoration: none; - } - - a:hover { - color: var(--vscode-textLink-activeForeground); - text-decoration: underline; - } - - a:focus { - ${focusOutline} - } - - code-icon { - font-size: inherit; - } - - .tag { - display: inline-block; - padding: 0.1rem 0.2rem; - background-color: var(--background-05); - color: var(--color-foreground--85); - white-space: nowrap; - } - .tag code-icon { - margin-right: 0.2rem; - } - - .status { - font-size: 1.6rem; - } - - .time { - } - - .icon-only { - } - - .stats { - } - - .actions { - text-align: right; - } - - .stat-added { - color: var(--vscode-gitDecoration-addedResourceForeground); - } - .stat-deleted { - color: var(--vscode-gitDecoration-deletedResourceForeground); - } - - .issue-open { - color: var(--vscode-gitlens-openAutolinkedIssueIconColor); - } - .issue-closed { - color: var(--vscode-gitlens-closedAutolinkedIssueIconColor); - } - - .indicator-info { - color: var(--vscode-problemsInfoIcon-foreground); - } - .indicator-warning { - color: var(--vscode-problemsWarningIcon-foreground); - } - .indicator-error { - color: var(--vscode-problemsErrorIcon-foreground); - } - .indicator-neutral { - color: var(--color-alert-neutralBorder); - } - - .pull-request-draft { - /* color: var(--vscode-pullRequests-draft); */ - color: var(--color-foreground--85); - } - .pull-request-open { - color: var(--vscode-gitlens-openPullRequestIconColor); - } - .pull-request-merged { - color: var(--vscode-gitlens-mergedPullRequestIconColor); - } - .pull-request-closed { - color: var(--vscode-gitlens-closedPullRequestIconColor); - } - .pull-request-notification { - color: var(--vscode-pullRequests-notification); - } - - ${srOnly} -`; - -@customElement({ - name: 'pull-request-row', - template: template, - styles: styles, -}) -export class PullRequestRow extends FASTElement { - @observable - public pullRequest?: PullRequestShape; - - @observable - public reasons?: string[]; - - @observable - public checks?: boolean; - - @volatile - get lastUpdatedDate() { - return new Date(this.pullRequest!.date); - } - - @volatile - get lastUpdatedState() { - return fromDateRange(this.lastUpdatedDate); - } - - @volatile - get lastUpdated() { - return fromNow(this.lastUpdatedDate, true); - } - - @volatile - get lastUpdatedLabel() { - return fromNow(this.lastUpdatedDate); - } - - @volatile - get lastUpdatedClass() { - switch (this.lastUpdatedState.status) { - case 'danger': - return 'indicator-error'; - case 'warning': - return 'indicator-warning'; - default: - return ''; - } - } - - @volatile - get indicator() { - if (this.pullRequest == null) return ''; - - console.log(this.pullRequest); - if (this.checks === false) { - return 'checks'; - } else if (this.pullRequest.reviewDecision === 'ChangesRequested') { - return 'changes'; - } else if (this.pullRequest.reviewDecision === 'Approved' && this.pullRequest.mergeableState === 'Mergeable') { - return 'ready'; - } - - if (this.pullRequest.mergeableState === 'Conflicting') { - return 'conflicting'; - } - - return ''; - } - - @volatile - get indicatorLabel() { - return undefined; - } -} diff --git a/src/webviews/apps/plus/focus/components/snooze.ts b/src/webviews/apps/plus/focus/components/snooze.ts new file mode 100644 index 0000000000000..5212de3063875 --- /dev/null +++ b/src/webviews/apps/plus/focus/components/snooze.ts @@ -0,0 +1,98 @@ +import { defineGkElement, Menu, MenuItem, Popover, Tooltip } from '@gitkraken/shared-web-components'; +import { html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { pinStyles } from './common.css'; +import { themeProperties } from './gk-theme.css'; + +const HOUR = 60 * 60 * 1000; + +@customElement('gl-snooze') +class GlSnooze extends LitElement { + static override styles = [themeProperties, pinStyles]; + + @property({ reflect: true }) + public snoozed?: string; + + constructor() { + super(); + + defineGkElement(Menu, MenuItem, Popover, Tooltip); + } + + override render() { + if (this.snoozed) { + return html` + + + Unsnooze + + `; + } + + return html` + + + + Snooze + Snooze for 1 hour + Snooze for 4 hours + Snooze until tomorrow at 9:00 AM + + + `; + } + + private onSnoozeActionCore(expiresAt?: string) { + this.dispatchEvent( + new CustomEvent('gl-snooze-action', { + detail: { expiresAt: expiresAt, snooze: this.snoozed }, + }), + ); + } + + onUnsnoozeClick(e: Event) { + e.preventDefault(); + this.onSnoozeActionCore(); + } + + onSelectDuration(e: CustomEvent<{ target: MenuItem }>) { + e.preventDefault(); + const duration = e.detail.target.dataset.value; + if (!duration) return; + + if (duration === 'unlimited') { + this.onSnoozeActionCore(); + return; + } + + const now = new Date(); + let nowTime = now.getTime(); + switch (duration) { + case '1hr': + nowTime += HOUR; + break; + case '4hr': + nowTime += HOUR * 4; + break; + case 'tomorrow-9am': + now.setDate(now.getDate() + 1); + now.setHours(9, 0, 0, 0); + nowTime = now.getTime(); + break; + } + + this.onSnoozeActionCore(new Date(nowTime).toISOString()); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gl-snooze': GlSnooze; + } + + interface HTMLElementEventMap { + 'gl-snooze-action': CustomEvent<{ expiresAt: never; snooze: string } | { expiresAt?: string; snooze: never }>; + } +} diff --git a/src/webviews/apps/plus/focus/components/workspace-item.ts b/src/webviews/apps/plus/focus/components/workspace-item.ts deleted file mode 100644 index 88faba29f8b45..0000000000000 --- a/src/webviews/apps/plus/focus/components/workspace-item.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { css, customElement, FASTElement, html, ref } from '@microsoft/fast-element'; -import { focusOutline, srOnly } from '../../../shared/components/styles/a11y'; -import { elementBase } from '../../../shared/components/styles/base'; - -import '../../../shared/components/table/table-cell'; - -const template = html` - -`; - -const styles = css` - ${elementBase} - - :host { - display: table-row; - cursor: pointer; - } - - :host(:focus) { - ${focusOutline} - } - - .actions { - text-align: right; - } - - ${srOnly} -`; - -@customElement({ name: 'workspace-item', template: template, styles: styles, shadowOptions: { delegatesFocus: true } }) -export class WorkspaceItem extends FASTElement { - actions!: HTMLElement; - count!: HTMLElement; - shared!: HTMLElement; - - selectRow(e: Event) { - const path = e.composedPath(); - // exclude events triggered from a slot with actions - if ([this.actions, this.count, this.shared].find(el => path.indexOf(el) > 0) !== undefined) { - return; - } - - console.log('WorkspaceItem.selectRow', e, path); - this.$emit('selected'); - } -} diff --git a/src/webviews/apps/plus/focus/components/workspace-list.ts b/src/webviews/apps/plus/focus/components/workspace-list.ts deleted file mode 100644 index 2c50b06bae0ae..0000000000000 --- a/src/webviews/apps/plus/focus/components/workspace-list.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { css, customElement, FASTElement, html } from '@microsoft/fast-element'; -import { srOnly } from '../../../shared/components/styles/a11y'; -import { elementBase } from '../../../shared/components/styles/base'; - -import '../../../shared/components/table/table-container'; -import '../../../shared/components/table/table-row'; -import '../../../shared/components/table/table-cell'; - -const template = html` - - - Row selection - Workspace - Description - # of repos - Latest update - Shared with - Owner - Workspace actions - - - - - No workspaces - - - - - - - - - -`; - -const styles = css` - ${elementBase} - - .row { - display: table-row; - } - - ${srOnly} -`; - -@customElement({ name: 'workspace-list', template: template, styles: styles }) -export class WorkspaceList extends FASTElement {} diff --git a/src/webviews/apps/plus/focus/focus.html b/src/webviews/apps/plus/focus/focus.html index 1e487ca325840..0352a17b1501f 100644 --- a/src/webviews/apps/plus/focus/focus.html +++ b/src/webviews/apps/plus/focus/focus.html @@ -1,185 +1,31 @@ - + - - - - - -
    -
    -
    -
    -
    -

    My Pull Requests

    - -
    -
    - - - - - - - Pull Request - Author - Assigned - - - - - - - - - - -
    - Loading -
    -
    - No pull requests found -
    -
    -
    -
    -
    -

    My Issues

    - -
    -
    - - - - - - - - - Title - Author - Assigned - - - - - - - - - -
    - Loading -
    - -
    -
    -
    -
    - - - - - - #{endOfBody} - - + + + + + #{endOfBody} diff --git a/src/webviews/apps/plus/focus/focus.scss b/src/webviews/apps/plus/focus/focus.scss index 669264f6d06aa..cfd337ae1447a 100644 --- a/src/webviews/apps/plus/focus/focus.scss +++ b/src/webviews/apps/plus/focus/focus.scss @@ -1,3 +1,5 @@ +@use '../../shared/styles/theme'; + @mixin focusStyles() { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; @@ -20,6 +22,25 @@ body { --table-text: var(--color-foreground--65); --table-pinned-background: var(--color-background); --layout-gutter-outer: 20px; + --gk-input-background-color: var(--vscode-input-background); + --gk-input-border-color: var(--vscode-input-border); + --gk-input-color: var(--vscode-input-foreground); + --gk-text-secondary-color: var(--color-foreground--65); + --gk-button-ghost-color: var(--color-foreground--50); + + --gk-menu-border-color: var(--vscode-menu-border); + --gk-menu-background-color: var(--vscode-menu-background); + --gk-menu-item-background-color-hover: var(--vscode-menu-selectionBackground); + --gk-menu-item-background-color-active: var(--vscode-menu-background); + --gk-focus-border-color: var(--focus-color); + --gk-badge-outline-color: var(--vscode-badge-foreground); + --gk-badge-filled-background-color: var(--vscode-badge-background); + --gk-badge-filled-color: var(--vscode-badge-foreground); + --gk-tooltip-padding: 0.4rem 0.8rem; + --gk-focus-background-color-hover: var(--background-05); + --gk-divider-color: var(--background-05); + --gk-focus-row-pin-min-width: 64px; + --gk-focus-item-repo-min-width: 150px; } .vscode-high-contrast, @@ -46,6 +67,18 @@ body { --popover-bg: var(--color-background--darken-15); } +@media (min-width: 1200px) { + body { + --gk-focus-item-repo-min-width: 240px; + } +} + +@media (min-width: 1400px) { + body { + --gk-focus-item-repo-min-width: 320px; + } +} + :root { font-size: 62.5%; font-family: var(--font-family); @@ -68,6 +101,22 @@ body { visibility: hidden; } +body[data-placement='editor'] { + background-color: var(--color-background); + + [data-placement-hidden='editor'], + [data-placement-visible]:not([data-placement-visible='editor']) { + display: none !important; + } +} + +body[data-placement='view'] { + [data-placement-hidden='view'], + [data-placement-visible]:not([data-placement-visible='view']) { + display: none !important; + } +} + [hidden] { display: none !important; } @@ -98,41 +147,8 @@ p { margin-top: 0; } -.tag { - display: inline-block; - padding: 0.1rem 0.2rem; - background-color: var(--color-background--lighten-05); - color: var(--color-foreground--85); - - code-icon { - margin-right: 0.2rem; - } -} - -.button { - width: 2.4rem; - height: 2.4rem; - padding: 0; - color: inherit; - border: none; - background: none; - text-align: center; - font-size: 1.6rem; -} -.button[disabled] { - color: var(--vscode-disabledForeground); -} -.button:focus { - background-color: var(--vscode-toolbar-activeBackground); - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} -.button:not([disabled]) { - cursor: pointer; -} -.button:hover:not([disabled]) { - color: var(--vscode-foreground); - background-color: var(--vscode-toolbar-hoverBackground); +h3 { + margin-bottom: 0; } .alert { @@ -180,262 +196,86 @@ p { } } -.focus-icon { - font-size: 1.6rem; - vertical-align: sub; -} - -.focus-section { - display: flex; - flex-direction: column; - gap: 0.8rem; - - &__header { - flex: none; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - padding: 0.4rem 0; - - h2 { - margin: 0; - } - } - - &__content { - min-height: 0; - flex: 1 1 auto; - overflow: auto; - } -} - .app { display: flex; flex-direction: column; height: 100vh; - overflow: hidden; - // padding-right: 0; - - &__header { - flex: none; - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.4rem 2rem; - margin: { - left: -2rem; - right: -2rem; - } - text-align: right; + &__toolbar { background-color: var(--background-05); + display: grid; + align-items: center; + padding: 0.2rem 2rem; + margin-left: -2rem; + margin-right: -2rem; + grid-template-columns: 1fr min-content min-content; + gap: 0.5rem; + z-index: 101; } &__content { position: relative; flex: 1 1 auto; - // display: flex; - // flex-direction: row; overflow: hidden; - gap: 1rem; - } - - &__controls { - flex: 0 0 20rem; } - &__main { - // flex: 1 1 auto; + &__focus { display: flex; flex-direction: column; overflow: hidden; height: 100%; - } - &__section { - min-height: 15rem; - flex: 0 1 50%; + gap: 1.2rem; } - &__cover { - [aria-hidden='true'] & { - position: absolute; - top: 0px; - left: 0px; - width: 100%; - height: 100%; - backdrop-filter: blur(4px) saturate(0.8); - z-index: var(--gitlens-z-cover); - pointer-events: none; + &__header { + flex: none; + display: flex; + flex-direction: column; + gap: 1.6rem; + padding-top: 1.2rem; + z-index: 1; + + &-group { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 0.4rem; } } -} -.tag { - display: inline-block; - padding: 0.1rem 0.2rem; - background-color: var(--background-05); - color: var(--color-foreground--85); - white-space: nowrap; -} -.tag code-icon { - margin-right: 0.2rem; -} - -.stat-added { - white-space: nowrap; - color: var(--vscode-gitDecoration-addedResourceForeground); -} -.stat-deleted { - white-space: nowrap; - color: var(--vscode-gitDecoration-deletedResourceForeground); -} -.stat-modified { - white-space: nowrap; - color: var(--vscode-gitDecoration-modifiedResourceForeground); -} - -.data { - &-status { - width: 5.8rem; - } - &-time { - width: 4rem; - } - &-body { - } - &-author { - width: 8.8rem; - } - &-assigned { - width: 8.8rem; - } - &-comments { - width: 4rem; - } - &-checks { - width: 3.2rem; - } - &-stats { - width: 9.2rem; - } - &-actions { - } -} - -.choice { - display: inline-flex; - flex-direction: row; - align-items: center; - color: var(--vscode-checkbox-foreground); - margin: 0.4rem 0; - user-select: none; - - &:focus-within { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } - - &__input { - clip: rect(0 0 0 0); - clip-path: inset(50%); - width: 1px; - height: 1px; - overflow: hidden; - position: absolute; - white-space: nowrap; - } - - &__indicator { - position: relative; - box-sizing: border-box; - display: flex; - justify-content: center; - align-items: center; - background: var(--vscode-checkbox-background); - border: 0.1rem solid var(--vscode-checkbox-border); - width: 1.8rem; - height: 1.8rem; - outline: none; - cursor: pointer; + &__search { + flex: 1; - &, code-icon { - border-radius: 0.3rem; - overflow: hidden; + margin-right: 0.8rem; } } - &__input[type='radio'] + &__indicator { - border-radius: 99.9rem; - } - - &__input:not(:checked) + &__indicator code-icon { - opacity: 0; - } - - &__label { - font-family: var(--font-family); - color: var(--vscode-checkbox-foreground); - padding-inline-start: 1rem; - margin-inline-end: 1rem; - cursor: pointer; + &__main { + min-height: 0; + flex: 1 1 auto; + overflow: auto; } } -.overlay { - z-index: var(--gitlens-z-modal); - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - font-size: 1.3em; - min-height: 100%; - padding: 0 2rem 2rem 2rem; - - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - - &__content { - max-width: 600px; - background: var(--background-05) no-repeat left top; - background-image: var(--gl-plus-bg); - background-position: left -20rem center; - background-size: 100%; - border: 1px solid var(--background-15); - border-radius: 0.4rem; - margin: 1rem; - padding: 1.2rem; - - > p:first-child { - margin-top: 0; - } - - vscode-button:not([appearance='icon']) { - align-self: center !important; - } - } +.preview { + font-size: 1rem; + font-weight: 700; + text-transform: uppercase; + color: var(--color-foreground); +} - &__actions { - text-align: center; - margin: 3rem 0; - } +.mine-menu { + width: max-content; } -.divider { - display: block; - height: 0; - margin: 0.6rem; - border: none; - border-top: 0.1rem solid var(--vscode-menu-separatorBackground); +gk-tooltip gk-menu { + z-index: 10; } -.badge { - font-size: 1rem; - font-weight: 700; - text-transform: uppercase; - color: var(--color-foreground); +gl-feature-gate gl-feature-badge { + vertical-align: super; + margin-left: 0.4rem; + margin-right: 0.4rem; } diff --git a/src/webviews/apps/plus/focus/focus.ts b/src/webviews/apps/plus/focus/focus.ts index 606b1fa12be4b..b8bca3fe2cb6d 100644 --- a/src/webviews/apps/plus/focus/focus.ts +++ b/src/webviews/apps/plus/focus/focus.ts @@ -1,27 +1,23 @@ -import { provideVSCodeDesignSystem, vsCodeButton } from '@vscode/webview-ui-toolkit'; +import type { IssueShape } from '../../../../git/models/issue'; +import type { PullRequestShape } from '../../../../git/models/pullRequest'; import type { State } from '../../../../plus/webviews/focus/protocol'; import { - DidChangeStateNotificationType, - DidChangeSubscriptionNotificationType, + DidChangeNotification, + OpenBranchCommand, + OpenWorktreeCommand, + PinIssueCommand, + PinPRCommand, + SnoozeIssueCommand, + SnoozePRCommand, + SwitchToBranchCommand, } from '../../../../plus/webviews/focus/protocol'; import type { IpcMessage } from '../../../protocol'; -import { ExecuteCommandType, onIpc } from '../../../protocol'; import { App } from '../../shared/appBase'; -import type { AccountBadge } from '../../shared/components/account/account-badge'; import { DOM } from '../../shared/dom'; -import type { IssueRow } from './components/issue-row'; -import type { PlusContent } from './components/plus-content'; -import type { PullRequestRow } from './components/pull-request-row'; -import '../../shared/components/code-icon'; -import '../../shared/components/avatars/avatar-item'; -import '../../shared/components/avatars/avatar-stack'; -import '../../shared/components/table/table-container'; -import '../../shared/components/table/table-row'; -import '../../shared/components/table/table-cell'; -import '../../shared/components/account/account-badge'; -import './components/issue-row'; -import './components/plus-content'; -import './components/pull-request-row'; +import type { GlFocusApp } from './components/focus-app'; +import type { GkIssueRow } from './components/gk-issue-row'; +import type { GkPullRequestRow } from './components/gk-pull-request-row'; +import './components/focus-app'; import './focus.scss'; export class FocusApp extends App { @@ -29,186 +25,117 @@ export class FocusApp extends App { super('FocusApp'); } - _prFilter?: string; - _issueFilter?: string; - override onInitialize() { - this.log(`${this.appName}.onInitialize`); - provideVSCodeDesignSystem().register(vsCodeButton()); - this.renderContent(); - console.log(this.state); + this.attachState(); } protected override onBind() { const disposables = super.onBind?.() ?? []; disposables.push( - DOM.on('#pr-filter [data-tab]', 'click', e => - this.onSelectTab(e, val => { - this._prFilter = val; - this.renderPullRequests(); - }), + DOM.on( + 'gk-pull-request-row', + 'open-worktree', + (e, target: HTMLElement) => this.onOpenWorktree(e, target), ), - ); - disposables.push( - DOM.on('#issue-filter [data-tab]', 'click', e => - this.onSelectTab(e, val => { - this._issueFilter = val; - this.renderIssues(); - }), + DOM.on('gk-pull-request-row', 'open-branch', (e, target: HTMLElement) => + this.onOpenBranch(e, target), ), - ); - disposables.push( - DOM.on('[data-action]', 'click', (e, target: HTMLElement) => this.onDataActionClicked(e, target)), - ); - disposables.push( - DOM.on('plus-content', 'action', (e, target: HTMLElement) => - this.onPlusActionClicked(e, target), + DOM.on( + 'gk-pull-request-row', + 'switch-branch', + (e, target: HTMLElement) => this.onSwitchBranch(e, target), + ), + DOM.on( + 'gk-pull-request-row', + 'snooze-item', + (e, _target: HTMLElement) => this.onSnoozeItem(e, false), + ), + DOM.on( + 'gk-pull-request-row', + 'pin-item', + (e, _target: HTMLElement) => this.onPinItem(e, false), + ), + DOM.on( + 'gk-issue-row', + 'snooze-item', + (e, _target: HTMLElement) => this.onSnoozeItem(e, true), + ), + DOM.on( + 'gk-issue-row', + 'pin-item', + (e, _target: HTMLElement) => this.onPinItem(e, true), ), ); return disposables; } - private onDataActionClicked(_e: MouseEvent, target: HTMLElement) { - const action = target.dataset.action; - this.onActionClickedCore(action); + private _component?: GlFocusApp; + private get component() { + if (this._component == null) { + this._component = (document.getElementById('app') as GlFocusApp)!; + } + return this._component; } - private onPlusActionClicked(e: CustomEvent, _target: HTMLElement) { - this.onActionClickedCore(e.detail); + attachState() { + this.component.state = this.state; } - private onActionClickedCore(action?: string) { - if (action?.startsWith('command:')) { - this.sendCommand(ExecuteCommandType, { command: action.slice(8) }); - } + private onOpenBranch(e: CustomEvent, _target: HTMLElement) { + if (e.detail?.refs?.head == null) return; + this.sendCommand(OpenBranchCommand, { pullRequest: e.detail }); } - protected override onMessageReceived(e: MessageEvent) { - const msg = e.data as IpcMessage; - this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); - - switch (msg.method) { - case DidChangeStateNotificationType.method: - onIpc(DidChangeStateNotificationType, msg, params => { - this.setState({ ...this.state, ...params.state }); - this.renderContent(); - }); - break; - case DidChangeSubscriptionNotificationType.method: - onIpc(DidChangeSubscriptionNotificationType, msg, params => { - this.setState({ ...this.state, subscription: params.subscription, isPlus: params.isPlus }); - this.renderContent(); - }); - break; - } + private onSwitchBranch(e: CustomEvent, _target: HTMLElement) { + if (e.detail?.refs?.head == null) return; + this.sendCommand(SwitchToBranchCommand, { pullRequest: e.detail }); } - renderContent() { - this.renderAccountState(); - - if (this.state.isPlus) { - this.renderPullRequests(); - this.renderIssues(); - } + private onOpenWorktree(e: CustomEvent, _target: HTMLElement) { + if (e.detail?.refs?.head == null) return; + this.sendCommand(OpenWorktreeCommand, { pullRequest: e.detail }); } - renderPullRequests() { - const tableEl = document.getElementById('pull-requests'); - if (tableEl == null) return; - - const rowEls = tableEl.querySelectorAll('pull-request-row'); - rowEls.forEach(el => el.remove()); - - const noneEl = document.getElementById('no-pull-requests')!; - const loadingEl = document.getElementById('loading-pull-requests')!; - if (this.state.pullRequests == null) { - noneEl.setAttribute('hidden', 'true'); - loadingEl.removeAttribute('hidden'); - } else if (this.state.pullRequests.length === 0) { - noneEl.removeAttribute('hidden'); - loadingEl.setAttribute('hidden', 'true'); - } else { - noneEl.setAttribute('hidden', 'true'); - loadingEl.setAttribute('hidden', 'true'); - this.state.pullRequests.forEach(({ pullRequest, reasons }) => { - if (this._prFilter == null || this._prFilter === '' || reasons.includes(this._prFilter)) { - const rowEl = document.createElement('pull-request-row') as PullRequestRow; - rowEl.pullRequest = pullRequest; - rowEl.reasons = reasons; - - tableEl.append(rowEl); - } + private onSnoozeItem( + e: CustomEvent<{ item: PullRequestShape | IssueShape; expiresAt?: string; snooze?: string }>, + isIssue: boolean, + ) { + if (isIssue) { + this.sendCommand(SnoozeIssueCommand, { + issue: e.detail.item as IssueShape, + expiresAt: e.detail.expiresAt, + snooze: e.detail.snooze, }); - } - } - - renderIssues() { - const tableEl = document.getElementById('issues')!; - - const rowEls = tableEl.querySelectorAll('issue-row'); - rowEls.forEach(el => el.remove()); - - const noneEl = document.getElementById('no-issues')!; - const loadingEl = document.getElementById('loading-issues')!; - if (this.state.issues == null) { - noneEl.setAttribute('hidden', 'true'); - loadingEl.removeAttribute('hidden'); - } else if (this.state.issues.length === 0) { - noneEl.removeAttribute('hidden'); - loadingEl.setAttribute('hidden', 'true'); } else { - noneEl.setAttribute('hidden', 'true'); - loadingEl.setAttribute('hidden', 'true'); - this.state.issues.forEach(({ issue, reasons }) => { - if (this._issueFilter == null || this._issueFilter === '' || reasons.includes(this._issueFilter)) { - const rowEl = document.createElement('issue-row') as IssueRow; - rowEl.issue = issue; - rowEl.reasons = reasons; - - tableEl.append(rowEl); - } + this.sendCommand(SnoozePRCommand, { + pullRequest: e.detail.item as PullRequestShape, + expiresAt: e.detail.expiresAt, + snooze: e.detail.snooze, }); } } - renderAccountState() { - const content = document.getElementById('content')!; - const accountOverlay = document.getElementById('account-overlay')!; - const connectOverlay = document.getElementById('connect-overlay')!; - - if (!this.state.isPlus) { - content.setAttribute('aria-hidden', 'true'); - accountOverlay.removeAttribute('hidden'); - connectOverlay.setAttribute('hidden', 'true'); - } else if (this.state.repos != null && this.state.repos.some(repo => repo.isConnected) === false) { - content.setAttribute('aria-hidden', 'true'); - accountOverlay.setAttribute('hidden', 'true'); - connectOverlay.removeAttribute('hidden'); + private onPinItem(e: CustomEvent<{ item: PullRequestShape | IssueShape; pin?: string }>, isIssue: boolean) { + if (isIssue) { + this.sendCommand(PinIssueCommand, { issue: e.detail.item as IssueShape, pin: e.detail.pin }); } else { - content.removeAttribute('aria-hidden'); - accountOverlay.setAttribute('hidden', 'true'); - connectOverlay.setAttribute('hidden', 'true'); + this.sendCommand(PinPRCommand, { pullRequest: e.detail.item as PullRequestShape, pin: e.detail.pin }); } - - const badgeEl = document.getElementById('account-badge')! as AccountBadge; - badgeEl.subscription = this.state.subscription; } - onSelectTab(e: Event, callback?: (val?: string) => void) { - const thisEl = e.target as HTMLElement; - const tab = thisEl.dataset.tab!; - - thisEl.parentElement?.querySelectorAll('[data-tab]')?.forEach(el => { - if ((el as HTMLElement).dataset.tab !== tab) { - el.classList.remove('is-active'); - } else { - el.classList.add('is-active'); - } - }); + protected override onMessageReceived(msg: IpcMessage) { + switch (true) { + case DidChangeNotification.is(msg): + this.state = msg.params.state; + this.setState(this.state); + this.attachState(); + break; - callback?.(tab); + default: + super.onMessageReceived?.(msg); + } } } diff --git a/src/webviews/apps/plus/graph/GraphWrapper.tsx b/src/webviews/apps/plus/graph/GraphWrapper.tsx index df4c7fe44cdde..23f0dece3cc3d 100644 --- a/src/webviews/apps/plus/graph/GraphWrapper.tsx +++ b/src/webviews/apps/plus/graph/GraphWrapper.tsx @@ -1,4 +1,7 @@ +import { getPlatform } from '@env/platform'; import type { + CommitType, + GraphColumnMode, GraphColumnSetting, GraphColumnsSettings, GraphContainerProps, @@ -10,25 +13,29 @@ import type { GraphZoneType, OnFormatCommitDateTime, } from '@gitkraken/gitkraken-components'; -import GraphContainer, { GRAPH_ZONE_TYPE, REF_ZONE_TYPE } from '@gitkraken/gitkraken-components'; +import GraphContainer, { CommitDateTimeSources, refZone } from '@gitkraken/gitkraken-components'; +import type { SlChangeEvent } from '@shoelace-style/shoelace'; +import { SlOption, SlSelect } from '@shoelace-style/shoelace/dist/react'; import { VSCodeCheckbox, VSCodeRadio, VSCodeRadioGroup } from '@vscode/webview-ui-toolkit/react'; -import type { FormEvent, ReactElement } from 'react'; +import type { FormEvent, MouseEvent, ReactElement } from 'react'; import React, { createElement, useEffect, useMemo, useRef, useState } from 'react'; -import { getPlatform } from '@env/platform'; -import { DateStyle } from '../../../../config'; -import { RepositoryVisibility } from '../../../../git/gitProvider'; -import type { SearchQuery } from '../../../../git/search'; +import type { DateStyle, GraphBranchesVisibility } from '../../../../config'; +import type { SearchQuery } from '../../../../constants.search'; +import type { Subscription } from '../../../../plus/gk/account/subscription'; +import { isSubscriptionPaid } from '../../../../plus/gk/account/subscription'; +import type { LaunchpadCommandArgs } from '../../../../plus/launchpad/launchpad'; import type { DidEnsureRowParams, + DidGetRowHoverParams, DidSearchParams, - DismissBannerParams, GraphAvatars, GraphColumnName, GraphColumnsConfig, - GraphCommitDateTimeSource, GraphComponentConfig, GraphExcludedRef, GraphExcludeTypes, + GraphItemContext, + GraphMinimapMarkerTypes, GraphMissingRefsMetadata, GraphRefMetadataItem, GraphRepository, @@ -40,78 +47,90 @@ import type { UpdateStateCallback, } from '../../../../plus/webviews/graph/protocol'; import { - DidChangeAvatarsNotificationType, - DidChangeColumnsNotificationType, - DidChangeGraphConfigurationNotificationType, - DidChangeRefsMetadataNotificationType, - DidChangeRefsVisibilityNotificationType, - DidChangeRowsNotificationType, - DidChangeSelectionNotificationType, - DidChangeSubscriptionNotificationType, - DidChangeWindowFocusNotificationType, - DidChangeWorkingTreeNotificationType, - DidFetchNotificationType, - DidSearchNotificationType, - GraphCommitDateTimeSources, - GraphMinimapMarkerTypes, + DidChangeAvatarsNotification, + DidChangeBranchStateNotification, + DidChangeColumnsNotification, + DidChangeGraphConfigurationNotification, + DidChangeRefsMetadataNotification, + DidChangeRefsVisibilityNotification, + DidChangeRepoConnectionNotification, + DidChangeRowsNotification, + DidChangeRowsStatsNotification, + DidChangeSelectionNotification, + DidChangeSubscriptionNotification, + DidChangeWorkingTreeNotification, + DidFetchNotification, + DidSearchNotification, } from '../../../../plus/webviews/graph/protocol'; -import type { Subscription } from '../../../../subscription'; -import { getSubscriptionTimeRemaining, SubscriptionState } from '../../../../subscription'; +import { filterMap, first, groupByFilterMap, join } from '../../../../system/iterable'; import { pluralize } from '../../../../system/string'; -import type { IpcNotificationType } from '../../../protocol'; +import { createWebviewCommandLink } from '../../../../system/webview'; +import type { IpcNotification } from '../../../protocol'; +import { DidChangeHostWindowFocusNotification } from '../../../protocol'; +import { createCommandLink } from '../../shared/commands'; +import { GlButton } from '../../shared/components/button.react'; +import { CodeIcon } from '../../shared/components/code-icon.react'; +import { GlConnect } from '../../shared/components/integrations/connect.react'; import { MenuDivider, MenuItem, MenuLabel, MenuList } from '../../shared/components/menu/react'; import { PopMenu } from '../../shared/components/overlays/pop-menu/react'; -import { PopOver } from '../../shared/components/overlays/react'; -import { SearchBox } from '../../shared/components/search/react'; +import { GlPopover } from '../../shared/components/overlays/popover.react'; +import { GlTooltip } from '../../shared/components/overlays/tooltip.react'; +import { GlFeatureBadge } from '../../shared/components/react/feature-badge'; +import { GlFeatureGate } from '../../shared/components/react/feature-gate'; +import { GlIssuePullRequest } from '../../shared/components/react/issue-pull-request'; +import { GlSearchBox } from '../../shared/components/search/react'; import type { SearchNavigationEventDetail } from '../../shared/components/search/search-box'; import type { DateTimeFormat } from '../../shared/date'; import { formatDate, fromNow } from '../../shared/date'; -import type { - GraphMinimapDaySelectedEventDetail, - GraphMinimapMarker, - GraphMinimapSearchResultMarker, - GraphMinimapStats, - GraphMinimap as GraphMinimapType, - StashMarker, -} from './minimap/minimap'; -import { GraphMinimap } from './minimap/react'; +import { GlGraphHover } from './hover/graphHover.react'; +import type { GraphMinimapDaySelectedEventDetail } from './minimap/minimap'; +import { GlGraphMinimapContainer } from './minimap/minimap-container.react'; +import { GlGraphSideBar } from './sidebar/sidebar.react'; export interface GraphWrapperProps { nonce?: string; state: State; subscriber: (callback: UpdateStateCallback) => () => void; + onChangeColumns?: (colsSettings: GraphColumnsConfig) => void; + onChangeExcludeTypes?: (key: keyof GraphExcludeTypes, value: boolean) => void; + onChangeGraphConfiguration?: (changes: UpdateGraphConfigurationParams['changes']) => void; + onChangeRefIncludes?: (branchesVisibility: GraphBranchesVisibility, refs?: GraphRefOptData[]) => void; + onChangeRefsVisibility?: (refs: GraphExcludedRef[], visible: boolean) => void; + onChangeSelection?: (rows: GraphRow[]) => void; onChooseRepository?: () => void; - onColumnsChange?: (colsSettings: GraphColumnsConfig) => void; - onDimMergeCommits?: (dim: boolean) => void; onDoubleClickRef?: (ref: GraphRef, metadata?: GraphRefMetadataItem) => void; onDoubleClickRow?: (row: GraphRow, preserveFocus?: boolean) => void; - onMissingAvatars?: (emails: { [email: string]: string }) => void; + onEnsureRowPromise?: (id: string, select: boolean) => Promise; + onHoverRowPromise?: (row: GraphRow) => Promise; + onJumpToRefPromise?: (alt: boolean) => Promise<{ name: string; sha: string } | undefined>; + onMissingAvatars?: (emails: Record) => void; onMissingRefsMetadata?: (metadata: GraphMissingRefsMetadata) => void; onMoreRows?: (id?: string) => void; - onRefsVisibilityChange?: (refs: GraphExcludedRef[], visible: boolean) => void; + onOpenPullRequest?: (pr: NonNullable['pr']>) => void; onSearch?: (search: SearchQuery | undefined, options?: { limit?: number }) => void; onSearchPromise?: ( search: SearchQuery, options?: { limit?: number; more?: boolean }, ) => Promise; onSearchOpenInView?: (search: SearchQuery) => void; - onDismissBanner?: (key: DismissBannerParams['key']) => void; - onSelectionChange?: (rows: GraphRow[]) => void; - onEnsureRowPromise?: (id: string, select: boolean) => Promise; - onExcludeType?: (key: keyof GraphExcludeTypes, value: boolean) => void; - onIncludeOnlyRef?: (all: boolean) => void; - onUpdateGraphConfiguration?: (changes: UpdateGraphConfigurationParams['changes']) => void; } const getGraphDateFormatter = (config?: GraphComponentConfig): OnFormatCommitDateTime => { - return (commitDateTime: number, source?: GraphCommitDateTimeSource) => + return (commitDateTime: number, source?: CommitDateTimeSources) => formatCommitDateTime(commitDateTime, config?.dateStyle, config?.dateFormat, source); }; -const createIconElements = (): { [key: string]: ReactElement } => { +const createIconElements = (): Record => { const iconList = [ 'head', 'remote', + 'remote-github', + 'remote-githubEnterprise', + 'remote-gitlab', + 'remote-gitlabSelfHosted', + 'remote-bitbucket', + 'remote-bitbucketServer', + 'remote-azureDevops', 'tag', 'stash', 'check', @@ -125,17 +144,31 @@ const createIconElements = (): { [key: string]: ReactElement } => { 'pull-request', 'show', 'hide', + 'branch', + 'graph', + 'commit', + 'author', + 'datetime', + 'message', + 'changes', + 'files', + 'worktree', ]; const miniIconList = ['upstream-ahead', 'upstream-behind']; - const elementLibrary: { [key: string]: ReactElement } = {}; + const elementLibrary: Record = {}; iconList.forEach(iconKey => { elementLibrary[iconKey] = createElement('span', { className: `graph-icon icon--${iconKey}` }); }); miniIconList.forEach(iconKey => { elementLibrary[iconKey] = createElement('span', { className: `graph-icon mini-icon icon--${iconKey}` }); }); + //TODO: fix this once the styling is properly configured component-side + elementLibrary.settings = createElement('span', { + className: 'graph-icon icon--settings', + style: { fontSize: '1.1rem', right: '0px', top: '-1px' }, + }); return elementLibrary; }; @@ -159,42 +192,68 @@ const getClientPlatform = (): GraphPlatform => { const clientPlatform = getClientPlatform(); +interface SelectionContext { + listDoubleSelection?: boolean; + listMultiSelection?: boolean; + webviewItems?: string; + webviewItemsValues?: GraphItemContext[]; +} + +interface SelectionContexts { + contexts: Map; + selectedShas: Set; +} + +const emptySelectionContext: SelectionContext = { + listDoubleSelection: false, + listMultiSelection: false, + webviewItems: undefined, + webviewItemsValues: undefined, +}; + // eslint-disable-next-line @typescript-eslint/naming-convention export function GraphWrapper({ subscriber, nonce, state, onChooseRepository, - onColumnsChange, - onDimMergeCommits, + onChangeColumns, + onChangeExcludeTypes, + onChangeGraphConfiguration, + onChangeRefIncludes, + onChangeRefsVisibility, + onChangeSelection, onDoubleClickRef, onDoubleClickRow, onEnsureRowPromise, + onHoverRowPromise, + onJumpToRefPromise, onMissingAvatars, onMissingRefsMetadata, onMoreRows, - onRefsVisibilityChange, + onOpenPullRequest, onSearch, onSearchPromise, onSearchOpenInView, - onSelectionChange, - onDismissBanner, - onExcludeType, - onIncludeOnlyRef, - onUpdateGraphConfiguration, }: GraphWrapperProps) { const graphRef = useRef(null); const [rows, setRows] = useState(state.rows ?? []); + const [rowsStats, setRowsStats] = useState(state.rowsStats); + const [rowsStatsLoading, setRowsStatsLoading] = useState(state.rowsStatsLoading); const [avatars, setAvatars] = useState(state.avatars); + const [downstreams, setDownstreams] = useState(state.downstreams ?? {}); const [refsMetadata, setRefsMetadata] = useState(state.refsMetadata); const [repos, setRepos] = useState(state.repositories ?? []); const [repo, setRepo] = useState( repos.find(item => item.path === state.selectedRepository), ); + const [branchesVisibility, setBranchesVisibility] = useState(state.branchesVisibility); + const [branchState, setBranchState] = useState(state.branchState); const [selectedRows, setSelectedRows] = useState(state.selectedRows); const [activeRow, setActiveRow] = useState(state.activeRow); const [activeDay, setActiveDay] = useState(state.activeDay); + const [selectionContexts, setSelectionContexts] = useState(); const [visibleDays, setVisibleDays] = useState(state.visibleDays); const [graphConfig, setGraphConfig] = useState(state.config); // const [graphDateFormatter, setGraphDateFormatter] = useState(getGraphDateFormatter(config)); @@ -209,15 +268,10 @@ export function GraphWrapper({ const [branchName, setBranchName] = useState(state.branchName); const [lastFetched, setLastFetched] = useState(state.lastFetched); const [windowFocused, setWindowFocused] = useState(state.windowFocused); - // account - const [showAccount, setShowAccount] = useState(state.trialBanner); - const [isAccessAllowed, setIsAccessAllowed] = useState(state.allowed ?? false); - const [isRepoPrivate, setIsRepoPrivate] = useState( - state.selectedRepositoryVisibility === RepositoryVisibility.Private, - ); + const [allowed, setAllowed] = useState(state.allowed ?? false); const [subscription, setSubscription] = useState(state.subscription); // search state - const searchEl = useRef(null); + const searchEl = useRef(null); const [searchQuery, setSearchQuery] = useState(undefined); const { results, resultsError } = getSearchResultModel(state); const [searchResults, setSearchResults] = useState(results); @@ -230,14 +284,15 @@ export function GraphWrapper({ state.workingTreeStats ?? { added: 0, modified: 0, deleted: 0 }, ); - const minimap = useRef(undefined); + const minimap = useRef(undefined); + const hover = useRef(undefined); const ensuredIds = useRef>(new Set()); const ensuredSkippedIds = useRef>(new Set()); function updateState( state: State, - type?: IpcNotificationType | InternalNotificationType, + type?: IpcNotification | InternalNotificationType, themingChanged?: boolean, ) { if (themingChanged) { @@ -250,28 +305,39 @@ export function GraphWrapper({ setStyleProps(state.theming); } break; - case DidChangeAvatarsNotificationType: + case DidChangeAvatarsNotification: setAvatars(state.avatars); break; - case DidChangeWindowFocusNotificationType: + case DidChangeBranchStateNotification: + setBranchState(state.branchState); + break; + case DidChangeHostWindowFocusNotification: setWindowFocused(state.windowFocused); break; - case DidChangeRefsMetadataNotificationType: + case DidChangeRefsMetadataNotification: setRefsMetadata(state.refsMetadata); break; - case DidChangeColumnsNotificationType: + case DidChangeColumnsNotification: setColumns(state.columns); setContext(state.context); break; - case DidChangeRowsNotificationType: + case DidChangeRowsNotification: + hover.current?.reset(); setRows(state.rows ?? []); + setRowsStats(state.rowsStats); + setRowsStatsLoading(state.rowsStatsLoading); setSelectedRows(state.selectedRows); setAvatars(state.avatars); + setDownstreams(state.downstreams ?? {}); setRefsMetadata(state.refsMetadata); setPagingHasMore(state.paging?.hasMore ?? false); setIsLoading(state.loading); break; - case DidSearchNotificationType: { + case DidChangeRowsStatsNotification: + setRowsStats(state.rowsStats); + setRowsStatsLoading(state.rowsStatsLoading); + break; + case DidSearchNotification: { const { results, resultsError } = getSearchResultModel(state); setSearchResultsError(resultsError); setSearchResults(results); @@ -279,29 +345,42 @@ export function GraphWrapper({ setSearching(false); break; } - case DidChangeGraphConfigurationNotificationType: + case DidChangeGraphConfigurationNotification: setGraphConfig(state.config); break; - case DidChangeSelectionNotificationType: + case DidChangeSelectionNotification: setSelectedRows(state.selectedRows); break; - case DidChangeRefsVisibilityNotificationType: + case DidChangeRefsVisibilityNotification: + setBranchesVisibility(state.branchesVisibility); setExcludeRefsById(state.excludeRefs); setExcludeTypes(state.excludeTypes); setIncludeOnlyRefsById(state.includeOnlyRefs); + // Hack to force the Graph to maintain the selected rows + if (state.selectedRows != null) { + const shas = Object.keys(state.selectedRows); + if (shas.length) { + queueMicrotask(() => graphRef?.current?.selectCommits(shas, false, true)); + } + } break; - case DidChangeSubscriptionNotificationType: - setIsAccessAllowed(state.allowed ?? false); + case DidChangeSubscriptionNotification: + setAllowed(state.allowed ?? false); setSubscription(state.subscription); break; - case DidChangeWorkingTreeNotificationType: + case DidChangeWorkingTreeNotification: setWorkingTreeStats(state.workingTreeStats ?? { added: 0, modified: 0, deleted: 0 }); break; - case DidFetchNotificationType: + case DidFetchNotification: setLastFetched(state.lastFetched); break; + case DidChangeRepoConnectionNotification: + setRepos(state.repositories ?? []); + setRepo(state.repositories?.find(item => item.path === state.selectedRepository)); + break; default: { - setIsAccessAllowed(state.allowed ?? false); + hover.current?.reset(); + setAllowed(state.allowed ?? false); if (!themingChanged) { setStyleProps(state.theming); } @@ -309,6 +388,8 @@ export function GraphWrapper({ setLastFetched(state.lastFetched); setColumns(state.columns); setRows(state.rows ?? []); + setRowsStats(state.rowsStats); + setRowsStatsLoading(state.rowsStatsLoading); setWorkingTreeStats(state.workingTreeStats ?? { added: 0, modified: 0, deleted: 0 }); setGraphConfig(state.config); setSelectedRows(state.selectedRows); @@ -317,14 +398,15 @@ export function GraphWrapper({ setIncludeOnlyRefsById(state.includeOnlyRefs); setContext(state.context); setAvatars(state.avatars ?? {}); + setDownstreams(state.downstreams ?? {}); + setBranchesVisibility(state.branchesVisibility); + setBranchState(state.branchState); setRefsMetadata(state.refsMetadata); setPagingHasMore(state.paging?.hasMore ?? false); setRepos(state.repositories ?? []); setRepo(repos.find(item => item.path === state.selectedRepository)); - setIsRepoPrivate(state.selectedRepositoryVisibility === RepositoryVisibility.Private); // setGraphDateFormatter(getGraphDateFormatter(config)); setSubscription(state.subscription); - setShowAccount(state.trialBanner ?? true); const { results, resultsError } = getSearchResultModel(state); setSearchResultsError(resultsError); @@ -338,6 +420,22 @@ export function GraphWrapper({ useEffect(() => subscriber?.(updateState), []); + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + const sha = getActiveRowInfo(activeRow ?? state.activeRow)?.id; + if (sha == null) return; + + // TODO@eamodio a bit of a hack since the graph container ref isn't exposed in the types + const graph = (graphRef.current as any)?.graphContainerRef.current; + if (!e.composedPath().some(el => el === graph)) return; + + const row = rows.find(r => r.sha === sha); + if (row == null) return; + + onDoubleClickRow?.(row, e.key !== 'Enter'); + } + }; + useEffect(() => { window.addEventListener('keydown', handleKeyDown); @@ -346,213 +444,6 @@ export function GraphWrapper({ }; }, [activeRow]); - const minimapData = useMemo(() => { - if (!graphConfig?.minimap) return undefined; - - // Loops through all the rows and group them by day and aggregate the row.stats - const statsByDayMap = new Map(); - const markersByDay = new Map(); - const enabledMinimapMarkers: GraphMinimapMarkerTypes[] = graphConfig?.enabledMinimapMarkerTypes ?? []; - - let rankedShas: { - head: string | undefined; - branch: string | undefined; - remote: string | undefined; - tag: string | undefined; - } = { - head: undefined, - branch: undefined, - remote: undefined, - tag: undefined, - }; - - let day; - let prevDay; - - let markers; - let headMarkers: GraphMinimapMarker[]; - let remoteMarkers: GraphMinimapMarker[]; - let stashMarker: StashMarker | undefined; - let tagMarkers: GraphMinimapMarker[]; - let row: GraphRow; - let stat; - let stats; - - // Iterate in reverse order so that we can track the HEAD upstream properly - for (let i = rows.length - 1; i >= 0; i--) { - row = rows[i]; - stats = row.stats; - - day = getDay(row.date); - if (day !== prevDay) { - prevDay = day; - rankedShas = { - head: undefined, - branch: undefined, - remote: undefined, - tag: undefined, - }; - } - - if ( - row.heads?.length && - (enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.Head) || - enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.LocalBranches)) - ) { - rankedShas.branch = row.sha; - - headMarkers = []; - - // eslint-disable-next-line no-loop-func - row.heads.forEach(h => { - if (h.isCurrentHead) { - rankedShas.head = row.sha; - } - - if ( - enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.LocalBranches) || - (enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.Head) && h.isCurrentHead) - ) { - headMarkers.push({ - type: 'branch', - name: h.name, - current: h.isCurrentHead && enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.Head), - }); - } - }); - - markers = markersByDay.get(day); - if (markers == null) { - markersByDay.set(day, headMarkers); - } else { - markers.push(...headMarkers); - } - } - - if ( - row.remotes?.length && - (enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.Upstream) || - enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.RemoteBranches)) - ) { - rankedShas.remote = row.sha; - - remoteMarkers = []; - - // eslint-disable-next-line no-loop-func - row.remotes.forEach(r => { - let current = false; - if (r.current) { - rankedShas.remote = row.sha; - current = true; - } - - if ( - enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.RemoteBranches) || - (enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.Upstream) && current) - ) { - remoteMarkers.push({ - type: 'remote', - name: `${r.owner}/${r.name}`, - current: current && enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.Upstream), - }); - } - }); - - markers = markersByDay.get(day); - if (markers == null) { - markersByDay.set(day, remoteMarkers); - } else { - markers.push(...remoteMarkers); - } - } - - if (row.type === 'stash-node' && enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.Stashes)) { - stashMarker = { type: 'stash', name: row.message }; - markers = markersByDay.get(day); - if (markers == null) { - markersByDay.set(day, [stashMarker]); - } else { - markers.push(stashMarker); - } - } - - if (row.tags?.length && enabledMinimapMarkers.includes(GraphMinimapMarkerTypes.Tags)) { - rankedShas.tag = row.sha; - - tagMarkers = row.tags.map(t => ({ - type: 'tag', - name: t.name, - })); - - markers = markersByDay.get(day); - if (markers == null) { - markersByDay.set(day, tagMarkers); - } else { - markers.push(...tagMarkers); - } - } - - stat = statsByDayMap.get(day); - if (stat == null) { - stat = - stats != null - ? { - activity: { additions: stats.additions, deletions: stats.deletions }, - commits: 1, - files: stats.files, - sha: row.sha, - } - : { - commits: 1, - sha: row.sha, - }; - statsByDayMap.set(day, stat); - } else { - stat.commits++; - stat.sha = rankedShas.head ?? rankedShas.branch ?? rankedShas.remote ?? rankedShas.tag ?? stat.sha; - if (stats != null) { - if (stat.activity == null) { - stat.activity = { additions: stats.additions, deletions: stats.deletions }; - } else { - stat.activity.additions += stats.additions; - stat.activity.deletions += stats.deletions; - } - stat.files = (stat.files ?? 0) + stats.files; - } - } - } - - return { stats: statsByDayMap, markers: markersByDay }; - }, [rows, graphConfig?.minimap, graphConfig?.enabledMinimapMarkerTypes]); - - const minimapSearchResults = useMemo(() => { - if ( - !graphConfig?.minimap || - !graphConfig.enabledMinimapMarkerTypes?.includes(GraphMinimapMarkerTypes.Highlights) - ) { - return undefined; - } - - const searchResultsByDay = new Map(); - - if (searchResults?.ids != null) { - let day; - let sha; - let r; - let result; - for ([sha, r] of Object.entries(searchResults.ids)) { - day = getDay(r.date); - - result = searchResultsByDay.get(day); - if (result == null) { - searchResultsByDay.set(day, { type: 'search-result', sha: sha }); - } - } - } - - return searchResultsByDay; - }, [searchResults, graphConfig?.minimap, graphConfig?.enabledMinimapMarkerTypes]); - const handleOnMinimapDaySelected = (e: CustomEvent) => { let { sha } = e.detail; if (sha == null) { @@ -569,34 +460,115 @@ export function GraphWrapper({ graphRef.current?.selectCommits([sha], false, true); }; - const handleOnToggleMinimap = (_e: React.MouseEvent) => { - onUpdateGraphConfiguration?.({ minimap: !graphConfig?.minimap }); + const handleOnMinimapToggle = (_e: React.MouseEvent) => { + onChangeGraphConfiguration?.({ minimap: !graphConfig?.minimap }); + }; + + // This can only be applied to one radio button for now due to a bug in the component: https://github.com/microsoft/fast/issues/6381 + const handleOnMinimapDataTypeChange = (e: Event | FormEvent) => { + if (graphConfig == null) return; + + const $el = e.target as HTMLInputElement; + if ($el.value === 'commits') { + const minimapDataType = $el.checked ? 'commits' : 'lines'; + if (graphConfig.minimapDataType === minimapDataType) return; + + setGraphConfig({ ...graphConfig, minimapDataType: minimapDataType }); + onChangeGraphConfiguration?.({ minimapDataType: minimapDataType }); + } + }; + + const handleOnMinimapAdditionalTypesChange = (e: Event | FormEvent) => { + if (graphConfig?.minimapMarkerTypes == null) return; + + const $el = e.target as HTMLInputElement; + const value = $el.value as GraphMinimapMarkerTypes; + + if ($el.checked) { + if (!graphConfig.minimapMarkerTypes.includes(value)) { + const minimapMarkerTypes = [...graphConfig.minimapMarkerTypes, value]; + setGraphConfig({ ...graphConfig, minimapMarkerTypes: minimapMarkerTypes }); + onChangeGraphConfiguration?.({ minimapMarkerTypes: minimapMarkerTypes }); + } + } else { + const index = graphConfig.minimapMarkerTypes.indexOf(value); + if (index !== -1) { + const minimapMarkerTypes = [...graphConfig.minimapMarkerTypes]; + minimapMarkerTypes.splice(index, 1); + setGraphConfig({ ...graphConfig, minimapMarkerTypes: minimapMarkerTypes }); + onChangeGraphConfiguration?.({ minimapMarkerTypes: minimapMarkerTypes }); + } + } + }; + + const stopColumnResize = () => { + const activeResizeElement = document.querySelector('.graph-header .resizable.resizing'); + if (!activeResizeElement) return; + + // Trigger a mouseup event to reset the column resize state + document.dispatchEvent( + new MouseEvent('mouseup', { + view: window, + bubbles: true, + cancelable: true, + }), + ); }; - const handleOnGraphMouseLeave = (_event: any) => { + const handleOnGraphMouseLeave = (_event: React.MouseEvent) => { minimap.current?.unselect(undefined, true); + stopColumnResize(); }; - const handleOnGraphRowHovered = (_event: any, graphZoneType: GraphZoneType, graphRow: GraphRow) => { - if (graphZoneType === REF_ZONE_TYPE || minimap.current == null) return; + const handleOnGraphRowHovered = ( + event: React.MouseEvent, + graphZoneType: GraphZoneType, + graphRow: GraphRow, + ) => { + if (graphZoneType === refZone) return; minimap.current?.select(graphRow.date, true); - }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - const sha = getActiveRowInfo(activeRow ?? state.activeRow)?.id; - if (sha == null) return; + if (onHoverRowPromise == null) return; + + const hoverComponent = hover.current; + if (hoverComponent == null) return; + + const { clientX } = event; + + const rect = event.currentTarget.getBoundingClientRect() as DOMRect; + const x = clientX; + const y = rect.top; + const height = rect.height; + const width = 60; // Add some width, so `skidding` will be able to apply + + const anchor = { + getBoundingClientRect: function () { + return { + width: width, + height: height, + x: x, + y: y, + top: y, + left: x, + right: x + width, + bottom: y + height, + }; + }, + }; - // TODO@eamodio a bit of a hack since the graph container ref isn't exposed in the types - const graph = (graphRef.current as any)?.graphContainerRef.current; - if (!e.composedPath().some(el => el === graph)) return; + hoverComponent.requestMarkdown ??= onHoverRowPromise; + hoverComponent.onRowHovered(graphRow, anchor); + }; - const row = rows.find(r => r.sha === sha); - if (row == null) return; + const handleOnGraphRowUnhovered = ( + event: React.MouseEvent, + graphZoneType: GraphZoneType, + graphRow: GraphRow, + ) => { + if (graphZoneType === refZone) return; - onDoubleClickRow?.(row, e.key !== 'Enter'); - } + hover.current?.onRowUnhovered(graphRow, event.relatedTarget); }; useEffect(() => { @@ -618,28 +590,12 @@ export function GraphWrapper({ return searchIndex < 1 ? 1 : searchIndex + 1; }, [activeRow, searchResults]); - const isAllBranches = useMemo(() => { - if (includeOnlyRefsById == null) { - return true; - } - return Object.keys(includeOnlyRefsById).length === 0; - }, [includeOnlyRefsById]); - const hasFilters = useMemo(() => { - if (!isAllBranches) { - return true; - } - - if (graphConfig?.dimMergeCommits) { - return true; - } - - if (excludeTypes == null) { - return false; - } + if (graphConfig?.onlyFollowFirstParent) return true; + if (excludeTypes == null) return false; return Object.values(excludeTypes).includes(true); - }, [excludeTypes, isAllBranches, graphConfig?.dimMergeCommits]); + }, [excludeTypes, graphConfig?.onlyFollowFirstParent]); const handleSearchInput = (e: CustomEvent) => { const detail = e.detail; @@ -659,6 +615,34 @@ export function GraphWrapper({ onSearchOpenInView?.(searchQuery); }; + const ensureSearchResultRow = async (id: string): Promise => { + if (onEnsureRowPromise == null) return id; + if (ensuredIds.current.has(id)) return id; + if (ensuredSkippedIds.current.has(id)) return undefined; + + let timeout: ReturnType | undefined = setTimeout(() => { + timeout = undefined; + setIsLoading(true); + }, 500); + + const e = await onEnsureRowPromise(id, false); + if (timeout == null) { + setIsLoading(false); + } else { + clearTimeout(timeout); + } + + if (e?.id === id) { + ensuredIds.current.add(id); + return id; + } + + if (e != null) { + ensuredSkippedIds.current.add(id); + } + return undefined; + }; + const handleSearchNavigation = async (e: CustomEvent) => { if (searchResults == null) return; @@ -744,75 +728,58 @@ export function GraphWrapper({ } if (id != null) { - queueMicrotask(() => graphRef.current?.selectCommits([id!], false, true)); + queueMicrotask(() => graphRef.current?.selectCommits([id], false, true)); } }; - const ensureSearchResultRow = async (id: string): Promise => { - if (onEnsureRowPromise == null) return id; - if (ensuredIds.current.has(id)) return id; - if (ensuredSkippedIds.current.has(id)) return undefined; - - let timeout: ReturnType | undefined = setTimeout(() => { - timeout = undefined; - setIsLoading(true); - }, 500); - - const e = await onEnsureRowPromise(id, false); - if (timeout == null) { - setIsLoading(false); - } else { - clearTimeout(timeout); - } + const handleChooseRepository = () => { + onChooseRepository?.(); + }; - if (e?.id === id) { - ensuredIds.current.add(id); - return id; - } + const handleJumpToRef = async (e: MouseEvent) => { + const ref = await onJumpToRefPromise?.(e.altKey); + if (ref != null) { + const sha = await ensureSearchResultRow(ref.sha); + if (sha == null) return; - if (e != null) { - ensuredSkippedIds.current.add(id); + queueMicrotask(() => graphRef.current?.selectCommits([sha], false, true)); } - return undefined; - }; - - const handleChooseRepository = () => { - onChooseRepository?.(); }; - const handleExcludeTypeChange = (e: Event | FormEvent) => { + const handleFilterChange = (e: Event | FormEvent) => { const $el = e.target as HTMLInputElement; + if ($el == null) return; - const value = $el.value; - const isLocalBranches = ['branch-all', 'branch-current'].includes(value); - if (!isLocalBranches && !['remotes', 'stashes', 'tags', 'mergeCommits'].includes(value)) return; - const isChecked = $el.checked; - if (value === 'mergeCommits') { - onDimMergeCommits?.(isChecked); - return; - } + const { checked } = $el; - const key = value as keyof GraphExcludeTypes; - const currentFilter = excludeTypes?.[key]; - if ((currentFilter == null && isChecked) || (currentFilter != null && currentFilter !== isChecked)) { - setExcludeTypes({ - ...excludeTypes, - [key]: isChecked, - }); - onExcludeType?.(key, isChecked); + switch ($el.value) { + case 'mergeCommits': + onChangeGraphConfiguration?.({ dimMergeCommits: checked }); + break; + + case 'onlyFollowFirstParent': + onChangeGraphConfiguration?.({ onlyFollowFirstParent: checked }); + break; + + case 'remotes': + case 'stashes': + case 'tags': { + const key = $el.value satisfies keyof GraphExcludeTypes; + const currentFilter = excludeTypes?.[key]; + if ((currentFilter == null && checked) || (currentFilter != null && currentFilter !== checked)) { + setExcludeTypes({ ...excludeTypes, [key]: checked }); + onChangeExcludeTypes?.(key, checked); + } + break; + } } }; - // This can only be applied to one radio button for now due to a bug in the component: https://github.com/microsoft/fast/issues/6381 - const handleLocalBranchFiltering = (e: Event | FormEvent) => { - const $el = e.target as HTMLInputElement; - const value = $el.value; - const isChecked = $el.checked; - const wantsAllBranches = value === 'branch-all' && isChecked; - if (isAllBranches === wantsAllBranches) { - return; - } - onIncludeOnlyRef?.(wantsAllBranches); + const handleBranchesVisibility = (e: SlChangeEvent): void => { + const $el = e.target as HTMLSelectElement; + if ($el == null) return; + + onChangeRefIncludes?.($el.value as GraphBranchesVisibility); }; const handleMissingAvatars = (emails: GraphAvatars) => { @@ -823,7 +790,7 @@ export function GraphWrapper({ onMissingRefsMetadata?.(metadata); }; - const handleToggleColumnSettings = (event: React.MouseEvent) => { + const handleToggleColumnSettings = (event: React.MouseEvent) => { const e = event.nativeEvent; const evt = new MouseEvent('contextmenu', { bubbles: true, @@ -841,10 +808,11 @@ export function GraphWrapper({ const handleOnColumnResized = (columnName: GraphColumnName, columnSettings: GraphColumnSetting) => { if (columnSettings.width) { - onColumnsChange?.({ + onChangeColumns?.({ [columnName]: { width: columnSettings.width, isHidden: columnSettings.isHidden, + mode: columnSettings.mode as GraphColumnMode, order: columnSettings.order, }, }); @@ -863,15 +831,15 @@ export function GraphWrapper({ for (const [columnName, config] of Object.entries(columnsSettings as GraphColumnsConfig)) { graphColumnsConfig[columnName] = { ...config }; } - onColumnsChange?.(graphColumnsConfig); + onChangeColumns?.(graphColumnsConfig); }; const handleOnToggleRefsVisibilityClick = (_event: any, refs: GraphRefOptData[], visible: boolean) => { - onRefsVisibilityChange?.(refs, visible); + onChangeRefsVisibility?.(refs, visible); }; const handleOnDoubleClickRef = ( - _event: React.MouseEvent, + _event: React.MouseEvent, refGroup: GraphRefGroup, _row: GraphRow, metadata?: GraphRefMetadataItem, @@ -882,366 +850,555 @@ export function GraphWrapper({ }; const handleOnDoubleClickRow = ( - _event: React.MouseEvent, + _event: React.MouseEvent, graphZoneType: GraphZoneType, row: GraphRow, ) => { - if (graphZoneType === REF_ZONE_TYPE || graphZoneType === GRAPH_ZONE_TYPE) return; + if (graphZoneType === refZone) return; onDoubleClickRow?.(row, true); }; + const handleRowContextMenu = (_event: React.MouseEvent, graphZoneType: GraphZoneType, graphRow: GraphRow) => { + if (graphZoneType === refZone) return; + + // If the row is in the current selection, use the typed selection context, otherwise clear it + const newSelectionContext = selectionContexts?.selectedShas.has(graphRow.sha) + ? selectionContexts.contexts.get(graphRow.type) + : emptySelectionContext; + + setContext({ + ...context, + graph: { + ...(context?.graph != null && typeof context.graph === 'string' + ? JSON.parse(context.graph) + : context?.graph), + ...newSelectionContext, + }, + }); + }; + + const computeSelectionContext = (_active: GraphRow, rows: GraphRow[]) => { + if (rows.length <= 1) { + setSelectionContexts(undefined); + return; + } + + const selectedShas = new Set(); + for (const row of rows) { + selectedShas.add(row.sha); + } + + // Group the selected rows by their type and only include ones that have row context + const grouped = groupByFilterMap( + rows, + r => r.type, + r => + r.contexts?.row != null + ? ((typeof r.contexts.row === 'string' + ? JSON.parse(r.contexts.row) + : r.contexts.row) as GraphItemContext) + : undefined, + ); + + const contexts: SelectionContexts['contexts'] = new Map(); + + for (let [type, items] of grouped) { + let webviewItems: string | undefined; + + const contextValues = new Set(); + for (const item of items) { + contextValues.add(item.webviewItem); + } + + if (contextValues.size === 1) { + webviewItems = first(contextValues); + } else if (contextValues.size > 1) { + // If there are multiple contexts, see if they can be boiled down into a least common denominator set + // Contexts are of the form `gitlens:++...`, can also contain multiple `:`, but assume the whole thing is the type + + const itemTypes = new Map>(); + + for (const context of contextValues) { + const [type, ...adds] = context.split('+'); + + let additionalContext = itemTypes.get(type); + if (additionalContext == null) { + additionalContext ??= new Map(); + itemTypes.set(type, additionalContext); + } + + // If any item has no additional context, then only the type is able to be used + if (adds.length === 0) { + additionalContext.clear(); + break; + } + + for (const add of adds) { + additionalContext.set(add, (additionalContext.get(add) ?? 0) + 1); + } + } + + if (itemTypes.size === 1) { + let additionalContext; + [webviewItems, additionalContext] = first(itemTypes)!; + + if (additionalContext.size > 0) { + const commonContexts = join( + filterMap(additionalContext, ([context, count]) => + count === items.length ? context : undefined, + ), + '+', + ); + + if (commonContexts) { + webviewItems += `+${commonContexts}`; + } + } + } else { + // If we have more than one type, something is wrong with our context key setup -- should NOT happen at runtime + debugger; + webviewItems = undefined; + items = []; + } + } + + const count = items.length; + contexts.set(type, { + listDoubleSelection: count === 2, + listMultiSelection: count > 1, + webviewItems: webviewItems, + webviewItemsValues: count > 1 ? items : undefined, + }); + } + + setSelectionContexts({ contexts: contexts, selectedShas: selectedShas }); + }; + const handleSelectGraphRows = (rows: GraphRow[]) => { - const active = rows[0]; + hover.current?.hide(); + + const active = rows[rows.length - 1]; const activeKey = active != null ? `${active.sha}|${active.date}` : undefined; // HACK: Ensure the main state is updated since it doesn't come from the extension state.activeRow = activeKey; setActiveRow(activeKey); setActiveDay(active?.date); + computeSelectionContext(active, rows); - onSelectionChange?.(rows); - }; - - const handleDismissAccount = () => { - setShowAccount(false); - onDismissBanner?.('trial'); + onChangeSelection?.(rows); }; - const renderAccountState = () => { - if (!subscription) return; - - let label = subscription.plan.effective.name; - let isPro = true; - let subText; - switch (subscription.state) { - case SubscriptionState.Free: - case SubscriptionState.FreePreviewTrialExpired: - case SubscriptionState.FreePlusTrialExpired: - isPro = false; - label = 'GitLens Free'; - break; - case SubscriptionState.FreeInPreviewTrial: - case SubscriptionState.FreePlusInTrial: { - const days = getSubscriptionTimeRemaining(subscription, 'days') ?? 0; - label = 'GitLens Pro (Trial)'; - subText = `${days < 1 ? '<1 day' : pluralize('day', days)} left`; - break; - } - case SubscriptionState.VerificationRequired: - isPro = false; - label = `${label} (Unverified)`; - break; - } - - return ( - - - ✨ {label} - {subText && ( - <> -    - {subText} - - )} - - - {isPro - ? 'You have access to all GitLens and GitLens+ features on any repo.' - : 'You have access to GitLens+ features on local & public repos, and all other GitLens features on any repo.'} -
    -
    ✨ indicates GitLens+ features -
    -
    + const renderFetchAction = () => { + let action: 'fetch' | 'pull' | 'push' = 'fetch'; + let icon = 'repo-fetch'; + let label = 'Fetch'; + let isBehind = false; + let isAhead = false; + + const remote = branchState?.upstream ? ( + <> + {branchState?.upstream} + + ) : ( + 'remote' ); - }; - const renderAlertContent = () => { - if (subscription == null || !isRepoPrivate || (isAccessAllowed && !showAccount)) return; + let tooltip; + if (branchState) { + isAhead = branchState.ahead > 0; + isBehind = branchState.behind > 0; - let icon = 'account'; - let modifier = ''; - let content; - let actions; - let days = 0; - if ([SubscriptionState.FreeInPreviewTrial, SubscriptionState.FreePlusInTrial].includes(subscription.state)) { - days = getSubscriptionTimeRemaining(subscription, 'days') ?? 0; - } + const branchPrefix = ( + <> + {branchName} is + + ); - switch (subscription.state) { - case SubscriptionState.Free: - case SubscriptionState.Paid: - return; - case SubscriptionState.FreeInPreviewTrial: - icon = 'calendar'; - modifier = 'neutral'; - content = ( + if (isBehind) { + action = 'pull'; + icon = 'repo-pull'; + label = 'Pull'; + tooltip = ( <> -

    GitLens Pro Trial

    -

    - You have {days < 1 ? 'less than one day' : pluralize('day', days)} left in your 3-day - GitLens Pro trial. Don't worry if you need more time, you can extend your trial for an - additional free 7-days of the Commit Graph and other{' '} - GitLens+ features on private repos. -

    + Pull {pluralize('commit', branchState.behind)} from {remote} + {branchState.provider?.name ? ` on ${branchState.provider?.name}` : ''} ); - break; - case SubscriptionState.FreePlusInTrial: - icon = 'calendar'; - modifier = 'neutral'; - content = ( - <> -

    GitLens Pro Trial

    -

    - You have {days < 1 ? 'less than one day' : pluralize('day', days)} left in your GitLens Pro - trial. Once your trial ends, you'll continue to have access to the Commit Graph and other{' '} - GitLens+ features on local and public repos, while - upgrading to GitLens Pro gives you access on private repos. -

    - - ); - break; - case SubscriptionState.FreePreviewTrialExpired: - icon = 'warning'; - modifier = 'warning'; - content = ( - <> -

    Extend Your GitLens Pro Trial

    -

    - Your free 3-day GitLens Pro trial has ended, extend your trial to get an additional free - 7-days of the Commit Graph and other{' '} - GitLens+ features on private repos. -

    - - ); - actions = ( - - Extend Pro Trial - - ); - break; - case SubscriptionState.FreePlusTrialExpired: - icon = 'warning'; - modifier = 'warning'; - content = ( - <> -

    GitLens Pro Trial Expired

    -

    - Your GitLens Pro trial has ended, please upgrade to GitLens Pro to continue to use the - Commit Graph and other GitLens+ features on private - repos. -

    - - ); - actions = ( - - Upgrade to Pro - - ); - break; - case SubscriptionState.VerificationRequired: - icon = 'unverified'; - modifier = 'warning'; - content = ( - <> -

    Please verify your email

    -

    - Before you can use GitLens+ features on private - repos, please verify your email address. -

    - - ); - actions = ( + if (isAhead) { + tooltip = ( + <> + {tooltip} +
    + {branchPrefix} {pluralize('commit', branchState.behind)} behind and{' '} + {pluralize('commit', branchState.ahead)} ahead of {remote} + {branchState.provider?.name ? ` on ${branchState.provider?.name}` : ''} + + ); + } else { + tooltip = ( + <> + {tooltip} +
    + {branchPrefix} {pluralize('commit', branchState.behind)} behind {remote} + {branchState.provider?.name ? ` on ${branchState.provider?.name}` : ''} + + ); + } + } else if (isAhead) { + action = 'push'; + icon = 'repo-push'; + label = 'Push'; + tooltip = ( <> - - Resend Verification Email - - - Refresh Verification Status - + Push {pluralize('commit', branchState.ahead)} to {remote} + {branchState.provider?.name ? ` on ${branchState.provider?.name}` : ''} +
    + {branchPrefix} {pluralize('commit', branchState.ahead)} ahead of {remote} ); - break; + } } + const lastFetchedDate = lastFetched && new Date(lastFetched); + const fetchedText = lastFetchedDate && lastFetchedDate.getTime() !== 0 ? fromNow(lastFetchedDate) : undefined; + return ( -
    -
    - -
    - {content} - {actions &&
    {actions}
    } -
    - {isAccessAllowed && ( - - )} -
    -
    + <> + {(isBehind || isAhead) && ( + + + + {label} + {(isAhead || isBehind) && ( + + + {isBehind && ( + + {branchState!.behind} + + + )} + {isAhead && ( + + {isBehind && <>  } + {branchState!.ahead} + + + )} + + + )} + +
    + {tooltip} + {fetchedText && ( + <> +
    Last fetched {fetchedText} + + )} +
    +
    + )} + + + + Fetch {fetchedText && ({fetchedText})} + + + Fetch from {remote} + {branchState?.provider?.name ? ` on ${branchState.provider?.name}` : ''} + {fetchedText && ( + <> +
    Last fetched {fetchedText} + + )} +
    +
    + ); }; return ( <> - {renderAlertContent()}
    -
    - - {repo && ( +
    + {repo?.provider?.url && ( <> - - - - - {branchName} + + + + + Open Repository on {repo.provider.name} + + {repo?.provider?.connected !== true && ( + + )} + + )} + + + Switch to Another Repository... + + {allowed && repo && ( + <> + + + + {branchState?.pr && ( + + +
    + + branchState.pr?.id ? onOpenPullRequest?.(branchState.pr) : undefined + } + /> +
    +
    + )} + + + {!branchState?.pr ? ( + + ) : ( + '' + )} + {branchName} + + +
    + + Switch to Another Branch... +
    + {' '} + {branchName} +
    +
    +
    + + + + Jump to HEAD +
    + [Alt] Jump to Reference... +
    +
    - - Fetch{' '} - {lastFetched && (Last fetched {fromNow(new Date(lastFetched))})} - + {renderFetchAction()} )}
    -
    - {state.debugging && ( - - {isLoading && } - {rows.length > 0 && ( - - showing {rows.length} item{rows.length ? 's' : ''} - - )} +
    + + ), + )}`} + className="action-button" + > + + + + + Launchpad — organizes your pull requests into actionable + groups to help you focus and keep your team unblocked + + + {(subscription == null || !isSubscriptionPaid(subscription)) && ( + )} - {renderAccountState()} - - -
    - {isAccessAllowed && ( + {allowed && (
    - - - - Filter options - - - {repo?.isVirtual !== true && ( - - Show All Local Branches - - )} - - Show Current Branch Only - - - - - {repo?.isVirtual !== true && ( - <> - - - Hide Remote Branches - - - - - Hide Stashes - - - - )} - - - Hide Tags - - - - - - Dim Merge Commit Rows - - - - + + + + + All Branches + + + Smart Branches + {!repo?.isVirtual ? ( + + + + Shows only relevant branches +
    +
    + + Includes the current branch, its upstream, and its base or + target branch + +
    +
    + ) : ( + + )} +
    + Current Branch +
    +
    + + + + + Graph Filters + {repo?.isVirtual !== true && ( + <> + + + + Simplify Merge History + + + + + + + Hide Remote-only Branches + + + + + Hide Stashes + + + + )} + + + Hide Tags + + + + + + Dim Merge Commit Rows + + + + + Graph Filtering + - handleSearchInput(e as CustomEvent)} - onNavigate={e => handleSearchNavigation(e as CustomEvent)} + onChange={e => handleSearchInput(e)} + onNavigate={e => handleSearchNavigation(e)} onOpenInView={() => handleSearchOpenInView()} /> - + + + + Toggle Minimap + + + + + + Minimap + + + + Commits + + + Lines Changed + + + + + Markers + + + + Local Branches + + + + + + Remote Branches + + + + + + Pull Requests + + + + + + Stashes + + + + + + Tags + + + + + Minimap Options + +
    )} -
    +
    - {graphConfig?.minimap && ( - handleOnMinimapDaySelected(e as CustomEvent)} - > - )} -
    - {!isAccessAllowed &&
    } +

    + Commit Graph + {' '} + — easily visualize your repository and keep track of all work in progress. Use the rich commit + search to find a specific commit, message, author, a changed file or files, or even a specific code + change. +

    + + handleOnMinimapDaySelected(e)} + > + +
    + {repo !== undefined ? ( <> No repository is selected

    )} -
    ); @@ -1364,16 +1665,16 @@ export function GraphWrapper({ function formatCommitDateTime( date: number, - style: DateStyle = DateStyle.Absolute, + style: DateStyle = 'absolute', format: DateTimeFormat | string = 'short+short', - source?: GraphCommitDateTimeSource, + source?: CommitDateTimeSources, ): string { switch (source) { - case GraphCommitDateTimeSources.Tooltip: + case CommitDateTimeSources.Tooltip: return `${formatDate(date, format)} (${fromNow(date)})`; - case GraphCommitDateTimeSources.RowEntry: + case CommitDateTimeSources.RowEntry: default: - return style === DateStyle.Relative ? fromNow(date) : formatDate(date, format); + return style === 'relative' ? fromNow(date) : formatDate(date, format); } } @@ -1495,7 +1796,3 @@ function getSearchResultModel(state: State): { } return { results: results, resultsError: resultsError }; } - -function getDay(date: number | Date): number { - return new Date(date).setHours(0, 0, 0, 0); -} diff --git a/src/webviews/apps/plus/graph/graph.html b/src/webviews/apps/plus/graph/graph.html index 5fe8f1b7037b4..8285498b0033e 100644 --- a/src/webviews/apps/plus/graph/graph.html +++ b/src/webviews/apps/plus/graph/graph.html @@ -1,30 +1,30 @@ - + - - - - -
    -

    A repository must be selected.

    -
    - #{endOfBody} - + + + + +
    + #{endOfBody} diff --git a/src/webviews/apps/plus/graph/graph.scss b/src/webviews/apps/plus/graph/graph.scss index 59432ffe3a1eb..de10904cf6e27 100644 --- a/src/webviews/apps/plus/graph/graph.scss +++ b/src/webviews/apps/plus/graph/graph.scss @@ -1,3 +1,4 @@ +@use '../../shared/styles/theme'; @import '../../shared/base'; @import '../../shared/codicons'; @import '../../shared/glicons'; @@ -11,41 +12,34 @@ .vscode-high-contrast, .vscode-dark { --popover-bg: var(--color-background--lighten-15); + --titlebar-bg: var(--color-background--lighten-075); } .vscode-high-contrast-light, .vscode-light { --popover-bg: var(--color-background--darken-15); + --titlebar-bg: var(--color-background--darken-075); } -body { - .vertical_scrollbar, - .horizontal_scrollbar { - border-color: transparent; - transition: border-color 1s linear; - } - - &:hover, - &:focus-within { - .vertical_scrollbar, - .horizontal_scrollbar { - transition: border-color 0.1s linear; - border-color: var(--vscode-scrollbarSlider-background); - } - } - +:root { + --titlebar-fg: var(--color-foreground--65); --color-graph-contrast-border: var(--vscode-list-focusOutline); --color-graph-selected-row: var(--vscode-list-activeSelectionBackground); --color-graph-hover-row: var(--vscode-list-hoverBackground); --color-graph-text-selected-row: var(--vscode-list-activeSelectionForeground); - --color-graph-text-dimmed-selected: var(--vscode-list-activeSelectionForeground); - --color-graph-text-dimmed: var(--vscode-list-activeSelectionForeground); + --color-graph-text-dimmed-selected: color-mix( + in srgb, + transparent 50%, + var(--vscode-list-activeSelectionForeground) + ); + --color-graph-text-selected: var(--vscode-editor-foreground, var(--vscode-foreground)); + --color-graph-text-dimmed: color-mix(in srgb, transparent 80%, var(--color-graph-text-selected)); + --color-graph-actionbar-selectedBackground: var(--vscode-toolbar-hoverBackground); --color-graph-text-hovered: var(--vscode-list-hoverForeground); - --color-graph-text-selected: var(--vscode-editor-foreground, var(--vscode-foreground)); - --color-graph-text-normal: var(--color-graph-text-selected); - --color-graph-text-secondary: var(--color-graph-text-selected); - --color-graph-text-disabled: var(--color-graph-text-selected); + --color-graph-text-normal: color-mix(in srgb, transparent 15%, var(--color-graph-text-selected)); + --color-graph-text-secondary: color-mix(in srgb, transparent 35%, var(--color-graph-text-selected)); + --color-graph-text-disabled: color-mix(in srgb, transparent 50%, var(--color-graph-text-selected)); --color-graph-stats-added: var(--vscode-gitlens-graphChangesColumnAddedColor); --color-graph-stats-deleted: var(--vscode-gitlens-graphChangesColumnDeletedColor); @@ -63,6 +57,8 @@ body { --color-graph-scroll-marker-highlights: var(--vscode-gitlens-graphScrollMarkerHighlightsColor); --color-graph-minimap-marker-local-branches: var(--vscode-gitlens-graphMinimapMarkerLocalBranchesColor); --color-graph-scroll-marker-local-branches: var(--vscode-gitlens-graphScrollMarkerLocalBranchesColor); + --color-graph-minimap-marker-pull-requests: var(--vscode-gitlens-graphMinimapMarkerPullRequestsColor); + --color-graph-scroll-marker-pull-requests: var(--vscode-gitlens-graphScrollMarkerPullRequestsColor); --color-graph-minimap-marker-remote-branches: var(--vscode-gitlens-graphMinimapMarkerRemoteBranchesColor); --color-graph-scroll-marker-remote-branches: var(--vscode-gitlens-graphScrollMarkerRemoteBranchesColor); --color-graph-minimap-marker-stashes: var(--vscode-gitlens-graphMinimapMarkerStashesColor); @@ -74,6 +70,10 @@ body { --color-graph-minimap-tip-headBorder: var(--color-graph-scroll-marker-head); --color-graph-minimap-tip-headForeground: var(--vscode-editor-foreground, var(--vscode-foreground)); + --color-graph-minimap-tip-highlightBackground: var(--color-graph-scroll-marker-highlights); + --color-graph-minimap-tip-highlightBorder: var(--color-graph-scroll-marker-highlights); + --color-graph-minimap-tip-highlightForeground: var(--vscode-editor-foreground, var(--vscode-foreground)); + --color-graph-minimap-tip-branchBackground: var(--color-graph-scroll-marker-local-branches); --color-graph-minimap-tip-branchBorder: var(--color-graph-scroll-marker-local-branches); --color-graph-minimap-tip-branchForeground: var(--vscode-editor-foreground, var(--vscode-foreground)); @@ -89,6 +89,10 @@ body { --color-graph-minimap-tip-stashBorder: var(--color-graph-scroll-marker-stashes); --color-graph-minimap-tip-stashForeground: var(--vscode-editor-foreground, var(--vscode-foreground)); + --color-graph-minimap-pullRequestBackground: var(--color-graph-scroll-marker-pull-requests); + --color-graph-minimap-pullRequestBorder: var(--color-graph-scroll-marker-pull-requests); + --color-graph-minimap-pullRequestForeground: var(--vscode-editor-foreground, var(--vscode-foreground)); + --color-graph-minimap-tip-tagBackground: var(--color-graph-scroll-marker-tags); --color-graph-minimap-tip-tagBorder: var(--color-graph-scroll-marker-tags); --color-graph-minimap-tip-tagForeground: var(--vscode-editor-foreground, var(--vscode-foreground)); @@ -99,6 +103,50 @@ body { --graph-stats-bar-height: 40%; --graph-stats-bar-border-radius: 3px; + + --branch-status-ahead-foreground: var(--vscode-gitlens-decorations\.branchAheadForegroundColor); + --branch-status-behind-foreground: var(--vscode-gitlens-decorations\.branchBehindForegroundColor); + --branch-status-both-foreground: var(--vscode-gitlens-decorations\.branchDivergedForegroundColor); + + --graph-column-scrollbar-thickness: 14px; +} + +:root:has(.vscode-dark, .vscode-high-contrast) { + --graph-theme-opacity-factor: '1'; + + --color-graph-actionbar-background: color-mix(in srgb, #fff 5%, var(--color-background)); + --color-graph-background: color-mix(in srgb, #fff 5%, var(--color-background)); + --color-graph-background2: color-mix(in srgb, #fff 10%, var(--color-background)); +} + +:root:has(.vscode-light, .vscode-high-contrast-light) { + --graph-theme-opacity-factor: '0.5'; + + --color-graph-actionbar-background: color-mix(in srgb, #000 5%, var(--color-background)); + --color-graph-background: color-mix(in srgb, #000 5%, var(--color-background)); + --color-graph-background2: color-mix(in srgb, #000 10%, var(--color-background)); +} + +body { + .vertical_scrollbar, + .horizontal_scrollbar { + border-color: transparent; + transition: border-color 1s linear; + } + + &:hover, + &:focus-within { + .vertical_scrollbar, + .horizontal_scrollbar { + transition: border-color 0.1s linear; + border-color: var(--vscode-scrollbarSlider-background); + } + } +} + +::-webkit-scrollbar { + width: var(--graph-column-scrollbar-thickness); + height: var(--graph-column-scrollbar-thickness); } ::-webkit-scrollbar-corner { @@ -136,6 +184,23 @@ button:not([disabled]), } } +.pill { + display: inline-block; + padding: 0.2rem 0.5rem; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 500; + line-height: 1.2; + text-transform: uppercase; + color: var(--vscode-foreground); + background-color: var(--vscode-editorWidget-background); + + .codicon[class*='codicon-'] { + font-size: inherit !important; + line-height: inherit !important; + } +} + .badge { font-size: 1rem; font-weight: 700; @@ -160,6 +225,7 @@ button:not([disabled]), width: max-content; right: 0; top: 100%; + white-space: normal; } &:not(:hover) + &-popover { @@ -167,6 +233,10 @@ button:not([disabled]), } } +.jump-to-ref { + --button-foreground: var(--color-foreground); +} + .action-button { position: relative; appearance: none; @@ -183,6 +253,12 @@ button:not([disabled]), border-radius: 3px; height: auto; + display: grid; + grid-auto-flow: column; + grid-gap: 0.5rem; + gap: 0.5rem; + max-width: fit-content; + &[disabled] { pointer-events: none; cursor: default; @@ -195,25 +271,46 @@ button:not([disabled]), text-decoration: none; } - .codicon[class*='codicon-'] { + &[aria-checked] { + border: 1px solid transparent; + } + + &[aria-checked='true'] { + background-color: var(--vscode-inputOption-activeBackground); + color: var(--vscode-inputOption-activeForeground); + border-color: var(--vscode-inputOption-activeBorder); + } + + .codicon[class*='codicon-'], + .glicon[class*='glicon-'] { line-height: 2.2rem; vertical-align: bottom; } - &__icon, - &__icon.codicon[class*='codicon-'] { - margin-right: 0.4rem; + .codicon[class*='codicon-graph-line'] { + transform: translateY(1px); } - &__icon:not(.codicon) { - display: inline-block; - width: 1.6rem; + + &__pill { + .is-ahead & { + background-color: var(--branch-status-ahead-pill-background); + } + .is-behind & { + background-color: var(--branch-status-behind-pill-background); + } + .is-ahead.is-behind & { + background-color: var(--branch-status-both-pill-background); + } } &__more, &__more.codicon[class*='codicon-'] { font-size: 1rem; - margin-left: 0.4rem; - margin-right: -0.35rem; + margin-right: -0.25rem; + } + + &__more.codicon[class*='codicon-']::before { + margin-left: -0.25rem; } &__indicator { @@ -227,8 +324,33 @@ button:not([disabled]), background-color: var(--vscode-progressBar-background); } - small { + &__small { + font-size: smaller; opacity: 0.6; + text-overflow: ellipsis; + overflow: hidden; + } + + &.is-ahead { + background-color: var(--branch-status-ahead-background); + + &:hover { + background-color: var(--branch-status-ahead-hover-background); + } + } + &.is-behind { + background-color: var(--branch-status-behind-background); + + &:hover { + background-color: var(--branch-status-behind-hover-background); + } + } + &.is-ahead.is-behind { + background-color: var(--branch-status-both-background); + + &:hover { + background-color: var(--branch-status-both-hover-background); + } } } @@ -248,7 +370,7 @@ button:not([disabled]), width: 0.1rem; height: 2.2rem; vertical-align: middle; - background-color: var(--vscode-titleBar-inactiveForeground); + background-color: var(--titlebar-fg); opacity: 0.4; margin: { // left: 0.2rem; @@ -256,6 +378,50 @@ button:not([disabled]), } } +.button-group { + display: flex; + flex-direction: row; + align-items: stretch; + + &:hover, + &:focus-within { + background-color: var(--color-graph-actionbar-selectedBackground); + border-radius: 3px; + } + + > *:not(:first-child), + > *:not(:first-child) .action-button { + display: flex; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + > *:not(:first-child) .action-button { + padding-left: 0.5rem; + padding-right: 0.5rem; + height: 100%; + } + + // > *:not(:last-child), + // > *:not(:last-child) .action-button { + // padding-right: 0.5rem; + // } + + &:hover > *:not(:last-child), + &:active > *:not(:last-child), + &:focus-within > *:not(:last-child), + &:hover > *:not(:last-child) .action-button, + &:active > *:not(:last-child) .action-button, + &:focus-within > *:not(:last-child) .action-button { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + // > *:not(:first-child) { + // border-left: 0.1rem solid var(--titlebar-fg); + // } +} + .repo-access { font-size: 1.1em; margin-right: 0.2rem; @@ -265,21 +431,14 @@ button:not([disabled]), } } -.column-button { - --column-button-height: 20px; - - position: absolute; - top: 1px; - right: 0; - z-index: 2; +.columns-settings { + --column-button-height: 19px; appearance: none; font-family: inherit; background-color: transparent; border: none; - color: var(--text-disabled, hsla(0, 0%, 100%, 0.4)); - margin: 0; - padding: 0 4px; + color: var(--color-graph-text-disabled, hsla(0, 0%, 100%, 0.4)); height: var(--column-button-height); cursor: pointer; background-color: var(--color-graph-actionbar-background); @@ -302,9 +461,14 @@ button:not([disabled]), .codicon[class*='codicon-'] { font-size: 1.1rem; + position: relative; } } +.gk-graph.bs-tooltip { + z-index: 1040; +} + .alert { --alert-foreground: var(--color-alert-foreground); --alert-background: var(--color-alert-infoBackground); @@ -471,6 +635,37 @@ button:not([disabled]), content: '\ebaa'; } } + &--remote-github, + &--remote-githubEnterprise { + &::before { + // codicon-github-inverted + font-family: codicon; + content: '\eba1'; + } + } + &--remote-gitlab, + &--remote-gitlabSelfHosted { + &::before { + // glicon-provider-gitlab + font-family: 'glicons'; + content: '\f123'; + } + } + &--remote-bitbucket, + &--remote-bitbucketServer { + &::before { + // glicon-provider-bitbucket + font-family: 'glicons'; + content: '\f11f'; + } + } + &--remote-azureDevops { + &::before { + // glicon-provider-azdo + font-family: 'glicons'; + content: '\f11e'; + } + } &--tag { &::before { // codicon-tag @@ -493,12 +688,12 @@ button:not([disabled]), } } &--warning { + color: #de9b43; :before { // codicon-vm font-family: codicon; content: '\ea6c'; } - color: #de9b43; } &--added { &::before { @@ -570,11 +765,93 @@ button:not([disabled]), content: '\ea9a'; } } + &--settings { + &::before { + // codicon-settings-gear + font-family: codicon; + content: '\eb51'; + } + } + &--branch { + &::before { + // codicon-git-branch + font-family: codicon; + content: '\ea68'; + top: 0px; + margin: 0 0 0 0; + } + } + + &--graph { + &::before { + // glicon-graph + font-family: glicons; + content: '\f102'; + } + } + + &--commit { + &::before { + // codicon-git-commit + font-family: codicon; + content: '\eafc'; + top: 0px; + margin: 0 0 0 0; + } + } + + &--author { + &::before { + // codicon-account + font-family: codicon; + content: '\eb99'; + } + } + + &--datetime { + &::before { + // glicon-clock + font-family: glicons; + content: '\f11d'; + } + } + + &--message { + &::before { + // codicon-comment + font-family: codicon; + content: '\ea6b'; + } + } + + &--changes { + &::before { + // codicon-request-changes + font-family: codicon; + content: '\eb43'; + } + } + + &--files { + &::before { + // codicon-file + font-family: codicon; + content: '\eb60'; + } + } + + &--worktree { + &::before { + // glicon-repositories-view + font-family: glicons; + content: '\f10e'; + } + } } .titlebar { - background: var(--vscode-titleBar-inactiveBackground); - color: var(--vscode-titleBar-inactiveForeground); + background: var(--titlebar-bg); + color: var(--titlebar-fg); padding: { left: 0.8rem; right: 0.8rem; @@ -606,16 +883,19 @@ button:not([disabled]), flex: 0 0 100%; &--wrap { - flex-wrap: wrap; + display: grid; + grid-auto-flow: column; + justify-content: start; + grid-template-columns: minmax(min-content, 1fr) min-content; } } &__group { flex: auto 1 1; + } - &--fixed { - flex: none; - } + &__row--wrap &__group { + white-space: nowrap; } &__debugging { @@ -623,21 +903,31 @@ button:not([disabled]), display: inline-block; } } + + gl-feature-badge { + color: var(--color-foreground); + } +} + +gl-feature-gate gl-feature-badge { + vertical-align: super; + margin-left: 0.4rem; + margin-right: 0.4rem; } .graph-app { --fs-1: 1.1rem; --fs-2: 1.3rem; - --scroll-thumb-bg: var(--vscode-scrollbarSlider-background); padding: 0; + overflow: hidden; &__container { display: flex; flex-direction: column; height: calc(100vh - 2px); // shoot me -- the 2px is to stop the vertical scrollbar from showing up gap: 0; - padding: 0.2rem 0.2rem 0; + padding: 0.1rem; } &__banners { @@ -653,21 +943,16 @@ button:not([disabled]), margin-top: 0.5rem; } } - &__cover { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 1999; - backdrop-filter: blur(4px) saturate(0.8); + + &__gate { + // top: 34px; /* height of the header bar */ + padding-top: 34px; } &__header { flex: none; - z-index: 2000; + z-index: 101; position: relative; - margin-right: 2px; } &__footer { @@ -678,6 +963,7 @@ button:not([disabled]), flex: 1 1 auto; overflow: hidden; position: relative; + display: flex; } &__main.is-gated { @@ -686,7 +972,17 @@ button:not([disabled]), } } -.graph-header { +.gk-graph:not(.ref-zone):not([role='tooltip']) { + flex: 1 1 auto; + position: relative; +} + +// Add when graph ref-zone "container" changes +// .gk-graph.ref-zone { +// position: absolute; +// } + +.gk-graph .graph-header { & .resizable-handle.horizontal { --sash-size: 4px; --sash-hover-size: 4px; @@ -696,6 +992,19 @@ button:not([disabled]), height: 100vh !important; z-index: 1000; + &:after { + content: ''; + border-left: 1px solid var(--titlebar-fg); + position: absolute; + top: 0.3rem; + left: 0.1rem; + height: 1.6rem; + width: var(--sash-size); + opacity: 0.3; + + transition: border-color 0.1s ease-out; + } + &:before { content: ''; pointer-events: none; @@ -715,6 +1024,11 @@ button:not([disabled]), transition-delay: 0.2s; background-color: var(--vscode-sash-hoverBorder); } + + &:after { + transition-delay: 0.2s; + border-left-color: var(--vscode-sash-hoverBorder); + } } &:active:after { @@ -728,9 +1042,13 @@ button:not([disabled]), } } + .columns-btn { + margin-top: 0.1rem; + } + .button { background-color: var(--color-graph-actionbar-background); - color: var(--text-disabled, hsla(0deg, 0%, 100%, 0.4)); + color: var(--color-graph-text-disabled, hsla(0deg, 0%, 100%, 0.4)); border-radius: 3px; &:hover { @@ -748,6 +1066,18 @@ button:not([disabled]), color: var(--color-foreground); } } + + .graph-icon { + color: var(--color-graph-text-disabled, hsla(0, 0%, 100%, 0.4)); + } +} + +.graph-app:not(:hover) { + .gk-graph .graph-header { + & .resizable-handle.horizontal:before { + display: none; + } + } } .graph-container { @@ -761,7 +1091,7 @@ button:not([disabled]), & .graph-adjust-commit-count { display: flex; - width: calc(100vw - var(--scrollable-scrollbar-thickness)); + width: calc(100vw - var(--graph-column-scrollbar-thickness)); align-items: center; justify-content: center; } @@ -861,24 +1191,123 @@ button:not([disabled]), } } -.tooltip { - font-size: var(--vscode-font-size); - font-family: var(--vscode-font-family); - font-weight: normal; - line-height: 19px; +.gk-graph .tooltip, +.gk-graph.tooltip { + font-size: var(--vscode-font-size) !important; + font-family: var(--vscode-font-family) !important; + font-weight: normal !important; + line-height: 19px !important; &.in { opacity: 1; } + + &.tooltip-arrow:after { + background-color: var(--color-hover-background) !important; + border-right-color: var(--color-hover-border) !important; + border-bottom-color: var(--color-hover-border) !important; + } + &-inner { - font-size: var(--vscode-font-size); - padding: 4px 10px 5px 10px; - color: var(--color-hover-foreground); - background-color: var(--color-hover-background); - border: 1px solid var(--color-hover-border); - border-radius: 0; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); - text-align: start; - white-space: break-spaces; + font-size: var(--vscode-font-size) !important; + padding: 4px 10px 5px 10px !important; + color: var(--color-hover-foreground) !important; + background-color: var(--color-hover-background) !important; + border: 1px solid var(--color-hover-border) !important; + border-radius: 0 !important; + box-shadow: 0 2px 8px var(--vscode-widget-shadow) !important; + text-align: start !important; + white-space: break-spaces !important; + } +} + +.minimap-marker-swatch { + display: inline-block; + width: 1.6rem; + height: 1.6rem; + border-radius: 3px; + margin-right: 0.75rem; + vertical-align: bottom; + + &[data-marker='localBranches'] { + background-color: var(--color-graph-minimap-marker-local-branches); + } + + &[data-marker='pullRequests'] { + background-color: var(--color-graph-minimap-marker-pull-requests); + } + + &[data-marker='remoteBranches'] { + background-color: var(--color-graph-minimap-marker-remote-branches); } + + &[data-marker='stashes'] { + background-color: var(--color-graph-minimap-marker-stashes); + } + + &[data-marker='tags'] { + background-color: var(--color-graph-minimap-marker-tags); + } +} + +hr { + border: none; + border-top: 1px solid var(--color-foreground--25); +} + +.md-code { + background: var(--vscode-textCodeBlock-background); + border-radius: 3px; + padding: 0px 4px 2px 4px; + font-family: var(--vscode-editor-font-family); +} + +gl-search-box::part(search) { + --gl-search-input-background: var(--color-graph-actionbar-background); + --gl-search-input-border: var(--sl-input-border-color); +} + +sl-option::part(base) { + padding: 0.2rem 0.4rem; +} + +sl-option[aria-selected='true']::part(base), +sl-option:not([aria-selected='true']):hover::part(base), +sl-option:not([aria-selected='true']):focus::part(base) { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +sl-option::part(checked-icon) { + display: none; +} + +sl-select::part(listbox) { + padding-block: 0.2rem 0; + width: max-content; +} + +sl-select::part(combobox) { + --sl-input-background-color: var(--color-graph-actionbar-background); + --sl-input-color: var(--color-foreground); + --sl-input-color-hover: var(--color-foreground); + padding: 0 0.75rem; + color: var(--color-foreground); + border-radius: var(--sl-border-radius-small); +} + +sl-select::part(display-input) { + field-sizing: content; +} + +sl-select::part(expand-icon) { + margin-inline-start: var(--sl-spacing-x-small); +} + +sl-select[open]::part(combobox) { + background-color: var(--color-graph-actionbar-background); +} +sl-select:hover::part(combobox), +sl-select:focus::part(combobox) { + background-color: var(--color-graph-actionbar-selectedBackground); } diff --git a/src/webviews/apps/plus/graph/graph.tsx b/src/webviews/apps/plus/graph/graph.tsx index 3ef8c07ddefd6..88d8f8feef836 100644 --- a/src/webviews/apps/plus/graph/graph.tsx +++ b/src/webviews/apps/plus/graph/graph.tsx @@ -1,11 +1,12 @@ /*global document window*/ -import type { CssVariables, GraphRef, GraphRow } from '@gitkraken/gitkraken-components'; +import type { CssVariables, GraphRef, GraphRefOptData, GraphRow } from '@gitkraken/gitkraken-components'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; +import type { GraphBranchesVisibility } from '../../../../config'; +import type { SearchQuery } from '../../../../constants.search'; import type { GitGraphRowType } from '../../../../git/models/graph'; -import type { SearchQuery } from '../../../../git/search'; import type { - DismissBannerParams, + DidSearchParams, GraphAvatars, GraphColumnsConfig, GraphExcludedRef, @@ -18,41 +19,46 @@ import type { UpdateStateCallback, } from '../../../../plus/webviews/graph/protocol'; import { - ChooseRepositoryCommandType, - DidChangeAvatarsNotificationType, - DidChangeColumnsNotificationType, - DidChangeGraphConfigurationNotificationType, - DidChangeNotificationType, - DidChangeRefsMetadataNotificationType, - DidChangeRefsVisibilityNotificationType, - DidChangeRowsNotificationType, - DidChangeSelectionNotificationType, - DidChangeSubscriptionNotificationType, - DidChangeWindowFocusNotificationType, - DidChangeWorkingTreeNotificationType, - DidEnsureRowNotificationType, - DidFetchNotificationType, - DidSearchNotificationType, - DimMergeCommitsCommandType, - DismissBannerCommandType, + ChooseRefRequest, + ChooseRepositoryCommand, + DidChangeAvatarsNotification, + DidChangeBranchStateNotification, + DidChangeColumnsNotification, + DidChangeGraphConfigurationNotification, + DidChangeNotification, + DidChangeRefsMetadataNotification, + DidChangeRefsVisibilityNotification, + DidChangeRepoConnectionNotification, + DidChangeRowsNotification, + DidChangeRowsStatsNotification, + DidChangeScrollMarkersNotification, + DidChangeSelectionNotification, + DidChangeSubscriptionNotification, + DidChangeWorkingTreeNotification, + DidFetchNotification, + DidSearchNotification, DoubleClickedCommandType, - EnsureRowCommandType, - GetMissingAvatarsCommandType, - GetMissingRefsMetadataCommandType, - GetMoreRowsCommandType, - SearchCommandType, - SearchOpenInViewCommandType, - UpdateColumnsCommandType, - UpdateExcludeTypeCommandType, - UpdateGraphConfigurationCommandType, - UpdateIncludeOnlyRefsCommandType, - UpdateRefsVisibilityCommandType, - UpdateSelectionCommandType, + EnsureRowRequest, + GetMissingAvatarsCommand, + GetMissingRefsMetadataCommand, + GetMoreRowsCommand, + GetRowHoverRequest, + OpenPullRequestDetailsCommand, + SearchOpenInViewCommand, + SearchRequest, + UpdateColumnsCommand, + UpdateExcludeTypesCommand, + UpdateGraphConfigurationCommand, + UpdateIncludedRefsCommand, + UpdateRefsVisibilityCommand, + UpdateSelectionCommand, } from '../../../../plus/webviews/graph/protocol'; -import { Color, darken, lighten, mix, opacity } from '../../../../system/color'; +import { Color, getCssVariable, mix, opacity } from '../../../../system/color'; +import { debug, log } from '../../../../system/decorators/log'; import { debounce } from '../../../../system/function'; -import type { IpcMessage, IpcNotificationType } from '../../../protocol'; -import { onIpc } from '../../../protocol'; +import { getLogScope, setLogScopeExit } from '../../../../system/logger.scope'; +import type { IpcMessage, IpcNotification } from '../../../protocol'; +import { DidChangeHostWindowFocusNotification } from '../../../protocol'; import { App } from '../../shared/appBase'; import type { ThemeChangeEvent } from '../../shared/theme'; import { GraphWrapper } from './GraphWrapper'; @@ -72,18 +78,17 @@ const graphLaneThemeColors = new Map([ ]); export class GraphApp extends App { - private callback?: UpdateStateCallback; + private updateStateCallback?: UpdateStateCallback; constructor() { super('GraphApp'); } + @log() protected override onBind() { const disposables = super.onBind?.() ?? []; // disposables.push(DOM.on(window, 'keyup', e => this.onKeyUp(e))); - this.log(`onBind()`); - this.ensureTheming(this.state); const $root = document.getElementById('root'); @@ -92,33 +97,34 @@ export class GraphApp extends App { this.registerEvents(callback)} - onColumnsChange={debounce( + subscriber={(updateState: UpdateStateCallback) => this.registerUpdateStateCallback(updateState)} + onChangeColumns={debounce( settings => this.onColumnsChanged(settings), 250, )} - onDimMergeCommits={dim => this.onDimMergeCommits(dim)} - onRefsVisibilityChange={(refs: GraphExcludedRef[], visible: boolean) => + onChangeExcludeTypes={this.onExcludeTypesChanged.bind(this)} + onChangeGraphConfiguration={this.onGraphConfigurationChanged.bind(this)} + onChangeRefIncludes={this.onRefIncludesChanged.bind(this)} + onChangeRefsVisibility={(refs: GraphExcludedRef[], visible: boolean) => this.onRefsVisibilityChanged(refs, visible) } + onChangeSelection={debounce( + rows => this.onSelectionChanged(rows), + 250, + )} onChooseRepository={debounce(() => this.onChooseRepository(), 250)} onDoubleClickRef={(ref, metadata) => this.onDoubleClickRef(ref, metadata)} onDoubleClickRow={(row, preserveFocus) => this.onDoubleClickRow(row, preserveFocus)} + onEnsureRowPromise={this.onEnsureRowPromise.bind(this)} + onHoverRowPromise={(row: GraphRow) => this.onHoverRowPromise(row)} + onJumpToRefPromise={(shift: boolean) => this.onJumpToRefPromise(shift)} onMissingAvatars={(...params) => this.onGetMissingAvatars(...params)} onMissingRefsMetadata={(...params) => this.onGetMissingRefsMetadata(...params)} onMoreRows={(...params) => this.onGetMoreRows(...params)} + onOpenPullRequest={(...params) => this.onOpenPullRequest(...params)} onSearch={debounce((search, options) => this.onSearch(search, options), 250)} onSearchPromise={(...params) => this.onSearchPromise(...params)} onSearchOpenInView={(...params) => this.onSearchOpenInView(...params)} - onSelectionChange={debounce( - rows => this.onSelectionChanged(rows), - 250, - )} - onDismissBanner={key => this.onDismissBanner(key)} - onEnsureRowPromise={this.onEnsureRowPromise.bind(this)} - onExcludeType={this.onExcludeType.bind(this)} - onIncludeOnlyRef={this.onIncludeOnlyRef.bind(this)} - onUpdateGraphConfiguration={this.onUpdateGraphConfiguration.bind(this)} />, $root, ); @@ -139,219 +145,182 @@ export class GraphApp extends App { // } // } - protected override onMessageReceived(e: MessageEvent) { - const msg = e.data as IpcMessage; - this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); + protected override onMessageReceived(msg: IpcMessage) { + const scope = getLogScope(); - switch (msg.method) { - case DidChangeNotificationType.method: - onIpc(DidChangeNotificationType, msg, (params, type) => { - this.setState({ ...this.state, ...params.state }, type); - }); + switch (true) { + case DidChangeNotification.is(msg): + this.setState({ ...this.state, ...msg.params.state }, DidChangeNotification); break; - case DidFetchNotificationType.method: - onIpc(DidFetchNotificationType, msg, (params, type) => { - this.state.lastFetched = params.lastFetched; - this.setState(this.state, type); - }); + case DidFetchNotification.is(msg): + this.state.lastFetched = msg.params.lastFetched; + this.setState(this.state, DidFetchNotification); break; - case DidChangeAvatarsNotificationType.method: - onIpc(DidChangeAvatarsNotificationType, msg, (params, type) => { - this.state.avatars = params.avatars; - this.setState(this.state, type); - }); + case DidChangeAvatarsNotification.is(msg): + this.state.avatars = msg.params.avatars; + this.setState(this.state, DidChangeAvatarsNotification); break; - case DidChangeWindowFocusNotificationType.method: - onIpc(DidChangeWindowFocusNotificationType, msg, (params, type) => { - this.state.windowFocused = params.focused; - this.setState(this.state, type); - }); + case DidChangeBranchStateNotification.is(msg): + this.state.branchState = msg.params.branchState; + this.setState(this.state, DidChangeBranchStateNotification); break; - case DidChangeColumnsNotificationType.method: - onIpc(DidChangeColumnsNotificationType, msg, (params, type) => { - this.state.columns = params.columns; - if (params.context != null) { - if (this.state.context == null) { - this.state.context = { header: params.context }; - } else { - this.state.context.header = params.context; - } - } else if (this.state.context?.header != null) { - this.state.context.header = undefined; - } + case DidChangeHostWindowFocusNotification.is(msg): + this.state.windowFocused = msg.params.focused; + this.setState(this.state, DidChangeHostWindowFocusNotification); + break; - this.setState(this.state, type); - }); + case DidChangeColumnsNotification.is(msg): + this.state.columns = msg.params.columns; + this.state.context = { + ...this.state.context, + header: msg.params.context, + settings: msg.params.settingsContext, + }; + this.setState(this.state, DidChangeColumnsNotification); break; - case DidChangeRefsVisibilityNotificationType.method: - onIpc(DidChangeRefsVisibilityNotificationType, msg, (params, type) => { - this.state.excludeRefs = params.excludeRefs; - this.state.excludeTypes = params.excludeTypes; - this.state.includeOnlyRefs = params.includeOnlyRefs; - this.setState(this.state, type); - }); + case DidChangeRefsVisibilityNotification.is(msg): + this.state.branchesVisibility = msg.params.branchesVisibility; + this.state.excludeRefs = msg.params.excludeRefs; + this.state.excludeTypes = msg.params.excludeTypes; + this.state.includeOnlyRefs = msg.params.includeOnlyRefs; + this.setState(this.state, DidChangeRefsVisibilityNotification); break; - case DidChangeRefsMetadataNotificationType.method: - onIpc(DidChangeRefsMetadataNotificationType, msg, (params, type) => { - this.state.refsMetadata = params.metadata; - this.setState(this.state, type); - }); + case DidChangeRefsMetadataNotification.is(msg): + this.state.refsMetadata = msg.params.metadata; + this.setState(this.state, DidChangeRefsMetadataNotification); break; - case DidChangeRowsNotificationType.method: - onIpc(DidChangeRowsNotificationType, msg, (params, type) => { - let rows; - if (params.rows.length && params.paging?.startingCursor != null && this.state.rows != null) { - const previousRows = this.state.rows; - const lastId = previousRows[previousRows.length - 1]?.sha; - - let previousRowsLength = previousRows.length; - const newRowsLength = params.rows.length; - - this.log( - `onMessageReceived(${msg.id}:${msg.method}): paging in ${newRowsLength} rows into existing ${previousRowsLength} rows at ${params.paging.startingCursor} (last existing row: ${lastId})`, - ); - - rows = []; - // Preallocate the array to avoid reallocations - rows.length = previousRowsLength + newRowsLength; - - if (params.paging.startingCursor !== lastId) { - this.log( - `onMessageReceived(${msg.id}:${msg.method}): searching for ${params.paging.startingCursor} in existing rows`, - ); - - let i = 0; - let row; - for (row of previousRows) { - rows[i++] = row; - if (row.sha === params.paging.startingCursor) { - this.log( - `onMessageReceived(${msg.id}:${msg.method}): found ${params.paging.startingCursor} in existing rows`, - ); - - previousRowsLength = i; - - if (previousRowsLength !== previousRows.length) { - // If we stopped before the end of the array, we need to trim it - rows.length = previousRowsLength + newRowsLength; - } - - break; + case DidChangeRowsNotification.is(msg): { + let rows; + if (msg.params.rows.length && msg.params.paging?.startingCursor != null && this.state.rows != null) { + const previousRows = this.state.rows; + const lastId = previousRows[previousRows.length - 1]?.sha; + + let previousRowsLength = previousRows.length; + const newRowsLength = msg.params.rows.length; + + this.log( + scope, + `paging in ${newRowsLength} rows into existing ${previousRowsLength} rows at ${msg.params.paging.startingCursor} (last existing row: ${lastId})`, + ); + + rows = []; + // Preallocate the array to avoid reallocations + rows.length = previousRowsLength + newRowsLength; + + if (msg.params.paging.startingCursor !== lastId) { + this.log(scope, `searching for ${msg.params.paging.startingCursor} in existing rows`); + + let i = 0; + let row; + for (row of previousRows) { + rows[i++] = row; + if (row.sha === msg.params.paging.startingCursor) { + this.log(scope, `found ${msg.params.paging.startingCursor} in existing rows`); + + previousRowsLength = i; + + if (previousRowsLength !== previousRows.length) { + // If we stopped before the end of the array, we need to trim it + rows.length = previousRowsLength + newRowsLength; } - } - } else { - for (let i = 0; i < previousRowsLength; i++) { - rows[i] = previousRows[i]; - } - } - for (let i = 0; i < newRowsLength; i++) { - rows[previousRowsLength + i] = params.rows[i]; + break; + } } } else { - this.log(`onMessageReceived(${msg.id}:${msg.method}): setting to ${params.rows.length} rows`); - - if (params.rows.length === 0) { - rows = this.state.rows; - } else { - rows = params.rows; + for (let i = 0; i < previousRowsLength; i++) { + rows[i] = previousRows[i]; } } - this.state.avatars = params.avatars; - if (params.refsMetadata !== undefined) { - this.state.refsMetadata = params.refsMetadata; + for (let i = 0; i < newRowsLength; i++) { + rows[previousRowsLength + i] = msg.params.rows[i]; } - this.state.rows = rows; - this.state.paging = params.paging; - if (params.selectedRows != null) { - this.state.selectedRows = params.selectedRows; + } else { + this.log(scope, `setting to ${msg.params.rows.length} rows`); + + if (msg.params.rows.length === 0) { + rows = this.state.rows; + } else { + rows = msg.params.rows; } - this.state.loading = false; - this.setState(this.state, type); - }); + } + + this.state.avatars = msg.params.avatars; + this.state.downstreams = msg.params.downstreams; + if (msg.params.refsMetadata !== undefined) { + this.state.refsMetadata = msg.params.refsMetadata; + } + this.state.rows = rows; + this.state.paging = msg.params.paging; + if (msg.params.rowsStats != null) { + this.state.rowsStats = { ...this.state.rowsStats, ...msg.params.rowsStats }; + } + this.state.rowsStatsLoading = msg.params.rowsStatsLoading; + if (msg.params.selectedRows != null) { + this.state.selectedRows = msg.params.selectedRows; + } + this.state.loading = false; + this.setState(this.state, DidChangeRowsNotification); + + setLogScopeExit(scope, ` \u2022 rows=${this.state.rows?.length ?? 0}`); + break; + } + case DidChangeRowsStatsNotification.is(msg): + this.state.rowsStats = { ...this.state.rowsStats, ...msg.params.rowsStats }; + this.state.rowsStatsLoading = msg.params.rowsStatsLoading; + this.setState(this.state, DidChangeRowsStatsNotification); break; - case DidSearchNotificationType.method: - onIpc(DidSearchNotificationType, msg, (params, type) => { - this.state.searchResults = params.results; - if (params.selectedRows != null) { - this.state.selectedRows = params.selectedRows; - } - this.setState(this.state, type); - }); + case DidChangeScrollMarkersNotification.is(msg): + this.state.context = { ...this.state.context, settings: msg.params.context }; + this.setState(this.state, DidChangeScrollMarkersNotification); + break; + + case DidSearchNotification.is(msg): + this.updateSearchResultState(msg.params); + break; + + case DidChangeSelectionNotification.is(msg): + this.state.selectedRows = msg.params.selection; + this.setState(this.state, DidChangeSelectionNotification); break; - case DidChangeSelectionNotificationType.method: - onIpc(DidChangeSelectionNotificationType, msg, (params, type) => { - this.state.selectedRows = params.selection; - this.setState(this.state, type); - }); + case DidChangeGraphConfigurationNotification.is(msg): + this.state.config = msg.params.config; + this.setState(this.state, DidChangeGraphConfigurationNotification); break; - case DidChangeGraphConfigurationNotificationType.method: - onIpc(DidChangeGraphConfigurationNotificationType, msg, (params, type) => { - this.state.config = params.config; - this.setState(this.state, type); - }); + case DidChangeSubscriptionNotification.is(msg): + this.state.subscription = msg.params.subscription; + this.state.allowed = msg.params.allowed; + this.setState(this.state, DidChangeSubscriptionNotification); break; - case DidChangeSubscriptionNotificationType.method: - onIpc(DidChangeSubscriptionNotificationType, msg, (params, type) => { - this.state.subscription = params.subscription; - this.state.allowed = params.allowed; - this.setState(this.state, type); - }); + case DidChangeWorkingTreeNotification.is(msg): + this.state.workingTreeStats = msg.params.stats; + this.setState(this.state, DidChangeWorkingTreeNotification); break; - case DidChangeWorkingTreeNotificationType.method: - onIpc(DidChangeWorkingTreeNotificationType, msg, (params, type) => { - this.state.workingTreeStats = params.stats; - this.setState(this.state, type); - }); + case DidChangeRepoConnectionNotification.is(msg): + this.state.repositories = msg.params.repositories; + this.setState(this.state, DidChangeRepoConnectionNotification); break; default: - super.onMessageReceived?.(e); + super.onMessageReceived?.(msg); } } protected override onThemeUpdated(e: ThemeChangeEvent) { - const bodyStyle = document.body.style; - bodyStyle.setProperty('--graph-theme-opacity-factor', e.isLightTheme ? '0.5' : '1'); - - bodyStyle.setProperty( - '--color-graph-actionbar-background', - e.isLightTheme ? darken(e.colors.background, 5) : lighten(e.colors.background, 5), - ); - bodyStyle.setProperty( - '--color-graph-actionbar-selectedBackground', - e.isLightTheme ? darken(e.colors.background, 10) : lighten(e.colors.background, 10), - ); - - bodyStyle.setProperty( - '--color-graph-background', - e.isLightTheme ? darken(e.colors.background, 5) : lighten(e.colors.background, 5), - ); - bodyStyle.setProperty( - '--color-graph-background2', - e.isLightTheme ? darken(e.colors.background, 10) : lighten(e.colors.background, 10), - ); - - const color = e.computedStyle.getPropertyValue('--color-graph-text-selected-row').trim(); - bodyStyle.setProperty('--color-graph-text-dimmed-selected', opacity(color, 50)); - bodyStyle.setProperty('--color-graph-text-dimmed', opacity(e.colors.foreground, 20)); - - bodyStyle.setProperty('--color-graph-text-normal', opacity(e.colors.foreground, 85)); - bodyStyle.setProperty('--color-graph-text-secondary', opacity(e.colors.foreground, 65)); - bodyStyle.setProperty('--color-graph-text-disabled', opacity(e.colors.foreground, 50)); + const rootStyle = document.documentElement.style; const backgroundColor = Color.from(e.colors.background); const foregroundColor = Color.from(e.colors.foreground); @@ -375,120 +344,71 @@ export class GraphApp extends App { // minimap and scroll markers - let c = Color.fromCssVariable('--color-graph-minimap-visibleAreaBackground', e.computedStyle); - bodyStyle.setProperty( + let c = Color.fromCssVariable('--vscode-scrollbarSlider-background', e.computedStyle); + rootStyle.setProperty( '--color-graph-minimap-visibleAreaBackground', c.luminance(themeLuminance(e.isLightTheme ? 0.6 : 0.1)).toString(), ); if (!e.isLightTheme) { - c = Color.fromCssVariable('--color-graph-minimap-tip-headForeground', e.computedStyle); - bodyStyle.setProperty('--color-graph-minimap-tip-headForeground', c.opposite().toString()); + c = Color.fromCssVariable('--color-graph-scroll-marker-local-branches', e.computedStyle); + rootStyle.setProperty( + '--color-graph-minimap-tip-branchBackground', + c.luminance(themeLuminance(0.55)).toString(), + ); - c = Color.fromCssVariable('--color-graph-minimap-tip-upstreamForeground', e.computedStyle); - bodyStyle.setProperty('--color-graph-minimap-tip-upstreamForeground', c.opposite().toString()); + c = Color.fromCssVariable('--color-graph-scroll-marker-local-branches', e.computedStyle); + rootStyle.setProperty( + '--color-graph-minimap-tip-branchBorder', + c.luminance(themeLuminance(0.55)).toString(), + ); - c = Color.fromCssVariable('--color-graph-minimap-tip-branchForeground', e.computedStyle); - bodyStyle.setProperty('--color-graph-minimap-tip-branchForeground', c.opposite().toString()); + c = Color.fromCssVariable('--vscode-editor-foreground', e.computedStyle); + const tipForeground = c.isLighter() ? c.luminance(0.01).toString() : c.luminance(0.99).toString(); + rootStyle.setProperty('--color-graph-minimap-tip-headForeground', tipForeground); + rootStyle.setProperty('--color-graph-minimap-tip-upstreamForeground', tipForeground); + rootStyle.setProperty('--color-graph-minimap-tip-highlightForeground', tipForeground); + rootStyle.setProperty('--color-graph-minimap-tip-branchForeground', tipForeground); } - // const highlightsColor = Color.from( - // e.computedStyle.getPropertyValue('--color-graph-scroll-marker-highlights').trim(), - // ); - // const branchColor = Color.from( - // e.computedStyle.getPropertyValue('--color-graph-scroll-marker-local-branches').trim(), - // ); - // const remoteBranchColor = Color.from( - // e.computedStyle.getPropertyValue('--color-graph-scroll-marker-remote-branches').trim(), - // ); - // const stashColor = Color.from(e.computedStyle.getPropertyValue('--color-graph-scroll-marker-stashes').trim()); - // const tagColor = Color.from(e.computedStyle.getPropertyValue('--color-graph-scroll-marker-tags').trim()); - - // color = e.computedStyle.getPropertyValue('--vscode-progressBar-background').trim(); - // const activityColor = Color.from(color); - // bodyStyle.setProperty('--color-graph-minimap-line0', activityColor.luminance(themeLuminance(0.5)).toString()); - - // bodyStyle.setProperty( - // '--color-graph-minimap-focusLine', - // backgroundColor.luminance(themeLuminance(e.isLightTheme ? 0.6 : 0.2)).toString(), - // ); - - // color = e.computedStyle.getPropertyValue('--vscode-scrollbarSlider-background').trim(); - // bodyStyle.setProperty( - // '--color-graph-minimap-visibleAreaBackground', - // Color.from(color) - // .luminance(themeLuminance(e.isLightTheme ? 0.6 : 0.15)) - // .toString(), - // ); - - // bodyStyle.setProperty( - // '--color-graph-scroll-marker-highlights', - // highlightsColor.luminance(themeLuminance(e.isLightTheme ? 0.6 : 1.0)).toString(), - // ); - - // const pillLabel = foregroundColor.luminance(themeLuminance(e.isLightTheme ? 0 : 1)).toString(); - // const headBackground = headColor.luminance(themeLuminance(e.isLightTheme ? 0.9 : 0.2)).toString(); - // const headBorder = headColor.luminance(themeLuminance(e.isLightTheme ? 0.2 : 0.4)).toString(); - // const headMarker = headColor.luminance(themeLuminance(0.5)).toString(); - - // bodyStyle.setProperty('--color-graph-minimap-headBackground', headBackground); - // bodyStyle.setProperty('--color-graph-minimap-headBorder', headBorder); - // bodyStyle.setProperty('--color-graph-minimap-headForeground', pillLabel); - - // bodyStyle.setProperty('--color-graph-scroll-marker-head', opacity(headMarker, 90)); - - // bodyStyle.setProperty('--color-graph-minimap-upstreamBackground', headBackground); - // bodyStyle.setProperty('--color-graph-minimap-upstreamBorder', headBorder); - // bodyStyle.setProperty('--color-graph-minimap-upstreamForeground', pillLabel); - // bodyStyle.setProperty('--color-graph-scroll-marker-upstream', opacity(headMarker, 60)); - - // const branchBackground = branchColor.luminance(themeLuminance(e.isLightTheme ? 0.8 : 0.3)).toString(); - // const branchBorder = branchColor.luminance(themeLuminance(e.isLightTheme ? 0.2 : 0.4)).toString(); - // const branchMarker = branchColor.luminance(themeLuminance(e.isLightTheme ? 0.2 : 0.6)).toString(); - - // bodyStyle.setProperty('--color-graph-minimap-branchBackground', branchBackground); - // bodyStyle.setProperty('--color-graph-minimap-branchBorder', branchBorder); - // bodyStyle.setProperty('--color-graph-minimap-branchForeground', pillLabel); - // bodyStyle.setProperty('--color-graph-scroll-marker-local-branches', opacity(branchMarker, 90)); - - // const remoteBranchBackground = remoteBranchColor - // .luminance(themeLuminance(e.isLightTheme ? 0.8 : 0.3)) - // .toString(); - // const remoteBranchBorder = remoteBranchColor.luminance(themeLuminance(e.isLightTheme ? 0.2 : 0.4)).toString(); - // const remoteBranchMarker = remoteBranchColor.luminance(themeLuminance(e.isLightTheme ? 0.3 : 0.6)).toString(); - - // bodyStyle.setProperty('--color-graph-minimap-remoteBackground', opacity(remoteBranchBackground, 80)); - // bodyStyle.setProperty('--color-graph-minimap-remoteBorder', opacity(remoteBranchBorder, 80)); - // bodyStyle.setProperty('--color-graph-minimap-remoteForeground', pillLabel); - // bodyStyle.setProperty('--color-graph-scroll-marker-remote-branches', opacity(remoteBranchMarker, 70)); - - // bodyStyle.setProperty( - // '--color-graph-minimap-stashBackground', - // stashColor.luminance(themeLuminance(e.isLightTheme ? 0.8 : 0.2)).toString(), - // ); - // bodyStyle.setProperty( - // '--color-graph-minimap-stashBorder', - // stashColor.luminance(themeLuminance(e.isLightTheme ? 0.2 : 0.4)).toString(), - // ); - // bodyStyle.setProperty('--color-graph-minimap-stashForeground', pillLabel); - // bodyStyle.setProperty( - // '--color-graph-scroll-marker-stashes', - // opacity(stashColor.luminance(themeLuminance(e.isLightTheme ? 0.5 : 0.9)).toString(), 90), - // ); - - // bodyStyle.setProperty( - // '--color-graph-minimap-tagBackground', - // tagColor.luminance(themeLuminance(e.isLightTheme ? 0.8 : 0.2)).toString(), - // ); - // bodyStyle.setProperty( - // '--color-graph-minimap-tagBorder', - // tagColor.luminance(themeLuminance(e.isLightTheme ? 0.2 : 0.4)).toString(), - // ); - // bodyStyle.setProperty('--color-graph-minimap-tagForeground', pillLabel); - // bodyStyle.setProperty( - // '--color-graph-scroll-marker-tags', - // opacity(tagColor.luminance(themeLuminance(e.isLightTheme ? 0.3 : 0.5)).toString(), 60), - // ); + const branchStatusLuminance = themeLuminance(e.isLightTheme ? 0.72 : 0.064); + const branchStatusHoverLuminance = themeLuminance(e.isLightTheme ? 0.64 : 0.076); + const branchStatusPillLuminance = themeLuminance(e.isLightTheme ? 0.92 : 0.02); + // branch status ahead + c = Color.fromCssVariable('--branch-status-ahead-foreground', e.computedStyle); + rootStyle.setProperty('--branch-status-ahead-background', c.luminance(branchStatusLuminance).toString()); + rootStyle.setProperty( + '--branch-status-ahead-hover-background', + c.luminance(branchStatusHoverLuminance).toString(), + ); + rootStyle.setProperty( + '--branch-status-ahead-pill-background', + c.luminance(branchStatusPillLuminance).toString(), + ); + + // branch status behind + c = Color.fromCssVariable('--branch-status-behind-foreground', e.computedStyle); + rootStyle.setProperty('--branch-status-behind-background', c.luminance(branchStatusLuminance).toString()); + rootStyle.setProperty( + '--branch-status-behind-hover-background', + c.luminance(branchStatusHoverLuminance).toString(), + ); + rootStyle.setProperty( + '--branch-status-behind-pill-background', + c.luminance(branchStatusPillLuminance).toString(), + ); + + // branch status both + c = Color.fromCssVariable('--branch-status-both-foreground', e.computedStyle); + rootStyle.setProperty('--branch-status-both-background', c.luminance(branchStatusLuminance).toString()); + rootStyle.setProperty( + '--branch-status-both-hover-background', + c.luminance(branchStatusHoverLuminance).toString(), + ); + rootStyle.setProperty( + '--branch-status-both-pill-background', + c.luminance(branchStatusPillLuminance).toString(), + ); if (e.isInitializing) return; @@ -496,15 +416,14 @@ export class GraphApp extends App { this.setState(this.state, 'didChangeTheme'); } - protected override setState(state: State, type?: IpcNotificationType | InternalNotificationType) { - this.log(`setState()`); + @debug({ args: false, singleLine: true }) + protected override setState(state: State, type?: IpcNotification | InternalNotificationType) { const themingChanged = this.ensureTheming(state); - // Avoid calling the base for now, since we aren't using the vscode state this.state = state; - // super.setState(state); + super.setState({ timestamp: state.timestamp, selectedRepository: state.selectedRepository }); - this.callback?.(this.state, type, themingChanged); + this.updateStateCallback?.(this.state, type, themingChanged); } private ensureTheming(state: State): boolean { @@ -517,15 +436,15 @@ export class GraphApp extends App { private getGraphTheming(): { cssVariables: CssVariables; themeOpacityFactor: number } { // this will be called on theme updated as well as on config updated since it is dependent on the column colors from config changes and the background color from the theme - const computedStyle = window.getComputedStyle(document.body); - const bgColor = computedStyle.getPropertyValue('--color-background'); + const computedStyle = window.getComputedStyle(document.documentElement); + const bgColor = getCssVariable('--color-background', computedStyle); const mixedGraphColors: CssVariables = {}; let i = 0; let color; for (const [colorVar, colorDefault] of graphLaneThemeColors) { - color = computedStyle.getPropertyValue(colorVar) || colorDefault; + color = getCssVariable(colorVar, computedStyle) || colorDefault; mixedGraphColors[`--column-${i}-color`] = color; @@ -540,67 +459,93 @@ export class GraphApp extends App { i++; } - const isHighContrastTheme = document.body.classList.contains('vscode-high-contrast'); + const isHighContrastTheme = + document.body.classList.contains('vscode-high-contrast') || + document.body.classList.contains('vscode-high-contrast-light'); return { cssVariables: { '--app__bg0': bgColor, - '--panel__bg0': computedStyle.getPropertyValue('--color-graph-background'), - '--panel__bg1': computedStyle.getPropertyValue('--color-graph-background2'), - '--section-border': computedStyle.getPropertyValue('--color-graph-background2'), + '--panel__bg0': getCssVariable('--color-graph-background', computedStyle), + '--panel__bg1': getCssVariable('--color-graph-background2', computedStyle), + '--section-border': getCssVariable('--color-graph-background2', computedStyle), - '--selected-row': computedStyle.getPropertyValue('--color-graph-selected-row'), + '--selected-row': getCssVariable('--color-graph-selected-row', computedStyle), '--selected-row-border': isHighContrastTheme - ? `1px solid ${computedStyle.getPropertyValue('--color-graph-contrast-border')}` + ? `1px solid ${getCssVariable('--color-graph-contrast-border', computedStyle)}` : 'none', - '--hover-row': computedStyle.getPropertyValue('--color-graph-hover-row'), + '--hover-row': getCssVariable('--color-graph-hover-row', computedStyle), '--hover-row-border': isHighContrastTheme - ? `1px dashed ${computedStyle.getPropertyValue('--color-graph-contrast-border')}` + ? `1px dashed ${getCssVariable('--color-graph-contrast-border', computedStyle)}` : 'none', - '--text-selected': computedStyle.getPropertyValue('--color-graph-text-selected'), - '--text-selected-row': computedStyle.getPropertyValue('--color-graph-text-selected-row'), - '--text-hovered': computedStyle.getPropertyValue('--color-graph-text-hovered'), - '--text-dimmed-selected': computedStyle.getPropertyValue('--color-graph-text-dimmed-selected'), - '--text-dimmed': computedStyle.getPropertyValue('--color-graph-text-dimmed'), - '--text-normal': computedStyle.getPropertyValue('--color-graph-text-normal'), - '--text-secondary': computedStyle.getPropertyValue('--color-graph-text-secondary'), - '--text-disabled': computedStyle.getPropertyValue('--color-graph-text-disabled'), - - '--text-accent': computedStyle.getPropertyValue('--color-link-foreground'), - '--text-inverse': computedStyle.getPropertyValue('--vscode-input-background'), - '--text-bright': computedStyle.getPropertyValue('--vscode-input-background'), + '--scrollable-scrollbar-thickness': getCssVariable('--graph-column-scrollbar-thickness', computedStyle), + '--scroll-thumb-bg': getCssVariable('--vscode-scrollbarSlider-background', computedStyle), + + '--scroll-marker-head-color': getCssVariable('--color-graph-scroll-marker-head', computedStyle), + '--scroll-marker-upstream-color': getCssVariable('--color-graph-scroll-marker-upstream', computedStyle), + '--scroll-marker-highlights-color': getCssVariable( + '--color-graph-scroll-marker-highlights', + computedStyle, + ), + '--scroll-marker-local-branches-color': getCssVariable( + '--color-graph-scroll-marker-local-branches', + computedStyle, + ), + '--scroll-marker-remote-branches-color': getCssVariable( + '--color-graph-scroll-marker-remote-branches', + computedStyle, + ), + '--scroll-marker-stashes-color': getCssVariable('--color-graph-scroll-marker-stashes', computedStyle), + '--scroll-marker-tags-color': getCssVariable('--color-graph-scroll-marker-tags', computedStyle), + '--scroll-marker-selection-color': getCssVariable( + '--color-graph-scroll-marker-selection', + computedStyle, + ), + '--scroll-marker-pull-requests-color': getCssVariable( + '--color-graph-scroll-marker-pull-requests', + computedStyle, + ), + + '--stats-added-color': getCssVariable('--color-graph-stats-added', computedStyle), + '--stats-deleted-color': getCssVariable('--color-graph-stats-deleted', computedStyle), + '--stats-files-color': getCssVariable('--color-graph-stats-files', computedStyle), + '--stats-bar-border-radius': getCssVariable('--graph-stats-bar-border-radius', computedStyle), + '--stats-bar-height': getCssVariable('--graph-stats-bar-height', computedStyle), + + '--text-selected': getCssVariable('--color-graph-text-selected', computedStyle), + '--text-selected-row': getCssVariable('--color-graph-text-selected-row', computedStyle), + '--text-hovered': getCssVariable('--color-graph-text-hovered', computedStyle), + '--text-dimmed-selected': getCssVariable('--color-graph-text-dimmed-selected', computedStyle), + '--text-dimmed': getCssVariable('--color-graph-text-dimmed', computedStyle), + '--text-normal': getCssVariable('--color-graph-text-normal', computedStyle), + '--text-secondary': getCssVariable('--color-graph-text-secondary', computedStyle), + '--text-disabled': getCssVariable('--color-graph-text-disabled', computedStyle), + + '--text-accent': getCssVariable('--color-link-foreground', computedStyle), + '--text-inverse': getCssVariable('--vscode-input-background', computedStyle), + '--text-bright': getCssVariable('--vscode-input-background', computedStyle), ...mixedGraphColors, }, - themeOpacityFactor: parseInt(computedStyle.getPropertyValue('--graph-theme-opacity-factor')) || 1, + themeOpacityFactor: parseInt(getCssVariable('--graph-theme-opacity-factor', computedStyle)) || 1, }; } - private onDismissBanner(key: DismissBannerParams['key']) { - this.sendCommand(DismissBannerCommandType, { key: key }); - } - private onColumnsChanged(settings: GraphColumnsConfig) { - this.sendCommand(UpdateColumnsCommandType, { + this.sendCommand(UpdateColumnsCommand, { config: settings, }); } private onRefsVisibilityChanged(refs: GraphExcludedRef[], visible: boolean) { - this.sendCommand(UpdateRefsVisibilityCommandType, { + this.sendCommand(UpdateRefsVisibilityCommand, { refs: refs, visible: visible, }); } private onChooseRepository() { - this.sendCommand(ChooseRepositoryCommandType, undefined); - } - - private onDimMergeCommits(dim: boolean) { - this.sendCommand(DimMergeCommitsCommandType, { - dim: dim, - }); + this.sendCommand(ChooseRepositoryCommand, undefined); } private onDoubleClickRef(ref: GraphRef, metadata?: GraphRefMetadataItem) { @@ -619,82 +564,112 @@ export class GraphApp extends App { }); } + private async onHoverRowPromise(row: GraphRow) { + try { + return await this.sendRequest(GetRowHoverRequest, { type: row.type as GitGraphRowType, id: row.sha }); + } catch (ex) { + return { id: row.sha, markdown: { status: 'rejected' as const, reason: ex } }; + } + } + + private async onJumpToRefPromise(alt: boolean): Promise<{ name: string; sha: string } | undefined> { + try { + // Assuming we have a command to get the ref details + const rsp = await this.sendRequest(ChooseRefRequest, { alt: alt }); + return rsp; + } catch { + return undefined; + } + } + private onGetMissingAvatars(emails: GraphAvatars) { - this.sendCommand(GetMissingAvatarsCommandType, { emails: emails }); + this.sendCommand(GetMissingAvatarsCommand, { emails: emails }); } private onGetMissingRefsMetadata(metadata: GraphMissingRefsMetadata) { - this.sendCommand(GetMissingRefsMetadataCommandType, { metadata: metadata }); + this.sendCommand(GetMissingRefsMetadataCommand, { metadata: metadata }); } private onGetMoreRows(sha?: string) { - return this.sendCommand(GetMoreRowsCommandType, { id: sha }); + this.sendCommand(GetMoreRowsCommand, { id: sha }); + } + + onOpenPullRequest(pr: NonNullable['pr']>): void { + this.sendCommand(OpenPullRequestDetailsCommand, { id: pr.id }); } - private onSearch(search: SearchQuery | undefined, options?: { limit?: number }) { + private async onSearch(search: SearchQuery | undefined, options?: { limit?: number }) { if (search == null) { this.state.searchResults = undefined; } - return this.sendCommand(SearchCommandType, { search: search, limit: options?.limit }); + try { + const rsp = await this.sendRequest(SearchRequest, { search: search, limit: options?.limit }); + this.updateSearchResultState(rsp); + } catch { + this.state.searchResults = undefined; + } } private async onSearchPromise(search: SearchQuery, options?: { limit?: number; more?: boolean }) { try { - return await this.sendCommandWithCompletion( - SearchCommandType, - { search: search, limit: options?.limit, more: options?.more }, - DidSearchNotificationType, - ); + const rsp = await this.sendRequest(SearchRequest, { + search: search, + limit: options?.limit, + more: options?.more, + }); + this.updateSearchResultState(rsp); + return rsp; } catch { return undefined; } } private onSearchOpenInView(search: SearchQuery) { - this.sendCommand(SearchOpenInViewCommandType, { search: search }); + this.sendCommand(SearchOpenInViewCommand, { search: search }); } private async onEnsureRowPromise(id: string, select: boolean) { try { - return await this.sendCommandWithCompletion( - EnsureRowCommandType, - { id: id, select: select }, - DidEnsureRowNotificationType, - ); + return await this.sendRequest(EnsureRowRequest, { id: id, select: select }); } catch { return undefined; } } - private onExcludeType(key: keyof GraphExcludeTypes, value: boolean) { - this.sendCommand(UpdateExcludeTypeCommandType, { key: key, value: value }); + private onExcludeTypesChanged(key: keyof GraphExcludeTypes, value: boolean) { + this.sendCommand(UpdateExcludeTypesCommand, { key: key, value: value }); } - private onIncludeOnlyRef(all?: boolean) { - this.sendCommand( - UpdateIncludeOnlyRefsCommandType, - all ? {} : { refs: [{ id: 'HEAD', type: 'head', name: 'HEAD' }] }, - ); + private onRefIncludesChanged(branchesVisibility: GraphBranchesVisibility, refs?: GraphRefOptData[]) { + this.sendCommand(UpdateIncludedRefsCommand, { branchesVisibility: branchesVisibility, refs: refs }); } - private onUpdateGraphConfiguration(changes: UpdateGraphConfigurationParams['changes']) { - this.sendCommand(UpdateGraphConfigurationCommandType, { changes: changes }); + private onGraphConfigurationChanged(changes: UpdateGraphConfigurationParams['changes']) { + this.sendCommand(UpdateGraphConfigurationCommand, { changes: changes }); } private onSelectionChanged(rows: GraphRow[]) { - const selection = rows.map(r => ({ id: r.sha, type: r.type as GitGraphRowType })); - this.sendCommand(UpdateSelectionCommandType, { + const selection = rows.filter(r => r != null).map(r => ({ id: r.sha, type: r.type as GitGraphRowType })); + this.sendCommand(UpdateSelectionCommand, { selection: selection, }); } - private registerEvents(callback: UpdateStateCallback): () => void { - this.callback = callback; + private registerUpdateStateCallback(updateState: UpdateStateCallback): () => void { + this.updateStateCallback = updateState; return () => { - this.callback = undefined; + this.updateStateCallback = undefined; }; } + + private updateSearchResultState(params: DidSearchParams) { + this.state.searchResults = params.results; + if (params.selectedRows != null) { + this.state.selectedRows = params.selectedRows; + } + this.setState(this.state, DidSearchNotification); + } } new GraphApp(); diff --git a/src/webviews/apps/plus/graph/hover/graphHover.react.tsx b/src/webviews/apps/plus/graph/hover/graphHover.react.tsx new file mode 100644 index 0000000000000..59db4570152c0 --- /dev/null +++ b/src/webviews/apps/plus/graph/hover/graphHover.react.tsx @@ -0,0 +1,5 @@ +import { reactWrapper } from '../../../shared/components/helpers/react-wrapper'; +import { GlGraphHover as GlGraphHoverWC } from './graphHover'; + +export interface GlGraphHover extends GlGraphHoverWC {} +export const GlGraphHover = reactWrapper(GlGraphHoverWC, { tagName: 'gl-graph-hover' }); diff --git a/src/webviews/apps/plus/graph/hover/graphHover.ts b/src/webviews/apps/plus/graph/hover/graphHover.ts new file mode 100644 index 0000000000000..6f6a5042566bf --- /dev/null +++ b/src/webviews/apps/plus/graph/hover/graphHover.ts @@ -0,0 +1,218 @@ +import type { GraphRow } from '@gitkraken/gitkraken-components'; +import { css, html } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { until } from 'lit/directives/until.js'; +import type { DidGetRowHoverParams } from '../../../../../plus/webviews/graph/protocol'; +import type { Deferrable } from '../../../../../system/function'; +import { debounce } from '../../../../../system/function'; +import { getSettledValue, isPromise } from '../../../../../system/promise'; +import { GlElement } from '../../../shared/components/element'; +import type { GlPopover } from '../../../shared/components/overlays/popover.react'; +import '../../../shared/components/overlays/popover'; +import './markdown'; + +declare global { + interface HTMLElementTagNameMap { + 'gl-graph-hover': GlGraphHover; + } + + // interface GlobalEventHandlersEventMap { + // 'gl-popover-show': CustomEvent; + // 'gl-popover-after-show': CustomEvent; + // 'gl-popover-hide': CustomEvent; + // 'gl-popover-after-hide': CustomEvent; + // } +} + +type Anchor = string | HTMLElement | { getBoundingClientRect: () => Omit }; + +@customElement('gl-graph-hover') +export class GlGraphHover extends GlElement { + static override styles = css``; + + @property({ type: Object }) + anchor?: Anchor; + + @property({ reflect: true, type: Number }) + distance?: number | undefined; + + @property({ reflect: true, type: Boolean }) + open?: boolean = false; + + @property({ reflect: true }) + placement?: GlPopover['placement'] = 'bottom-start'; + + @property({ type: Object }) + markdown?: Promise> | string; + + @property({ reflect: true, type: Number }) + skidding?: number | undefined; + + @property({ type: Function }) + requestMarkdown: ((row: GraphRow) => Promise) | undefined; + + @query('gl-popover') + popup!: GlPopover; + + private hoverMarkdownCache = new Map< + string, + Promise> | PromiseSettledResult | string + >(); + private hoveredSha: string | undefined; + private unhoverTimer: ReturnType | undefined; + + override render() { + if (!this.markdown) { + this.hide(); + return; + } + + return html` this.hide()} + @sl-reposition=${() => this.onReposition()} + > +
    + +
    +
    `; + } + + private previousSkidding: number | undefined; + private recalculated = false; + + private onReposition() { + if (this.skidding == null || (this.placement !== `bottom-start` && this.placement !== `top-start`)) { + return; + } + + switch (this.popup?.currentPlacement) { + case 'bottom-end': + case 'top-end': + if (!this.recalculated && this.previousSkidding == null) { + this.previousSkidding = this.skidding; + this.skidding = -this.skidding * 5; + this.recalculated = true; + } + break; + default: + if (this.previousSkidding != null) { + this.skidding = this.previousSkidding; + this.previousSkidding = undefined; + } + break; + } + } + + reset() { + this.recalculated = false; + this.hoverMarkdownCache.clear(); + } + + onParentMouseLeave = () => { + this.hide(); + }; + + private _showCoreDebounced: Deferrable | undefined = undefined; + + onRowHovered(row: GraphRow, anchor: Anchor) { + clearTimeout(this.unhoverTimer); + if (this.requestMarkdown == null) return; + // Break if we are already showing the hover for the same row + if (this.hoveredSha === row.sha && this.open) return; + + this.hoveredSha = row.sha; + + let markdown = this.hoverMarkdownCache.get(row.sha); + if (markdown == null) { + const cache = row.type !== 'work-dir-changes'; + + markdown = this.requestMarkdown(row).then(params => { + if (params.markdown.status === 'fulfilled' && cache) { + this.hoverMarkdownCache.set(row.sha, params.markdown); + } else if (params.markdown.status === 'rejected') { + this.hoverMarkdownCache.delete(row.sha); + } + + return params.markdown; + }); + + if (cache) { + this.hoverMarkdownCache.set(row.sha, markdown); + } + } + + if (this.open) { + this.showCore(anchor, markdown); + } else { + this._showCoreDebounced ??= debounce(this.showCore.bind(this), 500); + this._showCoreDebounced(anchor, markdown); + } + } + + onRowUnhovered(_row: GraphRow, relatedTarget: EventTarget | null) { + this.recalculated = false; + clearTimeout(this.unhoverTimer); + + if ( + relatedTarget != null && + 'closest' in relatedTarget && + (relatedTarget as HTMLElement).closest('gl-graph-hover') + ) { + return; + } + + this.unhoverTimer = setTimeout(() => this.hide(), 250); + } + + onWindowKeydown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + this.hide(); + } + }; + + private showCore( + anchor: string | HTMLElement | { getBoundingClientRect: () => Omit }, + markdown: Promise> | PromiseSettledResult | string, + ) { + if (typeof markdown === 'string') { + this.markdown = markdown; + } else if (isPromise(markdown)) { + const previousSha = this.hoveredSha; + void markdown + .then(markdown => { + if (previousSha !== this.hoveredSha) return; + + this.markdown = getSettledValue(markdown); + if (!markdown) { + this.hide(); + } + }) + .catch(() => {}); + } else { + this.markdown = getSettledValue(markdown); + } + + this.anchor = anchor; + this.open = true; + this.parentElement?.addEventListener('mouseleave', this.onParentMouseLeave); + window.addEventListener('keydown', this.onWindowKeydown); + } + + hide() { + this._showCoreDebounced?.cancel(); + clearTimeout(this.unhoverTimer); + this.parentElement?.removeEventListener('mouseleave', this.onParentMouseLeave); + window.removeEventListener('keydown', this.onWindowKeydown); + + this.hoveredSha = undefined; + this.markdown = undefined; + this.open = false; + } +} diff --git a/src/webviews/apps/plus/graph/hover/markdown.ts b/src/webviews/apps/plus/graph/hover/markdown.ts new file mode 100644 index 0000000000000..39f5cac00739b --- /dev/null +++ b/src/webviews/apps/plus/graph/hover/markdown.ts @@ -0,0 +1,254 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { until } from 'lit/directives/until.js'; +import type { RendererObject, RendererThis, Tokens } from 'marked'; +import { marked } from 'marked'; +import type { ThemeIcon } from 'vscode'; + +@customElement('gl-markdown') +export class GLMarkdown extends LitElement { + static override styles = css` + a, + a code { + text-decoration: none; + color: var(--vscode-textLink-foreground); + } + + a:hover, + a:hover code { + color: var(--vscode-textLink-activeForeground); + } + + a:hover:not(.disabled) { + cursor: pointer; + } + + p, + .code, + ul, + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 8px 0; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + } + + code { + background: var(--vscode-textCodeBlock-background); + border-radius: 3px; + padding: 0px 4px 2px 4px; + font-family: var(--vscode-editor-font-family); + } + + code code-icon { + color: inherit; + font-size: inherit; + vertical-align: middle; + } + + hr { + border: none; + border-top: 1px solid var(--color-foreground--25); + } + + p:first-child, + .code:first-child, + ul:first-child { + margin-top: 0; + } + + p:last-child, + .code:last-child, + ul:last-child { + margin-bottom: 0; + } + + /* MarkupContent Layout */ + ul { + padding-left: 20px; + } + ol { + padding-left: 20px; + } + + li > p { + margin-bottom: 0; + } + + li > ul { + margin-top: 0; + } + `; + + @property({ type: String }) + private markdown = ''; + + override render() { + return html`${this.markdown ? until(this.renderMarkdown(this.markdown), 'Loading...') : ''}`; + } + + private async renderMarkdown(markdown: string) { + marked.setOptions({ + gfm: true, + // smartypants: true, + // langPrefix: 'language-', + }); + + marked.use({ renderer: getMarkdownRenderer() }); + + let rendered = await marked.parse(markdownEscapeEscapedIcons(markdown)); + rendered = renderThemeIconsWithinText(rendered); + return unsafeHTML(rendered); + } +} + +function getMarkdownRenderer(): RendererObject { + return { + image: function (this: RendererThis, { href, title, text }: Tokens.Image): string { + let dimensions: string[] = []; + let attributes: string[] = []; + if (href) { + ({ href, dimensions } = parseHrefAndDimensions(href)); + attributes.push(`src="${escapeDoubleQuotes(href)}"`); + } + if (text) { + attributes.push(`alt="${escapeDoubleQuotes(text)}"`); + } + if (title) { + attributes.push(`title="${escapeDoubleQuotes(title)}"`); + } + if (dimensions.length) { + attributes = attributes.concat(dimensions); + } + return ``; + }, + paragraph: function (this: RendererThis, { tokens }: Tokens.Paragraph): string { + const text = this.parser.parseInline(tokens); + return `

    ${text}

    `; + }, + link: function (this: RendererThis, { href, title, tokens }: Tokens.Link): string | false { + if (typeof href !== 'string') return ''; + + // Remove markdown escapes. Workaround for https://github.com/chjj/marked/issues/829 + let text = this.parser.parseInline(tokens); + if (href === text) { + // raw link case + text = removeMarkdownEscapes(text); + } + + title = typeof title === 'string' ? escapeDoubleQuotes(removeMarkdownEscapes(title)) : ''; + + // HTML Encode href + href = removeMarkdownEscapes(href) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + return `${text}`; + }, + code: function (this: RendererThis, { text, lang }: Tokens.Code): string { + // Remote code may include characters that need to be escaped to be visible in HTML + text = text.replace(/${text}`; + }, + codespan: function (this: RendererThis, { text }: Tokens.Codespan): string { + // Remote code may include characters that need to be escaped to be visible in HTML + text = text.replace(/${text}`; + }, + }; +} + +const themeIconNameExpression = '[A-Za-z0-9-]+'; +const themeIconModifierExpression = '~[A-Za-z]+'; +const themeIconIdRegex = new RegExp(`^(${themeIconNameExpression})(${themeIconModifierExpression})?$`); +const themeIconsRegex = new RegExp(`\\$\\(${themeIconNameExpression}(?:${themeIconModifierExpression})?\\)`, 'g'); +const themeIconsMarkdownEscapedRegex = new RegExp(`\\\\${themeIconsRegex.source}`, 'g'); +const themeIconsWithinTextRegex = new RegExp( + `(\\\\)?\\$\\((${themeIconNameExpression}(?:${themeIconModifierExpression})?)\\)`, + 'g', +); + +function markdownEscapeEscapedIcons(text: string): string { + // Need to add an extra \ for escaping in markdown + return text.replace(themeIconsMarkdownEscapedRegex, match => `\\${match}`); +} + +function parseHrefAndDimensions(href: string): { href: string; dimensions: string[] } { + const dimensions: string[] = []; + const splitted = href.split('|').map(s => s.trim()); + href = splitted[0]; + const parameters = splitted[1]; + if (parameters) { + const heightFromParams = /height=(\d+)/.exec(parameters); + const widthFromParams = /width=(\d+)/.exec(parameters); + const height = heightFromParams ? heightFromParams[1] : ''; + const width = widthFromParams ? widthFromParams[1] : ''; + const widthIsFinite = isFinite(parseInt(width)); + const heightIsFinite = isFinite(parseInt(height)); + if (widthIsFinite) { + dimensions.push(`width="${width}"`); + } + if (heightIsFinite) { + dimensions.push(`height="${height}"`); + } + } + return { href: href, dimensions: dimensions }; +} + +function renderThemeIconsWithinText(text: string): string { + const elements: string[] = []; + let match: RegExpExecArray | null; + + let textStart = 0; + let textStop = 0; + while ((match = themeIconsWithinTextRegex.exec(text)) !== null) { + textStop = match.index || 0; + if (textStart < textStop) { + elements.push(text.substring(textStart, textStop)); + } + textStart = (match.index || 0) + match[0].length; + + const [, escaped, codicon] = match; + elements.push(escaped ? `$(${codicon})` : renderThemeIcon({ id: codicon })); + } + + if (textStart < text.length) { + elements.push(text.substring(textStart)); + } + return elements.join(''); +} + +function renderThemeIcon(icon: ThemeIcon): string { + const match = themeIconIdRegex.exec(icon.id); + let [, id, modifier] = match ?? [undefined, 'error', undefined]; + if (id.startsWith('gitlens-')) { + id = `gl-${id.substring(8)}`; + } + return /*html*/ ``; +} + +function escapeDoubleQuotes(input: string) { + return input.replace(/"/g, '"'); +} + +function removeMarkdownEscapes(text: string): string { + if (!text) { + return text; + } + return text.replace(/\\([\\`*_{}[\]()#+\-.!~])/g, '$1'); +} diff --git a/src/webviews/apps/plus/graph/minimap/minimap-container.react.tsx b/src/webviews/apps/plus/graph/minimap/minimap-container.react.tsx new file mode 100644 index 0000000000000..876a0774195e6 --- /dev/null +++ b/src/webviews/apps/plus/graph/minimap/minimap-container.react.tsx @@ -0,0 +1,12 @@ +import type { EventName } from '@lit/react'; +import type { CustomEventType } from '../../../shared/components/element'; +import { reactWrapper } from '../../../shared/components/helpers/react-wrapper'; +import { GlGraphMinimapContainer as GlGraphMinimapContainerWC } from './minimap-container'; + +export interface GlGraphMinimapContainer extends GlGraphMinimapContainerWC {} +export const GlGraphMinimapContainer = reactWrapper(GlGraphMinimapContainerWC, { + tagName: 'gl-graph-minimap-container', + events: { + onSelected: 'gl-graph-minimap-selected' as EventName>, + }, +}); diff --git a/src/webviews/apps/plus/graph/minimap/minimap-container.ts b/src/webviews/apps/plus/graph/minimap/minimap-container.ts new file mode 100644 index 0000000000000..e4aff4b738c6b --- /dev/null +++ b/src/webviews/apps/plus/graph/minimap/minimap-container.ts @@ -0,0 +1,398 @@ +import type { GraphRow } from '@gitkraken/gitkraken-components'; +import { html, nothing } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import type { + GraphDownstreams, + GraphMinimapMarkerTypes, + GraphRefsMetadata, + GraphRowStats, + GraphSearchResults, + GraphSearchResultsError, +} from '../../../../../plus/webviews/graph/protocol'; +import { GlElement, observe } from '../../../shared/components/element'; +import type { + GlGraphMinimap, + GraphMinimapMarker, + GraphMinimapSearchResultMarker, + GraphMinimapStats, + StashMarker, +} from './minimap'; +import './minimap'; + +@customElement('gl-graph-minimap-container') +export class GlGraphMinimapContainer extends GlElement { + @property({ type: Number }) + activeDay: number | undefined; + + @property({ type: Boolean }) + disabled = false; + + @observe('disabled') + private onDisabledChanged() { + if (!this.disabled) { + if (this.pendingDataChange) { + this.processRows(); + } + + if (this.pendingSearchResultsChange) { + this.processSearchResults(); + } + } + } + + @property({ type: String }) + dataType: 'commits' | 'lines' = 'commits'; + + @property({ type: Object }) + downstreams?: GraphDownstreams; + + @property({ type: Array }) + markerTypes: GraphMinimapMarkerTypes[] = []; + + @property({ type: Object }) + refMetadata?: GraphRefsMetadata | null; + + @property({ type: Array }) + rows: GraphRow[] = []; + + @property({ type: Object }) + rowsStats?: Record; + + @property({ type: Object }) + searchResults?: GraphSearchResults | GraphSearchResultsError; + + @property({ type: Object }) + visibleDays: { top: number; bottom: number } | undefined; + + @state() + private markersByDay = new Map(); + + @state() + private searchResultsByDay = new Map(); + + @state() + private statsByDay = new Map(); + + private pendingDataChange = false; + + @observe(['dataType', 'downstreams', 'markerTypes', 'refMetadata', 'rows', 'rowsStats']) + private handleDataChanged(changedKeys: PropertyKey[]) { + // If only the rowsStats changed, and we're not in lines mode, we don't need to reprocess the rows + if (changedKeys.length === 1 && changedKeys[0] === 'rowsStats') { + if (this.dataType !== 'lines') return; + } + + this.pendingDataChange = true; + if (this.disabled) return; + + this.processRows(); + } + + private pendingSearchResultsChange = false; + + @observe(['markerTypes', 'searchResults']) + private handleSearchResultsChanged() { + this.pendingSearchResultsChange = true; + if (this.disabled) return; + + this.processSearchResults(); + } + + @query('#minimap') + private minimap: GlGraphMinimap | undefined; + + override render() { + if (this.disabled) return nothing; + + return html``; + } + + select(date: number | Date | undefined, trackOnly: boolean = false) { + if (this.disabled) return; + this.minimap?.select(date, trackOnly); + } + + unselect(date?: number | Date, focus: boolean = false) { + if (this.disabled) return; + this.minimap?.unselect(date, focus); + } + + private processRows() { + this.pendingDataChange = false; + + const statsByDayMap = new Map(); + const markersByDay = new Map(); + + const showLinesChanged = this.dataType === 'lines'; + if (!this.rows?.length || (showLinesChanged && this.rowsStats == null)) { + this.statsByDay = statsByDayMap; + this.markersByDay = markersByDay; + + return; + } + + // Loops through all the rows and group them by day and aggregate the row.stats + + let rankedShas: { + head: string | undefined; + branch: string | undefined; + remote: string | undefined; + tag: string | undefined; + } = { + head: undefined, + branch: undefined, + remote: undefined, + tag: undefined, + }; + + let day; + let prevDay; + + let markers; + let headMarkers: GraphMinimapMarker[]; + let pullRequestMarkers: GraphMinimapMarker[]; + let remoteMarkers: GraphMinimapMarker[]; + let stashMarker: StashMarker | undefined; + let tagMarkers: GraphMinimapMarker[]; + let row: GraphRow; + let stat; + let stats; + + const rows = this.rows ?? []; + // Iterate in reverse order so that we can track the HEAD upstream properly + for (let i = rows.length - 1; i >= 0; i--) { + row = rows[i]; + pullRequestMarkers = []; + + day = getDay(row.date); + if (day !== prevDay) { + prevDay = day; + rankedShas = { + head: undefined, + branch: undefined, + remote: undefined, + tag: undefined, + }; + } + + if ( + row.heads?.length && + (this.markerTypes.includes('head') || + this.markerTypes.includes('localBranches') || + this.markerTypes.includes('pullRequests')) + ) { + rankedShas.branch = row.sha; + + headMarkers = []; + + // eslint-disable-next-line no-loop-func + row.heads.forEach(h => { + if (h.isCurrentHead) { + rankedShas.head = row.sha; + } + + if ( + this.markerTypes.includes('localBranches') || + (this.markerTypes.includes('head') && h.isCurrentHead) + ) { + headMarkers.push({ + type: 'branch', + name: h.name, + current: h.isCurrentHead && this.markerTypes.includes('head'), + }); + } + + if ( + this.markerTypes.includes('pullRequests') && + h.id != null && + this.refMetadata?.[h.id]?.pullRequest?.length + ) { + for (const pr of this.refMetadata?.[h.id]?.pullRequest ?? []) { + pullRequestMarkers.push({ + type: 'pull-request', + name: pr.title, + }); + } + } + }); + + markers = markersByDay.get(day); + if (markers == null) { + markersByDay.set(day, headMarkers); + } else { + markers.push(...headMarkers); + } + } + + if ( + row.remotes?.length && + (this.markerTypes.includes('upstream') || + this.markerTypes.includes('remoteBranches') || + this.markerTypes.includes('localBranches') || + this.markerTypes.includes('pullRequests')) + ) { + rankedShas.remote = row.sha; + + remoteMarkers = []; + + // eslint-disable-next-line no-loop-func + row.remotes.forEach(r => { + let current = false; + const hasDownstream = this.downstreams?.[`${r.owner}/${r.name}`]?.length; + if (r.current) { + rankedShas.remote = row.sha; + current = true; + } + + if ( + this.markerTypes.includes('remoteBranches') || + (this.markerTypes.includes('upstream') && current) || + (this.markerTypes.includes('localBranches') && hasDownstream) + ) { + remoteMarkers.push({ + type: 'remote', + name: `${r.owner}/${r.name}`, + current: current && this.markerTypes.includes('upstream'), + }); + } + + if ( + this.markerTypes.includes('pullRequests') && + r.id != null && + this.refMetadata?.[r.id]?.pullRequest?.length + ) { + for (const pr of this.refMetadata?.[r.id]?.pullRequest ?? []) { + pullRequestMarkers.push({ + type: 'pull-request', + name: pr.title, + }); + } + } + }); + + markers = markersByDay.get(day); + if (markers == null) { + markersByDay.set(day, remoteMarkers); + } else { + markers.push(...remoteMarkers); + } + } + + if (row.type === 'stash-node' && this.markerTypes.includes('stashes')) { + stashMarker = { type: 'stash', name: row.message }; + markers = markersByDay.get(day); + if (markers == null) { + markersByDay.set(day, [stashMarker]); + } else { + markers.push(stashMarker); + } + } + + if (row.tags?.length && this.markerTypes.includes('tags')) { + rankedShas.tag = row.sha; + + tagMarkers = row.tags.map(t => ({ + type: 'tag', + name: t.name, + })); + + markers = markersByDay.get(day); + if (markers == null) { + markersByDay.set(day, tagMarkers); + } else { + markers.push(...tagMarkers); + } + } + + markers = markersByDay.get(day); + if (markers == null) { + markersByDay.set(day, pullRequestMarkers); + } else { + markers.push(...pullRequestMarkers); + } + + stat = statsByDayMap.get(day); + if (stat == null) { + if (showLinesChanged) { + stats = this.rowsStats?.[row.sha]; + if (stats != null) { + stat = { + activity: { additions: stats.additions, deletions: stats.deletions }, + commits: 1, + files: stats.files, + sha: row.sha, + }; + statsByDayMap.set(day, stat); + } + } else { + stat = { + commits: 1, + sha: row.sha, + }; + statsByDayMap.set(day, stat); + } + } else { + stat.commits++; + stat.sha = rankedShas.head ?? rankedShas.branch ?? rankedShas.remote ?? rankedShas.tag ?? stat.sha; + if (showLinesChanged) { + stats = this.rowsStats?.[row.sha]; + if (stats != null) { + if (stat.activity == null) { + stat.activity = { additions: stats.additions, deletions: stats.deletions }; + } else { + stat.activity.additions += stats.additions; + stat.activity.deletions += stats.deletions; + } + stat.files = (stat.files ?? 0) + stats.files; + } + } + } + } + + this.statsByDay = statsByDayMap; + this.markersByDay = markersByDay; + } + + private processSearchResults() { + this.pendingSearchResultsChange = false; + + const searchResultsByDay = new Map(); + + if (this.searchResults != null && 'error' in this.searchResults) { + this.searchResultsByDay = searchResultsByDay; + + return; + } + + if (this.searchResults?.ids != null) { + let day; + let sha; + let r; + let result; + for ([sha, r] of Object.entries(this.searchResults.ids)) { + day = getDay(r.date); + + result = searchResultsByDay.get(day); + if (result == null) { + searchResultsByDay.set(day, { type: 'search-result', sha: sha, count: 1 }); + } else { + result.count++; + } + } + } + + this.searchResultsByDay = searchResultsByDay; + } +} + +function getDay(date: number | Date): number { + return new Date(date).setHours(0, 0, 0, 0); +} diff --git a/src/webviews/apps/plus/graph/minimap/minimap.react.tsx b/src/webviews/apps/plus/graph/minimap/minimap.react.tsx new file mode 100644 index 0000000000000..50b581276e969 --- /dev/null +++ b/src/webviews/apps/plus/graph/minimap/minimap.react.tsx @@ -0,0 +1,12 @@ +import type { EventName } from '@lit/react'; +import type { CustomEventType } from '../../../shared/components/element'; +import { reactWrapper } from '../../../shared/components/helpers/react-wrapper'; +import { GlGraphMinimap as GlGraphMinimapWC } from './minimap'; + +export interface GlGraphMinimap extends GlGraphMinimapWC {} +export const GlGraphMinimap = reactWrapper(GlGraphMinimapWC, { + tagName: 'gl-graph-minimap', + events: { + onSelected: 'gl-graph-minimap-selected' as EventName>, + }, +}); diff --git a/src/webviews/apps/plus/graph/minimap/minimap.ts b/src/webviews/apps/plus/graph/minimap/minimap.ts index f1d46fcfd9408..7c20c23d9552f 100644 --- a/src/webviews/apps/plus/graph/minimap/minimap.ts +++ b/src/webviews/apps/plus/graph/minimap/minimap.ts @@ -1,10 +1,11 @@ -import { css, customElement, FASTElement, html, observable, ref } from '@microsoft/fast-element'; import type { Chart, DataItem, RegionOptions } from 'billboard.js'; -import { groupByMap } from '../../../../../system/array'; +import { css, html } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; import { debug } from '../../../../../system/decorators/log'; import { debounce } from '../../../../../system/function'; -import { first, flatMap, map, some, union } from '../../../../../system/iterable'; -import { pluralize } from '../../../../../system/string'; +import { first, flatMap, groupByMap, map, union } from '../../../../../system/iterable'; +import { capitalize, pluralize } from '../../../../../system/string'; +import { GlElement, observe } from '../../../shared/components/element'; import { formatDate, formatNumeric, fromNow } from '../../../shared/date'; export interface BranchMarker { @@ -31,11 +32,18 @@ export interface TagMarker { current?: undefined; } -export type GraphMinimapMarker = BranchMarker | RemoteMarker | StashMarker | TagMarker; +export interface PullRequestMarker { + type: 'pull-request'; + name: string; + current?: undefined; +} + +export type GraphMinimapMarker = BranchMarker | RemoteMarker | StashMarker | TagMarker | PullRequestMarker; export interface GraphMinimapSearchResultMarker { type: 'search-result'; sha: string; + count: number; } export interface GraphMinimapStats { @@ -53,193 +61,249 @@ export interface GraphMinimapDaySelectedEventDetail { sha?: string; } -const template = html``; - -const styles = css` - :host { - display: flex; - position: relative; - width: 100%; - min-height: 24px; - height: 40px; - background: var(--color-background); - } +const markerZOrder = [ + 'marker-result', + 'marker-head-arrow-left', + 'marker-head-arrow-right', + 'marker-head', + 'marker-upstream', + 'marker-pull-request', + 'marker-branch', + 'marker-stash', + 'marker-remote', + 'marker-tag', + 'visible-area', +]; - #chart { - height: 100%; - width: 100%; - overflow: hidden; - position: initial !important; +declare global { + interface HTMLElementTagNameMap { + 'gl-graph-minimap': GlGraphMinimap; } - .bb svg { - font: 10px var(--font-family); - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); - transform: translateX(2.5em) rotateY(180deg); + interface GlobalEventHandlersEventMap { + 'gl-graph-minimap-selected': GraphMinimapDaySelectedEvent; } +} - .bb-chart { - width: 100%; - height: 100%; - } +@customElement('gl-graph-minimap') +export class GlGraphMinimap extends GlElement { + static override styles = css` + :host { + display: flex; + position: relative; + width: 100%; + min-height: 24px; + height: 40px; + background: var(--color-background); + } - .bb-event-rect { - height: calc(100% + 2px); - transform: translateY(-5px); - } + #chart { + height: 100%; + width: calc(100% - 1rem); + overflow: hidden; + position: initial !important; + } - /*-- Grid --*/ - .bb-grid { - pointer-events: none; - } + #spinner { + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + z-index: 1; + } - .bb-xgrid-focus line { - stroke: var(--color-graph-minimap-focusLine); - } + #spinner[aria-hidden='true'] { + display: none; + } - /*-- Line --*/ - .bb path, - .bb line { - fill: none; - } + .legend { + position: absolute; + top: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + z-index: 1; + opacity: 0.7; + cursor: help; + } - /*-- Point --*/ - .bb-circle._expanded_ { - fill: var(--color-background); - opacity: 1 !important; - fill-opacity: 1 !important; - stroke-opacity: 1 !important; - stroke-width: 1px; - } + .bb svg { + font: 10px var(--font-family); + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + } - .bb-selected-circle { - fill: var(--color-background); - opacity: 1 !important; - fill-opacity: 1 !important; - stroke-opacity: 1 !important; - stroke-width: 2px; - } + .bb-chart { + width: 100%; + height: 100%; + } - /*-- Bar --*/ - .bb-bar { - stroke-width: 0; - } - .bb-bar._expanded_ { - fill-opacity: 0.75; - } + .bb-event-rect { + height: calc(100% + 2px); + transform: translateY(-5px); + } - /*-- Regions --*/ + /*-- Grid --*/ + .bb-grid { + pointer-events: none; + } - .bb-regions { - pointer-events: none; - } + .bb-xgrid-focus line { + stroke: var(--color-graph-minimap-focusLine); + } - .bb-region > rect:not([x]) { - display: none; - } + /*-- Line --*/ + .bb path, + .bb line { + fill: none; + } - .bb-region.visible-area { - fill: var(--color-graph-minimap-visibleAreaBackground); - transform: translateY(-4px); - } - .bb-region.visible-area > rect { - height: 100%; - } + /*-- Point --*/ + .bb-circle._expanded_ { + fill: var(--color-background); + opacity: 1 !important; + fill-opacity: 1 !important; + stroke-opacity: 1 !important; + stroke-width: 1px; + } - .bb-region.marker-result { - fill: var(--color-graph-minimap-marker-highlights); - transform: translate(-1px, -4px); - z-index: 10; - } - .bb-region.marker-result > rect { - width: 2px; - height: 100%; - } + .bb-selected-circle { + fill: var(--color-background); + opacity: 1 !important; + fill-opacity: 1 !important; + stroke-opacity: 1 !important; + stroke-width: 2px; + } - .bb-region.marker-head { - fill: var(--color-graph-minimap-marker-head); - stroke: var(--color-graph-minimap-marker-head); - transform: translate(-1px, -4px); - } - .bb-region.marker-head > rect { - width: 1px; - height: 100%; - } + /*-- Bar --*/ + .bb-bar { + stroke-width: 0; + } + .bb-bar._expanded_ { + fill-opacity: 0.75; + } - .bb-region.marker-head-arrow-left { - fill: var(--color-graph-minimap-marker-head); - stroke: var(--color-graph-minimap-marker-head); - transform: translate(-5px, -5px) skewX(45deg); - } - .bb-region.marker-head-arrow-left > rect { - width: 3px; - height: 3px; - } + /*-- Regions --*/ - .bb-region.marker-head-arrow-right { - fill: var(--color-graph-minimap-marker-head); - stroke: var(--color-graph-minimap-marker-head); - transform: translate(1px, -5px) skewX(-45deg); - } - .bb-region.marker-head-arrow-right > rect { - width: 3px; - height: 3px; - } + .bb-regions { + pointer-events: none; + } - .bb-region.marker-upstream { - fill: var(--color-graph-minimap-marker-upstream); - stroke: var(--color-graph-minimap-marker-upstream); - transform: translate(-1px, -4px); - } - .bb-region.marker-upstream > rect { - width: 1px; - height: 100%; - } + .bb-region > rect:not([x]) { + display: none; + } - .bb-region.marker-branch { - fill: var(--color-graph-minimap-marker-local-branches); - stroke: var(--color-graph-minimap-marker-local-branches); - transform: translate(-2px, 32px); - } - .bb-region.marker-branch > rect { - width: 3px; - height: 3px; - } + .bb-region.visible-area { + fill: var(--color-graph-minimap-visibleAreaBackground); + /* transform: translateY(-4px); */ + } + .bb-region.visible-area > rect { + height: 100%; + } - .bb-region.marker-remote { - fill: var(--color-graph-minimap-marker-remote-branches); - stroke: var(--color-graph-minimap-marker-remote-branches); - transform: translate(-2px, 26px); - } - .bb-region.marker-remote > rect { - width: 3px; - height: 3px; - } + .bb-region.marker-result { + fill: var(--color-graph-minimap-marker-highlights); + transform: translateX(-1px); + z-index: 10; + } + .bb-region.marker-result > rect { + width: 2px; + height: 100%; + } - .bb-region.marker-stash { - fill: var(--color-graph-minimap-marker-stashes); - stroke: var(--color-graph-minimap-marker-stashes); - transform: translate(-2px, 32px); - } - .bb-region.marker-stash > rect { - width: 3px; - height: 3px; - } + .bb-region.marker-head { + fill: var(--color-graph-minimap-marker-head); + stroke: var(--color-graph-minimap-marker-head); + transform: translateX(-1px); + } + .bb-region.marker-head > rect { + width: 1px; + height: 100%; + } - .bb-region.marker-tag { - fill: var(--color-graph-minimap-marker-tags); - stroke: var(--color-graph-minimap-marker-tags); - transform: translate(-2px, 26px); - } - .bb-region.marker-tag > rect { - width: 3px; - height: 3px; - } + .bb-region.marker-head-arrow-left { + fill: var(--color-graph-minimap-marker-head); + stroke: var(--color-graph-minimap-marker-head); + transform: translate(-5px, -1px) skewX(45deg); + } + .bb-region.marker-head-arrow-left > rect { + width: 3px; + height: 3px; + } + + .bb-region.marker-head-arrow-right { + fill: var(--color-graph-minimap-marker-head); + stroke: var(--color-graph-minimap-marker-head); + transform: translate(1px, -1px) skewX(-45deg); + } + .bb-region.marker-head-arrow-right > rect { + width: 3px; + height: 3px; + } + + .bb-region.marker-upstream { + fill: var(--color-graph-minimap-marker-upstream); + stroke: var(--color-graph-minimap-marker-upstream); + transform: translateX(-1px); + } + .bb-region.marker-upstream > rect { + width: 1px; + height: 100%; + } + + .bb-region.marker-pull-request { + fill: var(--color-graph-minimap-marker-pull-requests); + stroke: var(--color-graph-minimap-marker-pull-requests); + transform: translate(-2px, 29px); + } + .bb-region.marker-pull-request > rect { + width: 3px; + height: 3px; + } + + .bb-region.marker-branch { + fill: var(--color-graph-minimap-marker-local-branches); + stroke: var(--color-graph-minimap-marker-local-branches); + transform: translate(-2px, 35px); + } + .bb-region.marker-branch > rect { + width: 3px; + height: 3px; + } + + .bb-region.marker-remote { + fill: var(--color-graph-minimap-marker-remote-branches); + stroke: var(--color-graph-minimap-marker-remote-branches); + transform: translate(-2px, 29px); + } + .bb-region.marker-remote > rect { + width: 3px; + height: 3px; + } - /*-- Zoom region --*/ - /* + .bb-region.marker-stash { + fill: var(--color-graph-minimap-marker-stashes); + stroke: var(--color-graph-minimap-marker-stashes); + transform: translate(-2px, 35px); + } + .bb-region.marker-stash > rect { + width: 3px; + height: 3px; + } + + .bb-region.marker-tag { + fill: var(--color-graph-minimap-marker-tags); + stroke: var(--color-graph-minimap-marker-tags); + transform: translate(-2px, 29px); + } + .bb-region.marker-tag > rect { + width: 3px; + height: 3px; + } + + /*-- Zoom region --*/ + /* :host-context(.vscode-dark) .bb-zoom-brush { fill: white; fill-opacity: 0.2; @@ -250,15 +314,15 @@ const styles = css` } */ - /*-- Brush --*/ - /* + /*-- Brush --*/ + /* .bb-brush .extent { fill-opacity: 0.1; } */ - /*-- Button --*/ - /* + /*-- Button --*/ + /* .bb-button { position: absolute; top: 2px; @@ -283,166 +347,183 @@ const styles = css` } */ - /*-- Tooltip --*/ - .bb-tooltip-container { - top: unset !important; - z-index: 10; - user-select: none; - min-width: 300px; - } + /*-- Tooltip --*/ + .bb-tooltip-container { + top: unset !important; + z-index: 10; + user-select: none; + min-width: 300px; + } - .bb-tooltip { - display: flex; - flex-direction: column; - padding: 0.5rem 1rem; - background-color: var(--color-hover-background); - color: var(--color-hover-foreground); - border: 1px solid var(--color-hover-border); - box-shadow: 0 2px 8px var(--vscode-widget-shadow); - font-size: var(--font-size); - opacity: 1; - white-space: nowrap; - } + .bb-tooltip { + display: flex; + flex-direction: column; + padding: 0.5rem 1rem; + background-color: var(--color-hover-background); + color: var(--color-hover-foreground); + border: 1px solid var(--color-hover-border); + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + font-size: var(--font-size); + opacity: 1; + white-space: nowrap; + } - .bb-tooltip .header { - display: flex; - flex-direction: row; - justify-content: space-between; - gap: 1rem; - } + .bb-tooltip .header { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 1rem; + } - .bb-tooltip .header--title { - font-weight: 600; - } + .bb-tooltip .header--title { + font-weight: 600; + } - .bb-tooltip .header--description { - font-weight: normal; - font-style: italic; - } + .bb-tooltip .header--description { + font-weight: normal; + font-style: italic; + } - .bb-tooltip .changes { - margin: 0.5rem 0; - } + .bb-tooltip .changes { + margin: 0.5rem 0; + } - .bb-tooltip .refs { - display: flex; - font-size: 12px; - gap: 0.5rem; - flex-direction: row; - flex-wrap: wrap; - margin: 0.5rem 0; - max-width: fit-content; - } - .bb-tooltip .refs .branch { - border-radius: 3px; - padding: 0 4px; - background-color: var(--color-graph-minimap-tip-branchBackground); - border: 1px solid var(--color-graph-minimap-tip-branchBorder); - color: var(--color-graph-minimap-tip-branchForeground); - } - .bb-tooltip .refs .branch.current { - background-color: var(--color-graph-minimap-tip-headBackground); - border: 1px solid var(--color-graph-minimap-tip-headBorder); - color: var(--color-graph-minimap-tip-headForeground); - } - .bb-tooltip .refs .remote { - border-radius: 3px; - padding: 0 4px; - background-color: var(--color-graph-minimap-tip-remoteBackground); - border: 1px solid var(--color-graph-minimap-tip-remoteBorder); - color: var(--color-graph-minimap-tip-remoteForeground); - } - .bb-tooltip .refs .remote.current { - background-color: var(--color-graph-minimap-tip-upstreamBackground); - border: 1px solid var(--color-graph-minimap-tip-upstreamBorder); - color: var(--color-graph-minimap-tip-upstreamForeground); - } - .bb-tooltip .refs .stash { - border-radius: 3px; - padding: 0 4px; - background-color: var(--color-graph-minimap-tip-stashBackground); - border: 1px solid var(--color-graph-minimap-tip-stashBorder); - color: var(--color-graph-minimap-tip-stashForeground); - } - .bb-tooltip .refs .tag { - border-radius: 3px; - padding: 0 4px; - background-color: var(--color-graph-minimap-tip-tagBackground); - border: 1px solid var(--color-graph-minimap-tip-tagBorder); - color: var(--color-graph-minimap-tip-tagForeground); - } -`; + .bb-tooltip .results { + display: flex; + font-size: 12px; + gap: 0.5rem; + flex-direction: row; + flex-wrap: wrap; + margin: 0.5rem 0; + max-width: fit-content; + } -const markerZOrder = [ - 'marker-result', - 'marker-head-arrow-left', - 'marker-head-arrow-right', - 'marker-head', - 'marker-upstream', - 'marker-branch', - 'marker-stash', - 'marker-remote', - 'marker-tag', - 'visible-area', -]; + .bb-tooltip .results .result { + border-radius: 3px; + padding: 0 4px; + background-color: var(--color-graph-minimap-tip-highlightBackground); + border: 1px solid var(--color-graph-minimap-tip-highlightBorder); + color: var(--color-graph-minimap-tip-highlightForeground); + } -@customElement({ name: 'graph-minimap', template: template, styles: styles }) -export class GraphMinimap extends FASTElement { - chart!: HTMLDivElement; + .bb-tooltip .refs { + display: flex; + font-size: 12px; + gap: 0.5rem; + flex-direction: row; + flex-wrap: wrap; + margin: 0.5rem 0; + max-width: fit-content; + } + .bb-tooltip .refs:empty { + margin: 0; + } + .bb-tooltip .refs .branch { + border-radius: 3px; + padding: 0 4px; + background-color: var(--color-graph-minimap-tip-branchBackground); + border: 1px solid var(--color-graph-minimap-tip-branchBorder); + color: var(--color-graph-minimap-tip-branchForeground); + } + .bb-tooltip .refs .branch.current { + background-color: var(--color-graph-minimap-tip-headBackground); + border: 1px solid var(--color-graph-minimap-tip-headBorder); + color: var(--color-graph-minimap-tip-headForeground); + } + .bb-tooltip .refs .remote { + border-radius: 3px; + padding: 0 4px; + background-color: var(--color-graph-minimap-tip-remoteBackground); + border: 1px solid var(--color-graph-minimap-tip-remoteBorder); + color: var(--color-graph-minimap-tip-remoteForeground); + } + .bb-tooltip .refs .remote.current { + background-color: var(--color-graph-minimap-tip-upstreamBackground); + border: 1px solid var(--color-graph-minimap-tip-upstreamBorder); + color: var(--color-graph-minimap-tip-upstreamForeground); + } + .bb-tooltip .refs .stash { + border-radius: 3px; + padding: 0 4px; + background-color: var(--color-graph-minimap-tip-stashBackground); + border: 1px solid var(--color-graph-minimap-tip-stashBorder); + color: var(--color-graph-minimap-tip-stashForeground); + } + .bb-tooltip .refs .pull-request { + border-radius: 3px; + padding: 0 4px; + background-color: var(--color-graph-minimap-pullRequestBackground); + border: 1px solid var(--color-graph-minimap-pullRequestBorder); + color: var(--color-graph-minimap-pullRequestForeground); + } + .bb-tooltip .refs .tag { + border-radius: 3px; + padding: 0 4px; + background-color: var(--color-graph-minimap-tip-tagBackground); + border: 1px solid var(--color-graph-minimap-tip-tagBorder); + color: var(--color-graph-minimap-tip-tagForeground); + } + + .bb-event-rects { + cursor: pointer !important; + } + `; + + @query('#chart') + chartContainer!: HTMLDivElement; private _chart!: Chart; + + @query('#spinner') + spinner!: HTMLDivElement; + private _loadTimer: ReturnType | undefined; private _markerRegions: Iterable | undefined; private _regions: RegionOptions[] | undefined; - @observable + @property({ type: Number }) activeDay: number | undefined; - @debug({ singleLine: true }) - protected activeDayChanged() { + + @observe('activeDay') + private onActiveDayChanged() { this.select(this.activeDay); } - @observable + @property({ type: Map }) data: Map | undefined; - @debug({ singleLine: true }) - protected dataChanged( - _oldVal?: Map, - _newVal?: Map, - markerChanged?: boolean, - ) { - if (this._loadTimer) { - clearTimeout(this._loadTimer); - this._loadTimer = undefined; - } - if (markerChanged) { - this._regions = undefined; - this._markerRegions = undefined; - } + @property({ type: String }) + dataType: 'commits' | 'lines' = 'commits'; - this._loadTimer = setTimeout(() => this.loadChart(), 150); + @observe(['data', 'dataType']) + private onDataChanged() { + this.handleDataChanged(false); } - @observable + @property({ type: Map }) markers: Map | undefined; - protected markersChanged() { - this.dataChanged(undefined, undefined, true); + + @observe('markers') + private onMarkersChanged() { + this.handleDataChanged(true); } - @observable + @property({ type: Map }) searchResults: Map | undefined; - protected searchResultsChanged() { + + @observe('searchResults') + private onSearchResultsChanged() { this._chart?.regions.remove({ classes: ['marker-result'] }); if (this.searchResults == null) return; - this._chart?.regions.add([...this.getSearchResultsRegions(this.searchResults)]); } - @observable + @property({ type: Object }) visibleDays: { top: number; bottom: number } | undefined; - @debug({ singleLine: true }) - protected visibleDaysChanged() { + + @observe('visibleDays') + private onVisibleDaysChanged() { this._chart?.regions.remove({ classes: ['visible-area'] }); if (this.visibleDays == null) return; @@ -452,7 +533,7 @@ export class GraphMinimap extends FASTElement { override connectedCallback(): void { super.connectedCallback(); - this.dataChanged(undefined, undefined, false); + this.handleDataChanged(false); } override disconnectedCallback(): void { @@ -462,11 +543,29 @@ export class GraphMinimap extends FASTElement { this._chart = undefined!; } + @debug({ singleLine: true }) + private handleDataChanged(markerChanged: boolean) { + if (this._loadTimer) { + clearTimeout(this._loadTimer); + this._loadTimer = undefined; + } + + if (markerChanged) { + this._regions = undefined; + this._markerRegions = undefined; + } + + this._loadTimer = setTimeout(() => this.loadChart(), 150); + } + private getInternalChart(): any { - return (this._chart as any).internal; + try { + return (this._chart as any)?.internal; + } catch { + return undefined; + } } - @debug({ singleLine: true }) select(date: number | Date | undefined, trackOnly: boolean = false) { if (date == null) { this.unselect(); @@ -478,6 +577,8 @@ export class GraphMinimap extends FASTElement { if (d == null) return; const internal = this.getInternalChart(); + if (internal == null) return; + internal.showGridFocus([d]); if (!trackOnly) { @@ -488,10 +589,9 @@ export class GraphMinimap extends FASTElement { } } - @debug({ singleLine: true }) unselect(date?: number | Date, focus: boolean = false) { if (focus) { - this.getInternalChart().hideGridFocus(); + this.getInternalChart()?.hideGridFocus(); return; } @@ -506,7 +606,7 @@ export class GraphMinimap extends FASTElement { } } - private getData(date: number | Date): DataItem | undefined { + private getData(date: number | Date): DataItem | undefined { date = new Date(date).setHours(0, 0, 0, 0); return this._chart ?.data()[0] @@ -590,15 +690,15 @@ export class GraphMinimap extends FASTElement { start: day, end: day, class: 'marker-result', - } satisfies RegionOptions), + }) satisfies RegionOptions, ); } private getVisibleAreaRegion(visibleDays: NonNullable): RegionOptions { return { axis: 'x', - start: visibleDays.bottom, - end: visibleDays.top, + start: visibleDays.top, + end: visibleDays.bottom, class: 'visible-area', } satisfies RegionOptions; } @@ -613,13 +713,15 @@ export class GraphMinimap extends FASTElement { @debug({ singleLine: true }) private async loadChartCore() { if (!this.data?.size) { + this.spinner.setAttribute('aria-hidden', 'false'); + this._chart?.destroy(); this._chart = undefined!; return; } - const hasActivity = some(this.data.values(), v => v?.activity != null); + const showLinesChanged = this.dataType === 'lines'; // Convert the map to an array dates and an array of stats const dates = []; @@ -650,7 +752,7 @@ export class GraphMinimap extends FASTElement { stat = this.data.get(day); dates.push(day); - if (hasActivity) { + if (showLinesChanged) { adds = stat?.activity?.additions ?? 0; deletes = stat?.activity?.deletions ?? 0; changes = adds + deletes; @@ -721,12 +823,13 @@ export class GraphMinimap extends FASTElement { ); if (this._chart == null) { - const { bb, selection, spline, zoom } = await import(/* webpackChunkName: "billboard" */ 'billboard.js'); + const { bb, selection, spline, zoom } = await import( + /* webpackChunkName: "lib-billboard" */ 'billboard.js' + ); this._chart = bb.generate({ - bindto: this.chart, + bindto: this.chartContainer, data: { x: 'date', - xSort: false, axes: { activity: 'y', }, @@ -745,10 +848,7 @@ export class GraphMinimap extends FASTElement { const sha = this.searchResults?.get(day)?.sha ?? this.data?.get(day)?.sha; queueMicrotask(() => { - this.$emit('selected', { - date: date, - sha: sha, - } satisfies GraphMinimapDaySelectedEventDetail); + this.emit('gl-graph-minimap-selected', { date: date, sha: sha }); }); }, selection: { @@ -765,17 +865,13 @@ export class GraphMinimap extends FASTElement { }, axis: { x: { - show: false, + inverted: true, localtime: true, type: 'timeseries', }, y: { min: 0, max: yMax, - show: true, - padding: { - bottom: 8, - }, }, }, clipPath: false, @@ -792,6 +888,13 @@ export class GraphMinimap extends FASTElement { point: true, zerobased: true, }, + padding: { + mode: 'fit', + bottom: -8, + left: 0, + right: 0, + top: 0, + }, point: { show: true, select: { @@ -819,94 +922,104 @@ export class GraphMinimap extends FASTElement { contents: (data, _defaultTitleFormat, _defaultValueFormat, _color) => { const date = new Date(data[0].x); - const stat = this.data?.get(getDay(date)); - const markers = this.markers?.get(getDay(date)); + const day = getDay(date); + const stat = this.data?.get(day); + const markers = this.markers?.get(day); + const results = this.searchResults?.get(day); + let groups; if (markers?.length) { groups = groupByMap(markers, m => m.type); } const stashesCount = groups?.get('stash')?.length ?? 0; + const pullRequestsCount = groups?.get('pull-request')?.length ?? 0; + + let commits; + let linesChanged; + let resultsCount; + if (stat?.commits) { + commits = pluralize('commit', stat.commits, { format: c => formatNumeric(c) }); + if (results?.count) { + resultsCount = pluralize('matching commit', results.count); + } - return /*html*/ `
    -
    - ${formatDate(date, 'MMMM Do, YYYY')} - (${capitalize(fromNow(date))}) -
    -
    - ${ - (stat?.commits ?? 0) === 0 - ? 'No commits' - : `${pluralize('commit', stat?.commits ?? 0, { - format: c => formatNumeric(c), - zero: 'No', - })}, ${pluralize('file', stat?.commits ?? 0, { - format: c => formatNumeric(c), - zero: 'No', - })}${ - hasActivity - ? `, ${pluralize( - 'line', - (stat?.activity?.additions ?? 0) + - (stat?.activity?.deletions ?? 0), - { - format: c => formatNumeric(c), - zero: 'No', - }, - )}` - : '' - } changed` - } -
    - ${ - groups != null - ? /*html*/ ` -
    - ${ - groups - ?.get('branch') - ?.sort((a, b) => (a.current ? -1 : 1) - (b.current ? -1 : 1)) - .map( - m => - /*html*/ `${ - m.name - }`, - ) - .join('') ?? '' - } - ${ - groups - ?.get('remote') - ?.sort((a, b) => (a.current ? -1 : 1) - (b.current ? -1 : 1)) - ?.map( - m => - /*html*/ `${ - m.name - }`, - ) - .join('') ?? '' - } - ${stashesCount ? /*html*/ `${pluralize('stash', stashesCount, { plural: 'stashes' })}` : ''} - ${ - groups - ?.get('tag') - ?.map(m => /*html*/ `${m.name}`) - .join('') ?? '' - } -
    ` - : '' + if (this.dataType === 'lines') { + linesChanged = `${pluralize('file', stat?.files ?? 0, { + format: c => formatNumeric(c), + zero: 'No', + })}, ${pluralize( + 'line', + (stat?.activity?.additions ?? 0) + (stat?.activity?.deletions ?? 0), + { + format: c => formatNumeric(c), + zero: 'No', + }, + )} changed`; } -
    `; + } else { + commits = 'No commits'; + } + + return /*html*/ `
    +
    + ${formatDate(date, 'MMMM Do, YYYY')} + (${capitalize(fromNow(date))}) +
    +
    + ${commits}${linesChanged ? `, ${linesChanged}` : ''} +
    + ${resultsCount ? /*html*/ `
    ${resultsCount}
    ` : ''} + ${ + groups != null + ? /*html*/ ` +
    ${ + stashesCount + ? /*html*/ `${pluralize('stash', stashesCount, { + plural: 'stashes', + })}` + : '' + }${ + groups + ?.get('branch') + ?.sort((a, b) => (a.current ? -1 : 1) - (b.current ? -1 : 1)) + .map( + m => /*html*/ `${m.name}`, + ) + .join('') ?? '' + }
    +
    ${ + pullRequestsCount + ? /*html*/ `${pluralize('pull request', pullRequestsCount, { + plural: 'pull requests', + })}` + : '' + }${ + groups + ?.get('remote') + ?.sort((a, b) => (a.current ? -1 : 1) - (b.current ? -1 : 1)) + ?.map( + m => /*html*/ `${m.name}`, + ) + .join('') ?? '' + }${ + groups + ?.get('tag') + ?.map(m => /*html*/ `${m.name}`) + .join('') ?? '' + }
    ` + : '' + } +
    `; }, grouped: true, position: (_data, width, _height, element, pos) => { - const { x } = pos; + let { x } = pos; const rect = (element as HTMLElement).getBoundingClientRect(); - let left = rect.right - x; - if (left + width > rect.right) { - left = rect.right - width; + if (x + width > rect.right) { + x = rect.right - width; } - return { top: 0, left: left }; + return { top: 0, left: x }; }, }, transition: { @@ -917,7 +1030,7 @@ export class GraphMinimap extends FASTElement { rescale: false, type: 'wheel', // Reset the active day when zooming because it fails to update properly - onzoom: debounce(() => this.activeDayChanged(), 250), + onzoom: debounce(() => this.onActiveDayChanged(), 250), }, onafterinit: function () { const xAxis = this.$.main.selectAll('.bb-axis-x').node(); @@ -951,14 +1064,25 @@ export class GraphMinimap extends FASTElement { this._chart.regions(regions); } - this.activeDayChanged(); + this.spinner.setAttribute('aria-hidden', 'true'); + + this.onActiveDayChanged(); + } + + override render() { + return html` +
    +
    +
    + +
    + `; } } function getDay(date: number | Date): number { return new Date(date).setHours(0, 0, 0, 0); } - -function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1); -} diff --git a/src/webviews/apps/plus/graph/minimap/react.tsx b/src/webviews/apps/plus/graph/minimap/react.tsx deleted file mode 100644 index 2819f0c33a417..0000000000000 --- a/src/webviews/apps/plus/graph/minimap/react.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { reactWrapper } from '../../../shared/components/helpers/react-wrapper'; -import { GraphMinimap as graphMinimapComponent } from './minimap'; - -export const GraphMinimap = reactWrapper(graphMinimapComponent, { - events: { - onSelected: 'selected', - }, -}); diff --git a/src/webviews/apps/plus/graph/sidebar/sidebar.react.tsx b/src/webviews/apps/plus/graph/sidebar/sidebar.react.tsx new file mode 100644 index 0000000000000..78bc35ff4b7e4 --- /dev/null +++ b/src/webviews/apps/plus/graph/sidebar/sidebar.react.tsx @@ -0,0 +1,5 @@ +import { reactWrapper } from '../../../shared/components/helpers/react-wrapper'; +import { GlGraphSideBar as GlGraphSideBarWC } from './sidebar'; + +export interface GlGraphSideBar extends GlGraphSideBarWC {} +export const GlGraphSideBar = reactWrapper(GlGraphSideBarWC, { tagName: 'gl-graph-sidebar' }); diff --git a/src/webviews/apps/plus/graph/sidebar/sidebar.ts b/src/webviews/apps/plus/graph/sidebar/sidebar.ts new file mode 100644 index 0000000000000..ed18e116e8cb0 --- /dev/null +++ b/src/webviews/apps/plus/graph/sidebar/sidebar.ts @@ -0,0 +1,169 @@ +import { consume } from '@lit/context'; +import { Task } from '@lit/task'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { DidChangeNotification, GetCountsRequest } from '../../../../../plus/webviews/graph/protocol'; +import { ipcContext } from '../../../shared/context'; +import type { Disposable } from '../../../shared/events'; +import type { HostIpc } from '../../../shared/ipc'; +import '../../../shared/components/code-icon'; +import '../../../shared/components/overlays/tooltip'; + +interface Icon { + type: IconTypes; + icon: string; + command: string; + tooltip: string; +} +type IconTypes = 'branches' | 'remotes' | 'stashes' | 'tags' | 'worktrees'; +const icons: Icon[] = [ + { type: 'branches', icon: 'gl-branches-view', command: 'gitlens.showBranchesView', tooltip: 'Branches' }, + { type: 'remotes', icon: 'gl-remotes-view', command: 'gitlens.showRemotesView', tooltip: 'Remotes' }, + { type: 'stashes', icon: 'gl-stashes-view', command: 'gitlens.showStashesView', tooltip: 'Stashes' }, + { type: 'tags', icon: 'gl-tags-view', command: 'gitlens.showTagsView', tooltip: 'Tags' }, + { type: 'worktrees', icon: 'gl-worktrees-view', command: 'gitlens.showWorktreesView', tooltip: 'Worktrees' }, +]; + +type Counts = Record; + +@customElement('gl-graph-sidebar') +export class GlGraphSideBar extends LitElement { + static override styles = css` + .sidebar { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.4rem; + background-color: var(--color-graph-background); + color: var(--titlebar-fg); + width: 2.6rem; + font-size: 9px; + font-weight: 600; + height: 100vh; + padding: 3rem 0; + z-index: 1040; + } + + .item { + color: inherit; + text-decoration: none; + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + } + + .item:hover { + color: var(--color-foreground); + text-decoration: none; + } + + .count { + color: var(--color-foreground--50); + /* color: var(--color-highlight); */ + margin-top: 0.4rem; + } + + .count.error { + color: var(--vscode-errorForeground); + opacity: 0.6; + } + `; + + @property({ type: Boolean }) + enabled = true; + + @property({ type: Array }) + include?: IconTypes[]; + + @consume({ context: ipcContext }) + private _ipc!: HostIpc; + private _disposable: Disposable | undefined; + private _countsTask = new Task(this, { + args: () => [this.fetchCounts()], + task: ([counts]) => counts, + autoRun: false, + }); + + override connectedCallback() { + super.connectedCallback(); + + this._disposable = this._ipc.onReceiveMessage(msg => { + switch (true) { + case DidChangeNotification.is(msg): + this._counts = undefined; + this.requestUpdate(); + break; + + case GetCountsRequest.response.is(msg): + this._counts = Promise.resolve(msg.params as Counts); + this.requestUpdate(); + break; + } + }); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + + this._disposable?.dispose(); + } + + private _counts: Promise | undefined; + private async fetchCounts() { + if (this._counts == null) { + const ipc = this._ipc; + if (ipc != null) { + async function fetch() { + const rsp = await ipc.sendRequest(GetCountsRequest, undefined); + return rsp as Counts; + } + this._counts = fetch(); + } else { + this._counts = Promise.resolve(undefined); + } + } + return this._counts; + } + + override render() { + if (!this.enabled) return nothing; + + if (this._counts == null) { + void this._countsTask.run(); + } + + return html``; + } + + private renderIcon(icon: Icon) { + if (this.include != null && !this.include.includes(icon.type)) return; + + return html` + + + ${this._countsTask.render({ + pending: () => + html``, + complete: c => renderCount(c?.[icon.type]), + error: () => html``, + })} + + `; + } +} + +function renderCount(count: number | undefined) { + if (count == null) return nothing; + + return html`${count > 999 ? '1K+' : String(count)}`; +} diff --git a/src/webviews/apps/plus/patchDetails/components/gl-draft-details.ts b/src/webviews/apps/plus/patchDetails/components/gl-draft-details.ts new file mode 100644 index 0000000000000..2328454b4a95d --- /dev/null +++ b/src/webviews/apps/plus/patchDetails/components/gl-draft-details.ts @@ -0,0 +1,963 @@ +import { Avatar, Button, defineGkElement, Menu, MenuItem, Popover } from '@gitkraken/shared-web-components'; +import { html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { map } from 'lit/directives/map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { when } from 'lit/directives/when.js'; +import type { TextDocumentShowOptions } from 'vscode'; +import type { + DraftArchiveReason, + DraftPatchFileChange, + DraftRole, + DraftVisibility, +} from '../../../../../gk/models/drafts'; +import type { + CloudDraftDetails, + DraftDetails, + DraftUserSelection, + ExecuteFileActionParams, + PatchDetails, + State, +} from '../../../../../plus/webviews/patchDetails/protocol'; +import { makeHierarchical } from '../../../../../system/array'; +import { flatCount } from '../../../../../system/iterable'; +import type { + TreeItemActionDetail, + TreeItemBase, + TreeItemCheckedDetail, + TreeItemSelectionDetail, + TreeModel, +} from '../../../shared/components/tree/base'; +import { GlTreeBase } from './gl-tree-base'; +import '../../../shared/components/actions/action-item'; +import '../../../shared/components/actions/action-nav'; +import '../../../shared/components/badges/badge'; +import '../../../shared/components/button-container'; +import '../../../shared/components/button'; +import '../../../shared/components/code-icon'; +import '../../../shared/components/commit/commit-identity'; +import '../../../shared/components/tree/tree-generator'; +import '../../../shared/components/webview-pane'; + +// Can only import types from 'vscode' +const BesideViewColumn = -2; /*ViewColumn.Beside*/ + +interface ExplainState { + cancelled?: boolean; + error?: { message: string }; + summary?: string; +} + +export interface ApplyPatchDetail { + draft: DraftDetails; + target?: 'current' | 'branch' | 'worktree'; + base?: string; + selectedPatches?: string[]; + // [key: string]: unknown; +} + +export interface ChangePatchBaseDetail { + draft: DraftDetails; + // [key: string]: unknown; +} + +export interface SelectPatchRepoDetail { + draft: DraftDetails; + repoPath?: string; + // [key: string]: unknown; +} + +export interface ShowPatchInGraphDetail { + draft: DraftDetails; + // [key: string]: unknown; +} + +export interface PatchCheckedDetail { + patch: PatchDetails; + checked: boolean; +} + +export interface PatchDetailsUpdateSelectionEventDetail { + selection: DraftUserSelection; + role: Exclude | 'remove'; +} + +export interface DraftReasonEventDetail { + reason: Exclude; +} + +@customElement('gl-draft-details') +export class GlDraftDetails extends GlTreeBase { + @property({ type: Object }) + state!: State; + + @state() + explainBusy = false; + + @property({ type: Object }) + explain?: ExplainState; + + @state() + selectedPatches: string[] = []; + + @state() + validityMessage?: string; + + @state() + private _copiedLink: boolean = false; + + get cloudDraft() { + if (this.state.draft?.draftType !== 'cloud') { + return undefined; + } + + return this.state.draft; + } + + get isCodeSuggestion() { + return this.cloudDraft?.type === 'suggested_pr_change'; + } + + get canSubmit() { + return this.selectedPatches.length > 0; + // return this.state.draft?.repoPath != null && this.state.draft?.baseRef != null; + } + + constructor() { + super(); + + defineGkElement(Avatar, Button, Popover, Menu, MenuItem); + } + + override updated(changedProperties: Map) { + if (changedProperties.has('explain')) { + this.explainBusy = false; + this.querySelector('[data-region="ai-explanation"]')?.scrollIntoView(); + } + + if (changedProperties.has('state')) { + const patches = this.state?.draft?.patches; + if (!patches?.length) { + this.selectedPatches = []; + } else { + this.selectedPatches = patches.map(p => p.id); + for (const patch of patches) { + const index = this.selectedPatches.indexOf(patch.id); + if (patch.repository.located) { + if (index === -1) { + this.selectedPatches.push(patch.id); + } + } else if (index > -1) { + this.selectedPatches.splice(index, 1); + } + } + } + // } else if (patches?.length === 1) { + // this.selectedPatches = [patches[0].id]; + // } else { + // this.selectedPatches = this.selectedPatches.filter(id => { + // return patches.find(p => p.id === id) != null; + // }); + // } + } + } + + private renderEmptyContent() { + return html` +
    + + Open Patch... + +
    + `; + } + + private renderPatchMessage() { + if (this.state?.draft?.title == null) return undefined; + let description = this.cloudDraft?.description; + if (description == null) return undefined; + + description = description.trim(); + + return html` +
    +

    + ${unsafeHTML(description)} +

    +
    + `; + } + + private renderExplainAi() { + if (this.state?.orgSettings.ai === false) return undefined; + + // TODO: add loading and response states + return html` + + Explain (AI) + + + + + +
    +

    Let AI assist in understanding the changes made with this patch.

    +

    + + Explain + Changes + +

    + ${when( + this.explain, + () => html` +
    + ${when( + this.explain?.error, + () => + html`

    + ${this.explain!.error!.message ?? 'Error retrieving content'} +

    `, + )} + ${when( + this.explain?.summary, + () => html`

    ${this.explain!.summary}

    `, + )} +
    + `, + )} +
    +
    + `; + } + + // private renderCommitStats() { + // if (this.state?.draft?.stats?.changedFiles == null) { + // return undefined; + // } + + // if (typeof this.state.draft.stats.changedFiles === 'number') { + // return html``; + // } + + // const { added, deleted, changed } = this.state.draft.stats.changedFiles; + // return html``; + // } + + private renderChangedFiles() { + const layout = this.state?.preferences?.files?.layout ?? 'auto'; + + return html` + + Files changed + + ${this.renderLayoutAction(layout)} + + ${when( + this.validityMessage != null, + () => + html`
    +
    + +

    ${this.validityMessage}

    +
    +
    `, + )} +
    + ${when( + this.state?.draft?.patches == null, + () => this.renderLoading(), + () => this.renderTreeView(this.treeModel, this.state?.preferences?.indentGuides), + )} +
    +
    + `; + } + + // TODO: make a local state instead of a getter + get treeModel(): TreeModel[] { + if (this.state?.draft?.patches == null) return []; + + const { + draft: { patches }, + } = this.state; + + const layout = this.state?.preferences?.files?.layout ?? 'auto'; + let isTree = false; + + const fileCount = flatCount(patches, p => p?.files?.length ?? 0); + if (layout === 'auto') { + isTree = fileCount > (this.state.preferences?.files?.threshold ?? 5); + } else { + isTree = layout === 'tree'; + } + + const models = patches?.map(p => + this.draftPatchToTreeModel(p, isTree, this.state.preferences?.files?.compact, { + checkable: true, + checked: this.selectedPatches.includes(p.id), + }), + ); + return models; + } + + renderUserSelection(userSelection: DraftUserSelection, role: DraftRole) { + if (userSelection.change === 'delete') return undefined; + + const selectionRole = userSelection.pendingRole ?? userSelection.user!.role; + const options = new Map([ + ['owner', 'owner'], + ['admin', 'admin'], + ['editor', 'can edit'], + ['viewer', 'can view'], + ['remove', 'un-invite'], + ]); + const roleLabel = options.get(selectionRole); + return html` +
    +
    + +
    +
    +
    + ${userSelection.member?.name ?? userSelection.member?.username ?? 'Unknown'} +
    +
    +
    + ${when( + selectionRole !== 'owner' && (role === 'owner' || role === 'admin'), + () => html` + + ${roleLabel} + + ${map(options, ([value, label]) => + value === 'owner' + ? undefined + : html` + this.onChangeSelectionRole( + e, + userSelection, + value as PatchDetailsUpdateSelectionEventDetail['role'], + )} + > + + ${label} + `, + )} + + + `, + () => html`${roleLabel}`, + )} +
    +
    + `; + } + + renderUserSelectionList(draft: CloudDraftDetails, includeOwner = false) { + if (!draft.userSelections?.length) return undefined; + + let userSelections = draft.userSelections; + if (includeOwner === false) { + userSelections = userSelections.filter(u => u.user?.role !== 'owner'); + } + + return html` +
    +
    + ${repeat( + userSelections, + userSelection => userSelection.member?.id ?? userSelection.user?.id, + userSelection => this.renderUserSelection(userSelection, draft.role), + )} +
    +
    + `; + } + + renderPatchPermissions() { + const draft = this.cloudDraft; + if (draft == null) return undefined; + + if (draft.role === 'admin' || draft.role === 'owner') { + const hasChanges = draft.userSelections?.some(selection => selection.change !== undefined); + let visibilityIcon: string | undefined; + switch (draft.visibility) { + case 'private': + visibilityIcon = 'organization'; + break; + case 'invite_only': + visibilityIcon = 'lock'; + break; + default: + visibilityIcon = 'globe'; + break; + } + return html` + ${when( + this.isCodeSuggestion !== true, + () => + html`
    +
    + + + +
    + Invite +
    `, + )} + ${this.renderUserSelectionList(draft)} + ${when( + hasChanges, + () => html` +

    + + Update Patch + +

    + `, + )} + `; + } + + return html` + ${when( + this.isCodeSuggestion !== true, + () => + html`
    +
    + ${when( + draft.visibility === 'public', + () => html` Anyone with the link`, + )} + ${when( + draft.visibility === 'private', + () => html` Members of my Org with the link`, + )} + ${when( + draft.visibility === 'invite_only', + () => html` Collaborators only`, + )} +
    +
    `, + )} + ${this.renderUserSelectionList(draft, true)} + `; + } + + renderCodeSuggectionActions() { + if ( + !this.isCodeSuggestion || + this.cloudDraft == null || + this.cloudDraft.isArchived || + this.cloudDraft.role === 'viewer' + ) { + return undefined; + } + + return html` +

    + + this.onArchiveDraft('accepted')} + >Accept + this.onArchiveDraft('rejected')} + >Reject + +

    + `; + } + + renderPatches() { + // // const path = this.state.draft?.repoPath; + // const repo = this.state.draft?.repoName; + // const base = this.state.draft?.baseRef; + + // const getActions = () => { + // if (!repo) { + // return html` + // Select base repo + // + // `; + // } + + // if (!base) { + // return html` + // ${repo} + // Select base + // + // `; + // } + + // return html` + // ${repo} + // ${base?.substring(0, 7)} + // + // `; + // }; + + //
    + //
    ${getActions()}
    + //
    + + return html` +
    + ${this.renderPatchPermissions()} +

    + + Apply Patch + + + + Apply to a Branch + + + + +

    + ${this.renderCodeSuggectionActions()} +
    + `; + } + + renderActionbar() { + const draft = this.state?.draft; + if (draft == null) return undefined; + + if (draft.draftType === 'local') { + return html` +
    +
    +
    + Share +
    +
    + `; + } + + return html` +
    +
    +
    + + + Copy Link + ${when( + this.cloudDraft?.gkDevLink != null, + () => html` + + + + `, + )} +
    +
    + `; + } + + renderDraftInfo() { + if (this.state.draft?.title == null) return nothing; + + let badge = undefined; + if (this.cloudDraft?.isArchived) { + const label = this.cloudDraft.archivedReason ?? 'archived'; + badge = html`${label}`; + } + + return html` +

    ${this.state.draft?.title} ${badge}

    + ${this.renderPatchMessage()} + `; + } + + override render() { + if (this.state?.draft == null) { + return html`
    ${this.renderEmptyContent()}
    `; + } + + return html` +
    +
    +
    ${this.renderActionbar()}${this.renderDraftInfo()}
    +
    +
    ${this.renderChangedFiles()}
    +
    + ${this.renderExplainAi()}${this.renderPatches()} +
    +
    + `; + } + + protected override createRenderRoot() { + return this; + } + + private onInviteUsers(_e: Event) { + this.emit('gl-patch-details-invite-users'); + } + + private onChangeSelectionRole( + e: MouseEvent, + selection: DraftUserSelection, + role: PatchDetailsUpdateSelectionEventDetail['role'], + ) { + this.emit('gl-patch-details-update-selection', { selection: selection, role: role }); + + const popoverEl: Popover | null = (e.target as HTMLElement)?.closest('gk-popover'); + popoverEl?.hidePopover(); + } + + private onVisibilityChange(e: Event) { + const draft = this.state.draft as CloudDraftDetails; + draft.visibility = (e.target as HTMLInputElement).value as DraftVisibility; + this.emit('gl-patch-details-update-metadata', { visibility: draft.visibility }); + } + + private onUpdatePatch(_e: Event) { + this.emit('gl-patch-details-update-permissions'); + } + + onExplainChanges(e: MouseEvent | KeyboardEvent) { + if (this.explainBusy === true || (e instanceof KeyboardEvent && e.key !== 'Enter')) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + this.explainBusy = true; + } + + override onTreeItemActionClicked(e: CustomEvent) { + if (!e.detail.context || !e.detail.action) return; + + const action = e.detail.action; + switch (action.action) { + // repo actions + case 'apply-patch': + this.onApplyPatch(); + break; + case 'change-patch-base': + this.onChangePatchBase(); + break; + case 'show-patch-in-graph': + this.onShowInGraph(); + break; + // file actions + case 'file-open': + this.onOpenFile(e); + break; + case 'file-compare-working': + this.onCompareWorking(e); + break; + } + } + + fireFileEvent(name: string, file: DraftPatchFileChange, showOptions?: TextDocumentShowOptions) { + const event = new CustomEvent(name, { + detail: { ...file, showOptions: showOptions }, + }); + this.dispatchEvent(event); + } + + onCompareWorking(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + this.emit('gl-patch-file-compare-working', { + ...file, + showOptions: { + preview: !e.detail.dblClick, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }, + }); + } + + onOpenFile(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + this.emit('gl-patch-file-open', { + ...file, + showOptions: { + preview: !e.detail.dblClick, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }, + }); + } + + override onTreeItemChecked(e: CustomEvent) { + if (!e.detail.context) return; + + const [gkRepositoryId] = e.detail.context; + const patch = this.state.draft?.patches?.find(p => p.gkRepositoryId === gkRepositoryId); + if (!patch) return; + const selectedIndex = this.selectedPatches.indexOf(patch?.id); + if (e.detail.checked) { + if (selectedIndex === -1) { + this.selectedPatches.push(patch.id); + this.validityMessage = undefined; + } + } else if (selectedIndex > -1) { + this.selectedPatches.splice(selectedIndex, 1); + } + + const event = new CustomEvent('gl-patch-checked', { + detail: { + patch: patch, + checked: e.detail.checked, + }, + }); + this.dispatchEvent(event); + } + + override onTreeItemSelected(e: CustomEvent) { + const { node, context } = e.detail; + if (node.branch === true || context == null) return; + + const [file] = context; + this.emit('gl-patch-file-compare-previous', { ...file }); + } + + onApplyPatch(_e?: MouseEvent | KeyboardEvent, target: 'current' | 'branch' | 'worktree' = 'current') { + if (this.canSubmit === false) { + this.validityMessage = 'Please select changes to apply'; + return; + } + + this.validityMessage = undefined; + + this.emit('gl-patch-apply-patch', { + draft: this.state.draft!, + target: target, + selectedPatches: this.selectedPatches, + }); + } + + onArchiveDraft(reason: DraftReasonEventDetail['reason']) { + this.emit('gl-draft-archive', { reason: reason }); + } + + onSelectApplyOption(e: CustomEvent<{ target: MenuItem }>) { + if (this.canSubmit === false) { + this.validityMessage = 'Please select changes to apply'; + return; + } + + const target = e.detail?.target; + if (target?.dataset?.value != null) { + this.onApplyPatch(undefined, target.dataset.value as 'current' | 'branch' | 'worktree'); + } + } + + onChangePatchBase(_e?: MouseEvent | KeyboardEvent) { + const evt = new CustomEvent('change-patch-base', { + detail: { + draft: this.state.draft!, + }, + }); + this.dispatchEvent(evt); + } + + onSelectPatchRepo(_e?: MouseEvent | KeyboardEvent) { + const evt = new CustomEvent('select-patch-repo', { + detail: { + draft: this.state.draft!, + }, + }); + this.dispatchEvent(evt); + } + + onShowInGraph(_e?: MouseEvent | KeyboardEvent) { + this.emit('gl-patch-details-graph-show-patch', { draft: this.state.draft! }); + } + + onCopyCloudLink() { + this.emit('gl-patch-details-copy-cloud-link', { draft: this.state.draft! }); + this._copiedLink = true; + setTimeout(() => (this._copiedLink = false), 1000); + } + + onShareLocalPatch() { + this.emit('gl-patch-details-share-local-patch', { draft: this.state.draft! }); + } + + draftPatchToTreeModel( + patch: NonNullable[0], + isTree = false, + compact = true, + options?: Partial, + ): TreeModel { + const model = this.repoToTreeModel( + patch.repository.name, + patch.gkRepositoryId, + options, + patch.repository.located ? undefined : 'missing', + ); + + if (!patch.files?.length) return model; + + const children = []; + if (isTree) { + const fileTree = makeHierarchical( + patch.files, + n => n.path.split('/'), + (...parts: string[]) => parts.join('/'), + compact, + ); + if (fileTree.children != null) { + for (const child of fileTree.children.values()) { + const childModel = this.walkFileTree(child, { level: 2 }); + children.push(childModel); + } + } + } else { + for (const file of patch.files) { + const child = this.fileToTreeModel(file, { level: 2, branch: false }, true); + children.push(child); + } + } + + if (children.length > 0) { + model.branch = true; + model.children = children; + } + + return model; + } + + // override getRepoActions(_name: string, _path: string, _options?: Partial) { + // return [ + // { + // icon: 'cloud-download', + // label: 'Apply...', + // action: 'apply-patch', + // }, + // // { + // // icon: 'git-commit', + // // label: 'Change Base', + // // action: 'change-patch-base', + // // }, + // { + // icon: 'gl-graph', + // label: 'Open in Commit Graph', + // action: 'show-patch-in-graph', + // }, + // ]; + // } + + override getFileActions(_file: DraftPatchFileChange, _options?: Partial) { + return [ + { + icon: 'go-to-file', + label: 'Open file', + action: 'file-open', + }, + { + icon: 'git-compare', + label: 'Open Changes with Working File', + action: 'file-compare-working', + }, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gl-draft-details': GlDraftDetails; + } + + interface GlobalEventHandlersEventMap { + 'gl-draft-archive': CustomEvent; + 'gl-patch-apply-patch': CustomEvent; + 'gl-patch-details-graph-show-patch': CustomEvent<{ draft: DraftDetails }>; + 'gl-patch-details-share-local-patch': CustomEvent<{ draft: DraftDetails }>; + 'gl-patch-details-copy-cloud-link': CustomEvent<{ draft: DraftDetails }>; + 'gl-patch-file-compare-previous': CustomEvent; + 'gl-patch-file-compare-working': CustomEvent; + 'gl-patch-file-open': CustomEvent; + 'gl-patch-checked': CustomEvent; + 'gl-patch-details-invite-users': CustomEvent; + 'gl-patch-details-update-selection': CustomEvent; + 'gl-patch-details-update-metadata': CustomEvent<{ visibility: DraftVisibility }>; + 'gl-patch-details-update-permissions': CustomEvent; + } +} diff --git a/src/webviews/apps/plus/patchDetails/components/gl-patch-create.ts b/src/webviews/apps/plus/patchDetails/components/gl-patch-create.ts new file mode 100644 index 0000000000000..8c04afdfc1b03 --- /dev/null +++ b/src/webviews/apps/plus/patchDetails/components/gl-patch-create.ts @@ -0,0 +1,864 @@ +import { Avatar, Button, defineGkElement, Menu, MenuItem, Popover } from '@gitkraken/shared-web-components'; +import { html } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { map } from 'lit/directives/map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { when } from 'lit/directives/when.js'; +import { urls } from '../../../../../constants'; +import type { GitFileChangeShape } from '../../../../../git/models/file'; +import type { DraftRole, DraftVisibility } from '../../../../../gk/models/drafts'; +import type { + Change, + DraftUserSelection, + ExecuteFileActionParams, + State, +} from '../../../../../plus/webviews/patchDetails/protocol'; +import { debounce } from '../../../../../system/function'; +import { flatCount } from '../../../../../system/iterable'; +import type { Serialized } from '../../../../../system/vscode/serialize'; +import type { + TreeItemActionDetail, + TreeItemBase, + TreeItemCheckedDetail, + TreeItemSelectionDetail, + TreeModel, +} from '../../../shared/components/tree/base'; +import { GlTreeBase } from './gl-tree-base'; +import '../../../shared/components/actions/action-nav'; +import '../../../shared/components/button'; +import '../../../shared/components/code-icon'; +import '../../../shared/components/commit/commit-stats'; +import '../../../shared/components/webview-pane'; + +export interface CreatePatchEventDetail { + title: string; + description?: string; + visibility: DraftVisibility; + changesets: Record; + userSelections: DraftUserSelection[] | undefined; +} + +export interface CreatePatchMetadataEventDetail { + title: string; + description: string | undefined; + visibility: DraftVisibility; +} + +export interface CreatePatchCheckRepositoryEventDetail { + repoUri: string; + checked: boolean | 'staged'; +} + +export interface CreatePatchUpdateSelectionEventDetail { + selection: DraftUserSelection; + role: Exclude | 'remove'; +} + +interface GenerateState { + cancelled?: boolean; + error?: { message: string }; + title?: string; + description?: string; +} + +// Can only import types from 'vscode' +const BesideViewColumn = -2; /*ViewColumn.Beside*/ + +@customElement('gl-patch-create') +export class GlPatchCreate extends GlTreeBase { + @property({ type: Object }) state?: Serialized; + + @property({ type: Boolean }) review = false; + + @property({ type: Object }) + generate?: GenerateState; + + @state() + generateBusy = false; + + @state() + creationBusy = false; + + // @state() + // patchTitle = this.create.title ?? ''; + + // @state() + // description = this.create.description ?? ''; + + @query('#title') + titleInput!: HTMLInputElement; + + @query('#desc') + descInput!: HTMLInputElement; + + @query('#generate-ai') + generateAiButton!: HTMLElement; + + @state() + validityMessage?: string; + + get create() { + return this.state!.create!; + } + + get createChanges() { + return Object.values(this.create.changes); + } + + get createEntries() { + return Object.entries(this.create.changes); + } + + get hasWipChanges() { + return this.createChanges.some(change => change?.type === 'wip'); + } + + get selectedChanges(): [string, Change][] { + if (this.createChanges.length === 1) return this.createEntries; + + return this.createEntries.filter(([, change]) => change.checked !== false); + } + + get canSubmit() { + return this.create.title != null && this.create.title.length > 0 && this.selectedChanges.length > 0; + } + + get fileLayout() { + return this.state?.preferences?.files?.layout ?? 'auto'; + } + + get isCompact() { + return this.state?.preferences?.files?.compact ?? true; + } + + get filesModified() { + return flatCount(this.createChanges, c => c.files?.length ?? 0); + } + + get draftVisibility() { + return this.state?.create?.visibility ?? 'public'; + } + + constructor() { + super(); + + defineGkElement(Avatar, Button, Menu, MenuItem, Popover); + } + + override updated(changedProperties: Map) { + if (changedProperties.has('state')) { + this.creationBusy = false; + } + if (changedProperties.has('generate')) { + this.generateBusy = false; + this.generateAiButton.scrollIntoView(); + } + } + protected override firstUpdated() { + window.requestAnimationFrame(() => { + this.titleInput.focus(); + }); + } + + renderUserSelection(userSelection: DraftUserSelection) { + const role = userSelection.pendingRole!; + const options = new Map([ + ['admin', 'admin'], + ['editor', 'can edit'], + ['viewer', 'can view'], + ['remove', 'un-invite'], + ]); + const roleLabel = options.get(role); + return html` +
    +
    + +
    +
    +
    + ${userSelection.member.name ?? userSelection.member.username ?? 'Unknown'} +
    +
    +
    + + ${roleLabel} + + ${map( + options, + ([value, label]) => + html` + this.onChangeSelectionRole( + e, + userSelection, + value as CreatePatchUpdateSelectionEventDetail['role'], + )} + > + + ${label} + `, + )} + + +
    +
    + `; + } + + renderUserSelectionList() { + if (this.state?.create?.userSelections == null || this.state?.create?.userSelections.length === 0) { + return undefined; + } + + return html` +
    +
    + ${repeat( + this.state.create.userSelections, + userSelection => userSelection.member.id, + userSelection => this.renderUserSelection(userSelection), + )} +
    +
    + `; + } + + renderForm() { + let visibilityIcon: string | undefined; + switch (this.draftVisibility) { + case 'private': + visibilityIcon = 'organization'; + break; + case 'invite_only': + visibilityIcon = 'lock'; + break; + default: + visibilityIcon = 'globe'; + break; + } + + const draftName = this.review ? 'Code Suggestion' : 'Cloud Patch'; + const draftNamePlural = this.review ? 'Code Suggestions' : 'Cloud Patches'; + return html` +
    + ${when( + this.state?.create?.creationError != null, + () => + html`
    + +

    ${this.state!.create!.creationError}

    +
    `, + )} + ${when( + this.review === false, + () => html` +
    +
    + + + +
    + Invite +
    + ${this.renderUserSelectionList()} + `, + )} +
    + this.onDebounceTitleInput(e)} + /> + ${when( + this.state?.orgSettings.ai === true, + () => + html`
    + this.onGenerateTitleClick(e)} + ?disabled=${this.generateBusy} + > +
    `, + )} +
    + + ${when( + this.generate?.error != null, + () => html` +
    + +

    ${this.generate!.error!.message ?? 'Error retrieving content'}

    +
    + `, + )} +
    + +
    +

    + + this.onCreateAll(e)} + >Create ${draftName} + +

    + ${when( + this.review === true, + () => html` +

    + + this.onCancel()} + >Cancel + +

    + `, + )} + ${when( + this.state?.orgSettings.byob === false, + () => + html`

    + + ${draftNamePlural} + are + securely stored + by GitKraken. +

    `, + () => + html`

    + + Your + ${draftName} + will be securely stored in your organization's self-hosted storage +

    `, + )} +
    + `; + } + + // + // + override render() { + return html` +
    +
    ${this.renderChangedFiles()}
    +
    ${this.renderForm()}
    +
    + `; + } + + private renderChangedFiles() { + return html` + + ${this.review ? 'Changes to Suggest' : 'Changes to Include'} + ${this.renderLayoutAction(this.fileLayout)} + + ${when( + this.validityMessage != null, + () => + html`
    +
    + +

    ${this.validityMessage}

    +
    +
    `, + )} +
    + ${when( + this.create.changes == null, + () => this.renderLoading(), + () => this.renderTreeViewWithModel(), + )} +
    +
    + `; + } + + // private renderChangeStats() { + // if (this.filesModified == null) return undefined; + + // return html``; + // } + + override onTreeItemChecked(e: CustomEvent) { + console.log(e); + // this.onRepoChecked() + if (e.detail.context == null || e.detail.context.length < 1) return; + + const [repoUri, type] = e.detail.context; + let checked: boolean | 'staged' = e.detail.checked; + if (type === 'unstaged') { + checked = e.detail.checked ? true : 'staged'; + } + const change = this.getChangeForRepo(repoUri); + if (change == null) { + debugger; + return; + } + + if (change.checked === checked) return; + + change.checked = checked; + this.requestUpdate('state'); + + this.emit('gl-patch-create-repo-checked', { + repoUri: repoUri, + checked: checked, + }); + } + + override onTreeItemSelected(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + this.emit('gl-patch-file-compare-previous', { ...file }); + } + + private renderTreeViewWithModel() { + if (this.createChanges == null || this.createChanges.length === 0) { + return this.renderTreeView([ + { + label: 'No changes', + path: '', + level: 1, + branch: false, + checkable: false, + expanded: true, + checked: false, + }, + ]); + } + + const treeModel: TreeModel[] = []; + // for knowing if we need to show repos + const isCheckable = this.createChanges.length > 1; + const isTree = this.isTree(this.filesModified ?? 0); + const compact = this.isCompact; + + if (isCheckable) { + for (const changeset of this.createChanges) { + const tree = this.getTreeForChange(changeset, true, isTree, compact); + if (tree != null) { + treeModel.push(...tree); + } + } + } else { + const changeset = this.createChanges[0]; + const tree = this.getTreeForChange(changeset, false, isTree, compact); + if (tree != null) { + treeModel.push(...tree); + } + } + return this.renderTreeView(treeModel, this.state?.preferences?.indentGuides); + } + + private getTreeForChange(change: Change, isMulti = false, isTree = false, compact = true): TreeModel[] | undefined { + if (change.files == null || change.files.length === 0) return undefined; + + const children = []; + if (change.type === 'wip') { + const staged: Change['files'] = []; + const unstaged: Change['files'] = []; + + for (const f of change.files) { + if (f.staged) { + staged.push(f); + } else { + unstaged.push(f); + } + } + + if (staged.length === 0 || unstaged.length === 0) { + children.push(...this.renderFiles(change.files, isTree, compact, isMulti ? 2 : 1)); + } else { + if (unstaged.length) { + children.push({ + label: 'Unstaged Changes', + path: '', + level: isMulti ? 2 : 1, + branch: true, + checkable: true, + expanded: true, + checked: change.checked === true, + context: [change.repository.uri, 'unstaged'], + children: this.renderFiles(unstaged, isTree, compact, isMulti ? 3 : 2), + }); + } + + if (staged.length) { + children.push({ + label: 'Staged Changes', + path: '', + level: isMulti ? 2 : 1, + branch: true, + checkable: true, + expanded: true, + checked: change.checked !== false, + disableCheck: true, + children: this.renderFiles(staged, isTree, compact, isMulti ? 3 : 2), + }); + } + } + } else { + children.push(...this.renderFiles(change.files, isTree, compact)); + } + + if (!isMulti) { + return children; + } + + const repoModel = this.repoToTreeModel(change.repository.name, change.repository.uri, { + branch: true, + checkable: true, + checked: change.checked !== false, + }); + repoModel.children = children; + + return [repoModel]; + } + + private isTree(count: number) { + if (this.fileLayout === 'auto') { + return count > (this.state?.preferences?.files?.threshold ?? 5); + } + return this.fileLayout === 'tree'; + } + + private createPatch() { + if (!this.canSubmit) { + // TODO: show error + if (this.titleInput.value.length === 0) { + this.titleInput.setCustomValidity('Title is required'); + this.titleInput.reportValidity(); + this.titleInput.focus(); + } else { + this.titleInput.setCustomValidity(''); + } + + if (this.selectedChanges == null || this.selectedChanges.length === 0) { + this.validityMessage = 'Check at least one change'; + } else { + this.validityMessage = undefined; + } + return; + } + this.validityMessage = undefined; + this.titleInput.setCustomValidity(''); + + const changes = this.selectedChanges.reduce>((a, [id, change]) => { + a[id] = change; + return a; + }, {}); + + const patch: CreatePatchEventDetail = { + title: this.create.title ?? '', + description: this.create.description, + changesets: changes, + visibility: this.create.visibility, + userSelections: this.create.userSelections, + }; + this.emit('gl-patch-create-patch', patch); + } + + private onCreateAll(_e: Event) { + // const change = this.create.[0]; + // if (change == null) { + // return; + // } + // this.createPatch([change]); + this.createPatch(); + if (!this.state?.create) { + return; + } + this.creationBusy = true; + } + + private onSelectCreateOption(_e: CustomEvent<{ target: MenuItem }>) { + // const target = e.detail?.target; + // const value = target?.dataset?.value as 'staged' | 'unstaged' | undefined; + // const currentChange = this.create.[0]; + // if (value == null || currentChange == null) { + // return; + // } + // const change = { + // ...currentChange, + // files: currentChange.files.filter(file => { + // const staged = file.staged ?? false; + // return (staged && value === 'staged') || (!staged && value === 'unstaged'); + // }), + // }; + // this.createPatch([change]); + } + + private getChangeForRepo(repoUri: string): Change | undefined { + return this.create.changes[repoUri]; + + // for (const [id, change] of this.createEntries) { + // if (change.repository.uri === repoUri) return change; + // } + + // return undefined; + } + + // private onRepoChecked(e: CustomEvent<{ repoUri: string; checked: boolean }>) { + // const [_, changeset] = this.getRepoChangeSet(e.detail.repoUri); + + // if ((changeset as RepoWipChangeSet).checked === e.detail.checked) { + // return; + // } + + // (changeset as RepoWipChangeSet).checked = e.detail.checked; + // this.requestUpdate('state'); + // } + + // private onUnstagedChecked(e: CustomEvent<{ repoUri: string; checked: boolean | 'staged' }>) { + // const [_, changeset] = this.getRepoChangeSet(e.detail.repoUri); + + // if ((changeset as RepoWipChangeSet).checked === e.detail.checked) { + // return; + // } + + // (changeset as RepoWipChangeSet).checked = e.detail.checked; + // this.requestUpdate('state'); + // } + + private onTitleInput(_e: InputEvent) { + this.create.title = this.titleInput.value; + this.fireMetadataUpdate(); + } + + private onDebounceTitleInput = debounce(this.onTitleInput, 500); + + private onDescriptionInput(_e: InputEvent) { + this.create.description = this.descInput.value; + this.fireMetadataUpdate(); + } + + private onDebounceDescriptionInput = debounce(this.onDescriptionInput, 500); + + private onInviteUsers(_e: Event) { + this.emit('gl-patch-create-invite-users'); + } + + private onChangeSelectionRole( + e: MouseEvent, + selection: DraftUserSelection, + role: CreatePatchUpdateSelectionEventDetail['role'], + ) { + this.emit('gl-patch-create-update-selection', { selection: selection, role: role }); + + const popoverEl: Popover | null = (e.target as HTMLElement)?.closest('gk-popover'); + popoverEl?.hidePopover(); + } + + private onVisibilityChange(e: Event) { + this.create.visibility = (e.target as HTMLInputElement).value as DraftVisibility; + this.fireMetadataUpdate(); + } + + private onGenerateTitleClick(_e: Event) { + this.generateBusy = true; + this.emit('gl-patch-generate-title', { + title: this.create.title!, + description: this.create.description, + visibility: this.create.visibility, + }); + } + + private fireMetadataUpdate() { + this.emit('gl-patch-create-update-metadata', { + title: this.create.title!, + description: this.create.description, + visibility: this.create.visibility, + }); + } + + protected override createRenderRoot() { + return this; + } + + override onTreeItemActionClicked(e: CustomEvent) { + if (!e.detail.context || !e.detail.action) return; + + const action = e.detail.action; + switch (action.action) { + case 'show-patch-in-graph': + this.onShowInGraph(e); + break; + + case 'file-open': + this.onOpenFile(e); + break; + + case 'file-stage': + this.onStageFile(e); + break; + + case 'file-unstage': + this.onUnstageFile(e); + break; + } + } + + onOpenFile(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + this.emit('gl-patch-file-open', { + ...file, + showOptions: { + preview: !e.detail.dblClick, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }, + }); + } + + onStageFile(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + this.emit('gl-patch-file-stage', { + ...file, + showOptions: { + preview: !e.detail.dblClick, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }, + }); + } + + onUnstageFile(e: CustomEvent) { + if (!e.detail.context) return; + + const [file] = e.detail.context; + this.emit('gl-patch-file-unstage', { + ...file, + showOptions: { + preview: !e.detail.dblClick, + viewColumn: e.detail.altKey ? BesideViewColumn : undefined, + }, + }); + } + + onShowInGraph(_e: CustomEvent) { + // this.emit('gl-patch-details-graph-show-patch', { draft: this.state!.create! }); + } + + onCancel() { + this.emit('gl-patch-create-cancelled'); + } + + override getFileActions(file: GitFileChangeShape, _options?: Partial) { + const openFile = { + icon: 'go-to-file', + label: 'Open file', + action: 'file-open', + }; + + if (this.review) { + return [openFile]; + } + if (file.staged === true) { + return [openFile, { icon: 'remove', label: 'Unstage changes', action: 'file-unstage' }]; + } + return [openFile, { icon: 'plus', label: 'Stage changes', action: 'file-stage' }]; + } + + override getRepoActions(_name: string, _path: string, _options?: Partial) { + return [ + { + icon: 'gl-graph', + label: 'Open in Commit Graph', + action: 'show-patch-in-graph', + }, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gl-patch-create': GlPatchCreate; + } + + interface GlobalEventHandlersEventMap { + 'gl-patch-create-repo-checked': CustomEvent; + 'gl-patch-create-patch': CustomEvent; + 'gl-patch-create-update-metadata': CustomEvent; + 'gl-patch-file-compare-previous': CustomEvent; + 'gl-patch-file-compare-working': CustomEvent; + 'gl-patch-file-open': CustomEvent; + 'gl-patch-file-stage': CustomEvent; + 'gl-patch-file-unstage': CustomEvent; + 'gl-patch-generate-title': CustomEvent; + 'gl-patch-create-invite-users': CustomEvent; + 'gl-patch-create-update-selection': CustomEvent; + 'gl-patch-create-cancelled': CustomEvent; + // 'gl-patch-details-graph-show-patch': CustomEvent<{ draft: State['create'] }>; + } +} diff --git a/src/webviews/apps/plus/patchDetails/components/gl-tree-base.ts b/src/webviews/apps/plus/patchDetails/components/gl-tree-base.ts new file mode 100644 index 0000000000000..29cfc5f476349 --- /dev/null +++ b/src/webviews/apps/plus/patchDetails/components/gl-tree-base.ts @@ -0,0 +1,214 @@ +import { html, nothing } from 'lit'; +import type { GitFileChangeShape } from '../../../../../git/models/file'; +import type { HierarchicalItem } from '../../../../../system/array'; +import { makeHierarchical } from '../../../../../system/array'; +import { GlElement } from '../../../shared/components/element'; +import type { + TreeItemAction, + TreeItemActionDetail, + TreeItemBase, + TreeItemCheckedDetail, + TreeItemSelectionDetail, + TreeModel, +} from '../../../shared/components/tree/base'; +import '../../../shared/components/tree/tree-generator'; +import '../../../shared/components/skeleton-loader'; +import '../../../shared/components/actions/action-item'; + +export class GlTreeBase extends GlElement { + protected onTreeItemActionClicked?(_e: CustomEvent): void; + protected onTreeItemChecked?(_e: CustomEvent): void; + protected onTreeItemSelected?(_e: CustomEvent): void; + + protected renderLoading() { + return html` +
    + +
    +
    + +
    +
    + +
    + `; + } + + protected renderLayoutAction(layout: string) { + if (!layout) return nothing; + + let value = 'tree'; + let icon = 'list-tree'; + let label = 'View as Tree'; + switch (layout) { + case 'auto': + value = 'list'; + icon = 'gl-list-auto'; + label = 'View as List'; + break; + case 'list': + value = 'tree'; + icon = 'list-flat'; + label = 'View as Tree'; + break; + case 'tree': + value = 'auto'; + icon = 'list-tree'; + label = 'View as Auto'; + break; + } + + return html``; + } + + protected renderTreeView(treeModel: TreeModel[], guides: 'none' | 'onHover' | 'always' = 'none') { + return html``; + } + + protected renderFiles(files: GitFileChangeShape[], isTree = false, compact = false, level = 2): TreeModel[] { + const children: TreeModel[] = []; + if (isTree) { + const fileTree = makeHierarchical( + files, + n => n.path.split('/'), + (...parts: string[]) => parts.join('/'), + compact, + ); + if (fileTree.children != null) { + for (const child of fileTree.children.values()) { + const childModel = this.walkFileTree(child, { level: level }); + children.push(childModel); + } + } + } else { + for (const file of files) { + const child = this.fileToTreeModel(file, { level: level, branch: false }, true); + children.push(child); + } + } + + return children; + } + + protected walkFileTree( + item: HierarchicalItem, + options: Partial = { level: 1 }, + ): TreeModel { + if (options.level === undefined) { + options.level = 1; + } + + let model: TreeModel; + if (item.value == null) { + model = this.folderToTreeModel(item.name, options); + } else { + model = this.fileToTreeModel(item.value, options); + } + + if (item.children != null) { + const children = []; + for (const child of item.children.values()) { + const childModel = this.walkFileTree(child, { ...options, level: options.level + 1 }); + children.push(childModel); + } + + if (children.length > 0) { + model.branch = true; + model.children = children; + } + } + + return model; + } + + protected folderToTreeModel(name: string, options?: Partial): TreeModel { + return { + branch: false, + expanded: true, + path: name, + level: 1, + checkable: false, + checked: false, + icon: 'folder', + label: name, + ...options, + }; + } + + protected getRepoActions(_name: string, _path: string, _options?: Partial): TreeItemAction[] { + return []; + } + + protected emptyTreeModel(name: string, options?: Partial): TreeModel { + return { + branch: false, + expanded: true, + path: '', + level: 1, + checkable: true, + checked: true, + icon: undefined, + label: name, + ...options, + }; + } + + protected repoToTreeModel( + name: string, + path: string, + options?: Partial, + description?: string, + ): TreeModel { + return { + branch: false, + expanded: true, + path: path, + level: 1, + checkable: true, + checked: true, + icon: 'repo', + label: name, + description: description, + context: [path], + actions: this.getRepoActions(name, path, options), + ...options, + }; + } + + protected getFileActions(_file: GitFileChangeShape, _options?: Partial): TreeItemAction[] { + return []; + } + + protected fileToTreeModel( + file: GitFileChangeShape, + options?: Partial, + flat = false, + glue = '/', + ): TreeModel { + const pathIndex = file.path.lastIndexOf(glue); + const fileName = pathIndex !== -1 ? file.path.substring(pathIndex + 1) : file.path; + const filePath = flat && pathIndex !== -1 ? file.path.substring(0, pathIndex) : ''; + + return { + branch: false, + expanded: true, + path: file.path, + level: 1, + checkable: false, + checked: false, + icon: 'file', //{ type: 'status', name: file.status }, + label: fileName, + description: flat === true ? filePath : undefined, + context: [file], + actions: this.getFileActions(file, options), + decorations: [{ type: 'text', label: file.status }], + ...options, + }; + } +} diff --git a/src/webviews/apps/plus/patchDetails/components/patch-details-app.ts b/src/webviews/apps/plus/patchDetails/components/patch-details-app.ts new file mode 100644 index 0000000000000..b8fe0d0587adb --- /dev/null +++ b/src/webviews/apps/plus/patchDetails/components/patch-details-app.ts @@ -0,0 +1,158 @@ +import { Badge, defineGkElement, Menu, MenuItem, Popover } from '@gitkraken/shared-web-components'; +import { html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { DraftDetails, Mode, State } from '../../../../../plus/webviews/patchDetails/protocol'; +import { GlElement } from '../../../shared/components/element'; +import type { PatchDetailsApp } from '../patchDetails'; +import './gl-draft-details'; +import './gl-patch-create'; + +interface ExplainState { + cancelled?: boolean; + error?: { message: string }; + summary?: string; +} + +interface GenerateState { + cancelled?: boolean; + error?: { message: string }; + title?: string; + description?: string; +} + +export interface ApplyPatchDetail { + draft: DraftDetails; + target?: 'current' | 'branch' | 'worktree'; + base?: string; + // [key: string]: unknown; +} + +export interface ChangePatchBaseDetail { + draft: DraftDetails; + // [key: string]: unknown; +} + +export interface SelectPatchRepoDetail { + draft: DraftDetails; + repoPath?: string; + // [key: string]: unknown; +} + +export interface ShowPatchInGraphDetail { + draft: DraftDetails; + // [key: string]: unknown; +} + +@customElement('gl-patch-details-app') +export class GlPatchDetailsApp extends GlElement { + @property({ type: Object }) + state!: State; + + @property({ type: Object }) + explain?: ExplainState; + + @property({ type: Object }) + generate?: GenerateState; + + @property({ attribute: false, type: Object }) + app?: PatchDetailsApp; + + constructor() { + super(); + + defineGkElement(Badge, Popover, Menu, MenuItem); + } + + get wipChangesCount() { + if (this.state?.create == null) return 0; + + return Object.values(this.state.create.changes).reduce((a, c) => { + a += c.files?.length ?? 0; + return a; + }, 0); + } + + get wipChangeState() { + if (this.state?.create == null) return undefined; + + const state = Object.values(this.state.create.changes).reduce( + (a, c) => { + if (c.files != null) { + a.files += c.files.length; + a.on.add(c.repository.uri); + } + return a; + }, + { files: 0, on: new Set() }, + ); + + // return file length total and repo/branch names + return { + count: state.files, + branches: Array.from(state.on).join(', '), + }; + } + + get mode(): Mode { + return this.state?.mode ?? 'view'; + } + + private indentPreference = 16; + private updateDocumentProperties() { + const preference = this.state?.preferences?.indent; + if (preference === this.indentPreference) return; + this.indentPreference = preference ?? 16; + + const rootStyle = document.documentElement.style; + rootStyle.setProperty('--gitlens-tree-indent', `${this.indentPreference}px`); + } + + override updated(changedProperties: Map) { + if (changedProperties.has('state')) { + this.updateDocumentProperties(); + } + } + + override render() { + return html` +
    +
    + ${when( + this.mode === 'view', + () => html``, + () => html``, + )} +
    +
    + `; + } + + // onShowInGraph(e: CustomEvent) { + // this.fireEvent('gl-patch-details-graph-show-patch', e.detail); + // } + + // private onShareLocalPatch(_e: CustomEvent) { + // this.fireEvent('gl-patch-details-share-local-patch'); + // } + + // private onCopyCloudLink(_e: CustomEvent) { + // this.fireEvent('gl-patch-details-copy-cloud-link'); + // } + + protected override createRenderRoot() { + return this; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gl-patch-details-app': GlPatchDetailsApp; + } + + // interface GlobalEventHandlersEventMap { + // 'gl-patch-details-graph-show-patch': CustomEvent; + // 'gl-patch-details-share-local-patch': CustomEvent; + // 'gl-patch-details-copy-cloud-link': CustomEvent; + // } +} diff --git a/src/webviews/apps/plus/patchDetails/patchDetails.html b/src/webviews/apps/plus/patchDetails/patchDetails.html new file mode 100644 index 0000000000000..74d5a9989ed44 --- /dev/null +++ b/src/webviews/apps/plus/patchDetails/patchDetails.html @@ -0,0 +1,27 @@ + + + + + + + + + + #{endOfBody} + + diff --git a/src/webviews/apps/plus/patchDetails/patchDetails.scss b/src/webviews/apps/plus/patchDetails/patchDetails.scss new file mode 100644 index 0000000000000..2c2ccd240f930 --- /dev/null +++ b/src/webviews/apps/plus/patchDetails/patchDetails.scss @@ -0,0 +1,427 @@ +@use '../../shared/styles/details-base'; + +body { + --gk-menu-border-color: var(--vscode-menu-border); + --gk-menu-background-color: var(--vscode-menu-background); + --gk-menu-item-background-color-hover: var(--vscode-menu-selectionBackground); + --gk-menu-item-background-color-active: var(--vscode-menu-background); + --gk-focus-border-color: var(--focus-color); + --gk-tooltip-padding: 0.4rem 0.8rem; + --gk-divider-color: var(--color-background--level-05); + --gk-button-ghost-color: var(--color-foreground--50); + --gitlens-tree-foreground: var(--vscode-sideBar-foreground, var(--vscode-foreground)); +} + +gk-menu { + color: var(--vscode-menu-foreground); +} + +gk-menu-item { + color: var(--vscode-menu-foreground); + + &:hover { + color: var(--vscode-menu-selectionForeground); + } +} + +.commit-action { + display: inline-flex; + justify-content: center; + align-items: center; + height: 21px; + border-radius: 0.25em; + color: inherit; + padding: 0.2rem; + vertical-align: text-bottom; + text-decoration: none; + + > * { + pointer-events: none; + } + + &:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + &:hover { + color: var(--vscode-foreground); + text-decoration: none; + + .vscode-dark & { + background-color: var(--color-background--lighten-15); + } + .vscode-light & { + background-color: var(--color-background--darken-15); + } + } + + &.is-active { + .vscode-dark & { + background-color: var(--color-background--lighten-10); + } + .vscode-light & { + background-color: var(--color-background--darken-10); + } + } + + &.is-disabled { + opacity: 0.5; + pointer-events: none; + } + + &.is-hidden { + display: none; + } +} + +.top-details { + position: sticky; + top: 0; + z-index: 1; + padding: { + top: 0.1rem; + left: var(--gitlens-gutter-width); + right: var(--gitlens-scrollbar-gutter-width); + bottom: 0.5rem; + } + background-color: var(--vscode-sideBar-background); + + &__actionbar { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + + &-group { + display: flex; + flex: none; + flex-direction: row; + max-width: 100%; + } + + &--highlight { + padding: 0 4px 2px 4px; + border: 1px solid var(--color-background--level-15); + border-radius: 0.3rem; + font-family: var(--vscode-editor-font-family); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &.is-pinned { + background-color: var(--color-alert-warningBackground); + box-shadow: 0 0 0 0.1rem var(--color-alert-warningBorder); + border-radius: 0.3rem; + + .commit-action:hover, + .commit-action.is-active { + background-color: var(--color-alert-warningHoverBackground); + } + } + } + + &__sha { + margin: 0 0.5rem 0 0.25rem; + } + + &__authors { + flex-basis: 100%; + padding-top: 0.5rem; + } + + &__author { + & + & { + margin-top: 0.5rem; + } + } +} + +.title { + font-size: 1.6rem; + font-weight: 600; + margin: 0.2rem 0 0.8rem; + + &__badge { + float: right; + } +} + +.message-block__text strong:not(:only-child) { + display: inline-block; + margin-bottom: 0.52rem; +} + +.patch-base { + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + gap: 0.4rem; + padding: { + top: 0.1rem; + bottom: 0.1rem; + } + + :first-child { + margin-right: auto; + } +} + +.section--action { + border-top: 1px solid var(--vscode-sideBarSectionHeader-border); + padding: { + top: 1.5rem; + bottom: 1.5rem; + } + + > :first-child { + padding-top: 0; + } +} + +.change-list { + margin-bottom: 1rem; +} + +// TODO: these form styles should be moved to a common location +.message-input { + padding-top: 0.8rem; + + &__control { + flex: 1; + border: 1px solid var(--vscode-input-border); + background: var(--vscode-input-background); + padding: 0.5rem; + font-size: 1.3rem; + line-height: 1.4; + width: 100%; + border-radius: 0.2rem; + color: var(--vscode-input-foreground); + font-family: inherit; + + &::placeholder { + color: var(--vscode-input-placeholderForeground); + } + + &:invalid { + border-color: var(--vscode-inputValidation-errorBorder); + background-color: var(--vscode-inputValidation-errorBackground); + } + + &:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; + } + + &--text { + overflow: hidden; + white-space: nowrap; + opacity: 0.7; + } + } + + &__action { + flex: none; + } + + &__select { + flex: 1; + position: relative; + display: flex; + align-items: stretch; + + &-icon { + position: absolute; + left: 0; + top: 0; + display: flex; + width: 2.4rem; + height: 100%; + align-items: center; + justify-content: center; + pointer-events: none; + color: var(--vscode-foreground); + } + &-caret { + position: absolute; + right: 0; + top: 0; + display: flex; + width: 2.4rem; + height: 100%; + align-items: center; + justify-content: center; + pointer-events: none; + color: var(--vscode-foreground); + } + } + + &__select &__control { + box-sizing: border-box; + appearance: none; + padding-left: 2.4rem; + padding-right: 2.4rem; + } + + &__menu { + position: absolute; + top: 0.8rem; + right: 0; + } + + &--group { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0.6rem; + } + + &--with-menu { + position: relative; + } +} + +textarea.message-input__control { + resize: vertical; + min-height: 4rem; + max-height: 40rem; +} + +.user-selection-container { + max-height: (2.4rem * 4); + overflow: auto; +} + +.user-selection { + --gk-avatar-size: 2rem; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.4rem; + height: 2.4rem; + + &__avatar { + flex: none; + } + + &__info { + flex: 1; + min-width: 0; + white-space: nowrap; + } + + &__name { + overflow: hidden; + text-overflow: ellipsis; + } + + &__actions { + flex: none; + color: var(--gk-button-ghost-color); + + gk-button::part(base) { + padding-right: 0; + padding-block: 0.4rem; + } + + gk-button code-icon { + pointer-events: none; + } + } + + &__check:not(.is-active) { + opacity: 0; + } +} + +.h { + &-spacing { + margin-bottom: 1.5rem; + } + &-deemphasize { + margin: 0.8rem 0 0.4rem; + opacity: 0.7; + } + &-no-border { + --vscode-sideBarSectionHeader-border: transparent; + } +} + +.alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 0.8rem 1.2rem; + line-height: 1.2; + background-color: var(--color-alert-errorBackground); + border-left: 0.3rem solid var(--color-alert-errorBorder); + color: var(--color-alert-foreground); + + code-icon { + margin-right: 0.4rem; + vertical-align: baseline; + } + + &__content { + font-size: 1.2rem; + line-height: 1.2; + text-align: left; + margin: 0; + } +} + +.commit-detail-panel { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +main { + flex: 1 1 auto; + overflow: hidden; +} + +gl-patch-create { + display: contents; +} + +.pane-groups { + display: flex; + flex-direction: column; + height: 100%; + + &__group { + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow: hidden; + + webview-pane { + flex: none; + + &[expanded] { + min-height: 0; + flex: 1; + } + } + } + + &__group-fixed { + flex: none; + + webview-pane::part(content) { + overflow: visible; + } + } +} diff --git a/src/webviews/apps/plus/patchDetails/patchDetails.ts b/src/webviews/apps/plus/patchDetails/patchDetails.ts new file mode 100644 index 0000000000000..d2201cd64004b --- /dev/null +++ b/src/webviews/apps/plus/patchDetails/patchDetails.ts @@ -0,0 +1,440 @@ +/*global*/ +import type { TextDocumentShowOptions } from 'vscode'; +import type { ViewFilesLayout } from '../../../../config'; +import type { DraftPatchFileChange, DraftVisibility } from '../../../../gk/models/drafts'; +import type { State, SwitchModeParams } from '../../../../plus/webviews/patchDetails/protocol'; +import { + ApplyPatchCommand, + ArchiveDraftCommand, + CopyCloudLinkCommand, + CreateFromLocalPatchCommand, + CreatePatchCommand, + DidChangeCreateNotification, + DidChangeDraftNotification, + DidChangeNotification, + DidChangePatchRepositoryNotification, + DidChangePreferencesNotification, + DraftPatchCheckedCommand, + ExecuteFileActionCommand, + ExplainRequest, + GenerateRequest, + OpenFileCommand, + OpenFileComparePreviousCommand, + OpenFileCompareWorkingCommand, + OpenFileOnRemoteCommand, + SelectPatchBaseCommand, + SelectPatchRepoCommand, + SwitchModeCommand, + UpdateCreatePatchMetadataCommand, + UpdateCreatePatchRepositoryCheckedStateCommand, + UpdatePatchDetailsMetadataCommand, + UpdatePatchDetailsPermissionsCommand, + UpdatePatchUsersCommand, + UpdatePatchUserSelectionCommand, + UpdatePreferencesCommand, +} from '../../../../plus/webviews/patchDetails/protocol'; +import { debounce } from '../../../../system/function'; +import type { Serialized } from '../../../../system/vscode/serialize'; +import type { IpcMessage } from '../../../protocol'; +import { ExecuteCommand } from '../../../protocol'; +import { App } from '../../shared/appBase'; +import { DOM } from '../../shared/dom'; +import type { + ApplyPatchDetail, + DraftReasonEventDetail, + GlDraftDetails, + PatchCheckedDetail, + PatchDetailsUpdateSelectionEventDetail, +} from './components/gl-draft-details'; +import type { + CreatePatchCheckRepositoryEventDetail, + CreatePatchEventDetail, + CreatePatchMetadataEventDetail, + CreatePatchUpdateSelectionEventDetail, + GlPatchCreate, +} from './components/gl-patch-create'; +import type { + ChangePatchBaseDetail, + GlPatchDetailsApp, + SelectPatchRepoDetail, + ShowPatchInGraphDetail, +} from './components/patch-details-app'; +import './patchDetails.scss'; +import './components/patch-details-app'; + +export const uncommittedSha = '0000000000000000000000000000000000000000'; + +export interface FileChangeListItemDetail extends DraftPatchFileChange { + showOptions?: TextDocumentShowOptions; +} + +export class PatchDetailsApp extends App> { + constructor() { + super('PatchDetailsApp'); + } + + override onInitialize() { + this.debouncedAttachState(); + } + + override onBind() { + const disposables = [ + DOM.on('[data-switch-value]', 'click', e => this.onToggleFilesLayout(e)), + DOM.on('[data-action="ai-explain"]', 'click', e => this.onAIExplain(e)), + DOM.on('[data-action="switch-ai"]', 'click', e => this.onSwitchAIModel(e)), + DOM.on('[data-action="mode"]', 'click', e => this.onModeClicked(e)), + DOM.on('gl-draft-details', 'gl-patch-apply-patch', e => + this.onApplyPatch(e.detail), + ), + DOM.on('gl-draft-details', 'gl-draft-archive', e => + this.onArchiveDraft(e.detail.reason), + ), + DOM.on('gl-patch-details-app', 'change-patch-base', e => + this.onChangePatchBase(e.detail), + ), + DOM.on('gl-patch-details-app', 'select-patch-repo', e => + this.onSelectPatchRepo(e.detail), + ), + DOM.on( + 'gl-patch-details-app', + 'gl-patch-details-graph-show-patch', + e => this.onShowPatchInGraph(e.detail), + ), + DOM.on('gl-patch-details-app', 'gl-patch-create-patch', e => + this.onCreatePatch(e.detail), + ), + DOM.on('gl-patch-details-app', 'gl-patch-share-local-patch', () => + this.onShareLocalPatch(), + ), + DOM.on('gl-draft-details', 'gl-patch-details-copy-cloud-link', () => + this.onCopyCloudLink(), + ), + DOM.on('gl-patch-create', 'gl-patch-create-invite-users', () => + this.onInviteUsers(), + ), + DOM.on('gl-draft-details', 'gl-patch-details-invite-users', () => + this.onInviteUsers(), + ), + DOM.on( + 'gl-patch-create', + 'gl-patch-create-update-selection', + e => this.onUpdateUserSelection(e.detail), + ), + DOM.on( + 'gl-draft-details', + 'gl-patch-details-update-selection', + e => this.onUpdateUserSelection(e.detail), + ), + DOM.on( + 'gl-patch-create', + 'gl-patch-create-repo-checked', + e => this.onCreateCheckRepo(e.detail), + ), + DOM.on('gl-patch-create', 'gl-patch-generate-title', e => + this.onCreateGenerateTitle(e.detail), + ), + DOM.on( + 'gl-patch-create', + 'gl-patch-create-update-metadata', + e => this.onCreateUpdateMetadata(e.detail), + ), + DOM.on( + 'gl-draft-details', + 'gl-patch-details-update-metadata', + e => this.onDraftUpdateMetadata(e.detail), + ), + DOM.on('gl-draft-details', 'gl-patch-details-update-permissions', () => + this.onDraftUpdatePermissions(), + ), + DOM.on( + 'gl-patch-create,gl-draft-details', + 'gl-patch-file-compare-previous', + e => this.onCompareFileWithPrevious(e.detail), + ), + DOM.on( + 'gl-patch-create,gl-draft-details', + 'gl-patch-file-compare-working', + e => this.onCompareFileWithWorking(e.detail), + ), + DOM.on( + 'gl-patch-create,gl-draft-details', + 'gl-patch-file-open', + e => this.onOpenFile(e.detail), + ), + DOM.on('gl-draft-details', 'gl-patch-checked', e => + this.onPatchChecked(e.detail), + ), + ]; + + return disposables; + } + + protected override onMessageReceived(msg: IpcMessage) { + switch (true) { + // case DidChangeRichStateNotificationType.method: + // onIpc(DidChangeRichStateNotificationType, msg, params => { + // if (this.state.selected == null) return; + + // assertsSerialized(params); + + // const newState = { ...this.state }; + // if (params.formattedMessage != null) { + // newState.selected!.message = params.formattedMessage; + // } + // // if (params.pullRequest != null) { + // newState.pullRequest = params.pullRequest; + // // } + // // if (params.formattedMessage != null) { + // newState.autolinkedIssues = params.autolinkedIssues; + // // } + + // this.state = newState; + // this.setState(this.state); + + // this.renderRichContent(); + // }); + // break; + case DidChangeNotification.is(msg): + assertsSerialized(msg.params.state); + + this.state = msg.params.state; + this.setState(this.state); + this.debouncedAttachState(); + break; + + case DidChangeCreateNotification.is(msg): + // assertsSerialized(params.state); + + this.state = { ...this.state, ...msg.params }; + this.setState(this.state); + this.debouncedAttachState(true); + break; + + case DidChangeDraftNotification.is(msg): + // assertsSerialized(params.state); + + this.state = { ...this.state, ...msg.params }; + this.setState(this.state); + this.debouncedAttachState(true); + break; + + case DidChangePreferencesNotification.is(msg): + // assertsSerialized(params.state); + + this.state = { ...this.state, ...msg.params }; + this.setState(this.state); + this.debouncedAttachState(true); + break; + + case DidChangePatchRepositoryNotification.is(msg): { + // assertsSerialized(params.state); + + const draft = this.state.draft!; + const patches = draft.patches!; + const patchIndex = patches.findIndex(p => p.id === msg.params.patch.id); + patches.splice(patchIndex, 1, msg.params.patch); + + this.state = { + ...this.state, + draft: draft, + }; + this.setState(this.state); + this.debouncedAttachState(true); + break; + } + default: + super.onMessageReceived?.(msg); + } + } + + private onPatchChecked(e: PatchCheckedDetail) { + this.sendCommand(DraftPatchCheckedCommand, e); + } + + private onCreateCheckRepo(e: CreatePatchCheckRepositoryEventDetail) { + this.sendCommand(UpdateCreatePatchRepositoryCheckedStateCommand, e); + } + + private onCreateUpdateMetadata(e: CreatePatchMetadataEventDetail) { + this.sendCommand(UpdateCreatePatchMetadataCommand, e); + } + + private async onCreateGenerateTitle(_e: CreatePatchMetadataEventDetail) { + try { + const result = await this.sendRequest(GenerateRequest, undefined); + + if (result.error) { + this.component.generate = { error: { message: result.error.message ?? 'Error retrieving content' } }; + } else if (result.title || result.description) { + this.component.generate = { + title: result.title, + description: result.description, + }; + + this.state = { + ...this.state, + create: { + ...this.state.create!, + title: result.title ?? this.state.create?.title, + description: result.description ?? this.state.create?.description, + }, + }; + this.setState(this.state); + this.debouncedAttachState(); + } else { + this.component.generate = undefined; + } + } catch (_ex) { + this.component.generate = { error: { message: 'Error retrieving content' } }; + } + } + + private onDraftUpdateMetadata(e: { visibility: DraftVisibility }) { + this.sendCommand(UpdatePatchDetailsMetadataCommand, e); + } + + private onDraftUpdatePermissions() { + this.sendCommand(UpdatePatchDetailsPermissionsCommand, undefined); + } + + private onShowPatchInGraph(_e: ShowPatchInGraphDetail) { + // this.sendCommand(OpenInCommitGraphCommandType, { }); + } + + private onCreatePatch(e: CreatePatchEventDetail) { + this.sendCommand(CreatePatchCommand, e); + } + + private onShareLocalPatch() { + this.sendCommand(CreateFromLocalPatchCommand, undefined); + } + + private onCopyCloudLink() { + this.sendCommand(CopyCloudLinkCommand, undefined); + } + + private onModeClicked(e: Event) { + const mode = ((e.target as HTMLElement)?.dataset.actionValue as SwitchModeParams['mode']) ?? undefined; + if (mode === this.state.mode) return; + + this.sendCommand(SwitchModeCommand, { mode: mode }); + } + + private onApplyPatch(e: ApplyPatchDetail) { + console.log('onApplyPatch', e); + if (e.selectedPatches == null || e.selectedPatches.length === 0) return; + this.sendCommand(ApplyPatchCommand, { + details: e.draft, + target: e.target ?? 'current', + selected: e.selectedPatches, + }); + } + + private onArchiveDraft(reason?: DraftReasonEventDetail['reason']) { + this.sendCommand(ArchiveDraftCommand, { reason: reason }); + } + + private onChangePatchBase(e: ChangePatchBaseDetail) { + console.log('onChangePatchBase', e); + this.sendCommand(SelectPatchBaseCommand, undefined); + } + + private onSelectPatchRepo(e: SelectPatchRepoDetail) { + console.log('onSelectPatchRepo', e); + this.sendCommand(SelectPatchRepoCommand, undefined); + } + + private onCommandClickedCore(action?: string) { + const command = action?.startsWith('command:') ? action.slice(8) : action; + if (command == null) return; + + this.sendCommand(ExecuteCommand, { command: command }); + } + + private onSwitchAIModel(_e: MouseEvent) { + this.onCommandClickedCore('gitlens.switchAIModel'); + } + + async onAIExplain(_e: MouseEvent) { + try { + const result = await this.sendRequest(ExplainRequest, undefined); + + if (result.error) { + this.component.explain = { error: { message: result.error.message ?? 'Error retrieving content' } }; + } else if (result.summary) { + this.component.explain = { summary: result.summary }; + } else { + this.component.explain = undefined; + } + } catch (_ex) { + this.component.explain = { error: { message: 'Error retrieving content' } }; + } + } + + private onToggleFilesLayout(e: MouseEvent) { + const layout = ((e.target as HTMLElement)?.dataset.switchValue as ViewFilesLayout) ?? undefined; + if (layout === this.state.preferences.files?.layout) return; + + const files: State['preferences']['files'] = { + ...this.state.preferences.files, + layout: layout ?? 'auto', + compact: this.state.preferences.files?.compact ?? true, + threshold: this.state.preferences.files?.threshold ?? 5, + icon: this.state.preferences.files?.icon ?? 'type', + }; + + this.state = { ...this.state, preferences: { ...this.state.preferences, files: files } }; + this.debouncedAttachState(); + + this.sendCommand(UpdatePreferencesCommand, { files: files }); + } + + private onInviteUsers() { + this.sendCommand(UpdatePatchUsersCommand, undefined); + } + + private onUpdateUserSelection(e: CreatePatchUpdateSelectionEventDetail | PatchDetailsUpdateSelectionEventDetail) { + this.sendCommand(UpdatePatchUserSelectionCommand, e); + } + + private onOpenFileOnRemote(e: FileChangeListItemDetail) { + this.sendCommand(OpenFileOnRemoteCommand, e); + } + + private onOpenFile(e: FileChangeListItemDetail) { + this.sendCommand(OpenFileCommand, e); + } + + private onCompareFileWithWorking(e: FileChangeListItemDetail) { + this.sendCommand(OpenFileCompareWorkingCommand, e); + } + + private onCompareFileWithPrevious(e: FileChangeListItemDetail) { + this.sendCommand(OpenFileComparePreviousCommand, e); + } + + private onFileMoreActions(e: FileChangeListItemDetail) { + this.sendCommand(ExecuteFileActionCommand, e); + } + + private _component?: GlPatchDetailsApp; + private get component() { + if (this._component == null) { + this._component = (document.getElementById('app') as GlPatchDetailsApp)!; + this._component.app = this; + } + return this._component; + } + + private attachState(_force?: boolean) { + this.component.state = this.state!; + // if (force) { + // this.component.requestUpdate('state'); + // } + } + private debouncedAttachState = debounce(this.attachState.bind(this), 100); +} + +function assertsSerialized(obj: unknown): asserts obj is Serialized {} + +new PatchDetailsApp(); diff --git a/src/webviews/apps/plus/shared/components/feature-gate-plus-state.ts b/src/webviews/apps/plus/shared/components/feature-gate-plus-state.ts new file mode 100644 index 0000000000000..d99ecf18fc695 --- /dev/null +++ b/src/webviews/apps/plus/shared/components/feature-gate-plus-state.ts @@ -0,0 +1,192 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { Commands } from '../../../../../constants.commands'; +import type { Source } from '../../../../../constants.telemetry'; +import type { Promo } from '../../../../../plus/gk/account/promos'; +import { getApplicablePromo } from '../../../../../plus/gk/account/promos'; +import { SubscriptionState } from '../../../../../plus/gk/account/subscription'; +import type { GlButton } from '../../../shared/components/button'; +import { linkStyles } from './vscode.css'; +import '../../../shared/components/button'; +import '../../../shared/components/promo'; + +declare global { + interface HTMLElementTagNameMap { + 'gl-feature-gate-plus-state': GlFeatureGatePlusState; + } + + // interface GlobalEventHandlersEventMap {} +} + +@customElement('gl-feature-gate-plus-state') +export class GlFeatureGatePlusState extends LitElement { + static override styles = [ + linkStyles, + css` + :host { + --gk-action-radius: 0.3rem; + container-type: inline-size; + } + + :host([appearance='welcome']) gl-button { + width: 100%; + max-width: 300px; + } + + @container (max-width: 600px) { + :host([appearance='welcome']) gl-button { + display: block; + margin-left: auto; + margin-right: auto; + } + } + + :host([appearance='alert']) gl-button:not(.inline) { + display: block; + margin-left: auto; + margin-right: auto; + } + + :host-context([appearance='alert']) p:first-child { + margin-top: 0; + } + + :host-context([appearance='alert']) p:last-child { + margin-bottom: 0; + } + + .actions { + text-align: center; + } + + .hint { + border-bottom: 1px dashed currentColor; + } + `, + ]; + + @query('gl-button') + private readonly button!: GlButton; + + @property({ type: String }) + appearance?: 'alert' | 'welcome'; + + @property() + featureWithArticleIfNeeded?: string; + + @property({ type: Object }) + source?: Source; + + @property({ attribute: false, type: Number }) + state?: SubscriptionState; + + protected override firstUpdated() { + if (this.appearance === 'alert') { + queueMicrotask(() => this.button.focus()); + } + } + + override render() { + if (this.state == null) { + this.hidden = true; + return undefined; + } + + this.hidden = false; + const appearance = (this.appearance ?? 'alert') === 'alert' ? 'alert' : nothing; + const promo = this.state ? getApplicablePromo(this.state) : undefined; + + switch (this.state) { + case SubscriptionState.VerificationRequired: + return html` +

    + Resend Email + +

    +

    You must verify your email before you can continue.

    + `; + + case SubscriptionState.Free: + return html` + Continue +

    + Continuing gives you 3 days to preview + ${this.featureWithArticleIfNeeded ? `${this.featureWithArticleIfNeeded} and other ` : ''}local + Pro features.
    + ${appearance !== 'alert' ? html`
    ` : ''} For full access to Pro features + start your free 7-day Pro trial + or + sign in. +

    + `; + + case SubscriptionState.FreePreviewTrialExpired: + return html` + Start Pro Trial +

    + Start your free 7-day Pro trial to try + ${this.featureWithArticleIfNeeded ? `${this.featureWithArticleIfNeeded} and other ` : ''}Pro + features, or + sign in. +

    + `; + + case SubscriptionState.FreePlusTrialExpired: + return html` Upgrade to Pro + ${this.renderPromo(promo)} +

    + Your Pro trial has ended. Please upgrade for full access to + ${this.featureWithArticleIfNeeded ? `${this.featureWithArticleIfNeeded} and other ` : ''}Pro + features. +

    `; + + case SubscriptionState.FreePlusTrialReactivationEligible: + return html` + Continue +

    + Reactivate your Pro trial and experience + ${this.featureWithArticleIfNeeded ? `${this.featureWithArticleIfNeeded} and ` : ''}all the new + Pro features — free for another 7 days! +

    + `; + } + + return undefined; + } + + private renderPromo(promo: Promo | undefined) { + return html``; + } +} + +function generateCommandLink(command: Commands, source: Source | undefined) { + return `command:${command}${source ? `?${encodeURIComponent(JSON.stringify(source))}` : ''}`; +} diff --git a/src/webviews/apps/plus/shared/components/home-account-content.ts b/src/webviews/apps/plus/shared/components/home-account-content.ts new file mode 100644 index 0000000000000..feed4685286b6 --- /dev/null +++ b/src/webviews/apps/plus/shared/components/home-account-content.ts @@ -0,0 +1,398 @@ +import { consume } from '@lit/context'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import { urls } from '../../../../../constants'; +import type { Promo } from '../../../../../plus/gk/account/promos'; +import { getApplicablePromo } from '../../../../../plus/gk/account/promos'; +import { + getSubscriptionPlanName, + getSubscriptionTimeRemaining, + hasAccountFromSubscriptionState, + SubscriptionPlanId, + SubscriptionState, +} from '../../../../../plus/gk/account/subscription'; +import { pluralize } from '../../../../../system/string'; +import type { State } from '../../../../home/protocol'; +import { stateContext } from '../../../home/context'; +import { elementBase, linkBase } from '../../../shared/components/styles/lit/base.css'; +import '../../../shared/components/accordion/accordion'; +import '../../../shared/components/button'; +import '../../../shared/components/button-container'; +import '../../../shared/components/code-icon'; +import '../../../shared/components/promo'; + +@customElement('gl-home-account-content') +export class GLHomeAccountContent extends LitElement { + static override shadowRootOptions: ShadowRootInit = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + static override styles = [ + elementBase, + linkBase, + css` + :host { + display: block; + margin-bottom: 1.3rem; + } + + :host > * { + margin-bottom: 0; + } + + button-container { + margin-bottom: 1.3rem; + } + + .header { + display: flex; + align-items: center; + gap: 0.6rem; + } + + .header__media { + flex: none; + } + + .header__actions { + flex: none; + display: flex; + gap: 0.2rem; + flex-direction: row; + align-items: center; + justify-content: center; + } + + img.header__media { + width: 3rem; + aspect-ratio: 1 / 1; + border-radius: 50%; + } + + .header__title { + flex: 1; + font-size: 1.5rem; + font-weight: 600; + margin: 0; + } + + .org { + position: relative; + display: flex; + flex-direction: row; + gap: 0 0.8rem; + align-items: center; + margin-bottom: 1.3rem; + } + + .org__media { + flex: none; + width: 3.4rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-foreground--65); + } + + .org__image { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 50%; + } + + .org__details { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + } + + .org__title { + font-size: 1.3rem; + font-weight: 600; + margin: 0; + } + + .org__access { + position: relative; + margin: 0; + color: var(--color-foreground--65); + } + + .org__signout { + flex: none; + display: flex; + gap: 0.2rem; + flex-direction: row; + align-items: center; + justify-content: center; + } + + .org__badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.4rem; + height: 2.4rem; + line-height: 2.4rem; + font-size: 1rem; + font-weight: 600; + color: var(--color-foreground--65); + background-color: var(--vscode-toolbar-hoverBackground); + border-radius: 50%; + margin-right: 0.6rem; + } + + .account > :first-child { + margin-block-start: 0; + } + .account > :last-child { + margin-block-end: 0; + } + + hr { + border: none; + border-top: 1px solid var(--color-foreground--25); + } + `, + ]; + + @consume({ context: stateContext, subscribe: true }) + @state() + private _state!: State; + + private get daysRemaining() { + if (this._state.subscription == null) return 0; + + return getSubscriptionTimeRemaining(this._state.subscription, 'days') ?? 0; + } + + get hasAccount() { + return hasAccountFromSubscriptionState(this.state); + } + + get isReactivatedTrial() { + return ( + this.state === SubscriptionState.FreePlusInTrial && + (this._state.subscription?.plan.effective.trialReactivationCount ?? 0) > 0 + ); + } + + private get planId() { + return this._state.subscription?.plan.actual.id ?? SubscriptionPlanId.Pro; + } + + get planName() { + switch (this.state) { + case SubscriptionState.Free: + case SubscriptionState.FreePreviewTrialExpired: + case SubscriptionState.FreePlusTrialExpired: + case SubscriptionState.FreePlusTrialReactivationEligible: + return 'GitKraken Free'; + case SubscriptionState.FreeInPreviewTrial: + case SubscriptionState.FreePlusInTrial: + return 'GitKraken Pro (Trial)'; + case SubscriptionState.VerificationRequired: + return `${getSubscriptionPlanName(this.planId)} (Unverified)`; + default: + return getSubscriptionPlanName(this.planId); + } + } + + private get state() { + return this._state.subscription?.state; + } + + override render() { + return html` +
    + ${this.hasAccount && this._state.avatar + ? html`` + : html``} + ${this.planName} + ${when( + this.hasAccount, + () => html` + + `, + )} +
    + ${this.renderOrganization()}${this.renderAccountState()} + +
    `; + } + + private renderOrganization() { + const organization = this._state.subscription?.activeOrganization?.name ?? ''; + if (!this.hasAccount || !organization) return nothing; + + return html` +
    +
    + +
    +
    +

    ${organization}

    +
    + ${when( + this._state.organizationsCount! > 1, + () => + html`
    + +${this._state.organizationsCount! - 1}Switch Active Organization +
    + You are in + ${pluralize('organization', this._state.organizationsCount! - 1, { + infix: ' other ', + })}
    +
    `, + )} +
    + `; + } + + private renderAccountState() { + const promo = getApplicablePromo(this.state); + + switch (this.state) { + case SubscriptionState.Paid: + return html` + + `; + + case SubscriptionState.VerificationRequired: + return html` + + `; + + case SubscriptionState.FreePlusInTrial: { + const days = this.daysRemaining; + + return html` + + `; + } + + case SubscriptionState.FreePlusTrialExpired: + return html` + + `; + + case SubscriptionState.FreePlusTrialReactivationEligible: + return html` + + `; + + default: + return html` + + `; + } + } + + private renderIncludesDevEx() { + return html` +

    + Includes access to our DevEx platform, unleashing powerful Git + visualization & productivity capabilities everywhere you work: IDE, desktop, browser, and terminal. +

    + `; + } + + private renderPromo(promo: Promo | undefined) { + return html``; + } +} diff --git a/src/webviews/apps/plus/shared/components/vscode.css.ts b/src/webviews/apps/plus/shared/components/vscode.css.ts new file mode 100644 index 0000000000000..c06e6459adb31 --- /dev/null +++ b/src/webviews/apps/plus/shared/components/vscode.css.ts @@ -0,0 +1,15 @@ +import { css } from 'lit'; + +export const linkStyles = css` + a { + color: var(--link-foreground); + text-decoration: var(--link-decoration-default, none); + } + a:focus { + outline-color: var(--focus-border); + } + a:hover { + color: var(--link-foreground-active); + text-decoration: underline; + } +`; diff --git a/src/webviews/apps/plus/timeline/chart.ts b/src/webviews/apps/plus/timeline/chart.ts index 4272249b578e8..aa546f7d3d088 100644 --- a/src/webviews/apps/plus/timeline/chart.ts +++ b/src/webviews/apps/plus/timeline/chart.ts @@ -1,12 +1,12 @@ -'use strict'; /*global*/ import type { Chart, ChartOptions, ChartTypes, DataItem } from 'billboard.js'; -import { bar, bb, bubble, zoom } from 'billboard.js'; // import BubbleCompare from 'billboard.js/dist/plugin/billboardjs-plugin-bubblecompare'; // import { scaleSqrt } from 'd3-scale'; import type { Commit, State } from '../../../../plus/webviews/timeline/protocol'; +import type { Deferred } from '../../../../system/promise'; +import { defer } from '../../../../system/promise'; import { formatDate, fromNow } from '../../shared/date'; -import type { Event } from '../../shared/events'; +import type { Disposable, Event } from '../../shared/events'; import { Emitter } from '../../shared/events'; export interface DataPointClickEvent { @@ -16,7 +16,7 @@ export interface DataPointClickEvent { }; } -export class TimelineChart { +export class TimelineChart implements Disposable { private _onDidClickDataPoint = new Emitter(); get onDidClickDataPoint(): Event { return this._onDidClickDataPoint.event; @@ -24,9 +24,9 @@ export class TimelineChart { private readonly $container: HTMLElement; private _chart: Chart | undefined; - private _chartDimensions: { height: number; width: number }; private readonly _resizeObserver: ResizeObserver; private readonly _selector: string; + private _size: { height: number; width: number }; private readonly _commitsByTimestamp = new Map(); private readonly _authorsByIndex = new Map(); @@ -35,48 +35,51 @@ export class TimelineChart { private _dateFormat: string = undefined!; private _shortDateFormat: string = undefined!; - constructor(selector: string) { - this._selector = selector; + private get compact(): boolean { + return this.placement !== 'editor'; + } - let idleRequest: number | undefined; + constructor( + selector: string, + private readonly placement: 'editor' | 'view', + ) { + this._selector = selector; const fn = () => { - idleRequest = undefined; - - const dimensions = this._chartDimensions; + const size = this._size; this._chart?.resize({ - width: dimensions.width, - height: dimensions.height - 10, + width: size.width, + height: size.height, }); }; + const widthOffset = this.compact ? 32 : 0; + const heightOffset = this.compact ? 16 : 0; + + this.$container = document.querySelector(selector)!.parentElement!; this._resizeObserver = new ResizeObserver(entries => { - const size = entries[0].borderBoxSize[0]; - const dimensions = { - width: Math.floor(size.inlineSize), - height: Math.floor(size.blockSize), + const boxSize = entries[0].borderBoxSize[0]; + const size = { + width: Math.floor(boxSize.inlineSize) + widthOffset, + height: Math.floor(boxSize.blockSize) + heightOffset, }; - if ( - this._chartDimensions.height === dimensions.height && - this._chartDimensions.width === dimensions.width - ) { - return; - } - - this._chartDimensions = dimensions; - if (idleRequest != null) { - cancelIdleCallback(idleRequest); - idleRequest = undefined; - } - idleRequest = requestIdleCallback(fn, { timeout: 1000 }); + this._size = size; + requestAnimationFrame(fn); }); - this.$container = document.querySelector(selector)!.parentElement!; const rect = this.$container.getBoundingClientRect(); - this._chartDimensions = { height: Math.floor(rect.height), width: Math.floor(rect.width) }; + this._size = { + height: Math.floor(rect.height) + widthOffset, + width: Math.floor(rect.width) + heightOffset, + }; + + this._resizeObserver.observe(this.$container, { box: 'border-box' }); + } - this._resizeObserver.observe(this.$container); + dispose(): void { + this._resizeObserver.disconnect(); + this._chart?.destroy(); } reset() { @@ -84,7 +87,31 @@ export class TimelineChart { this._chart?.unzoom(); } - updateChart(state: State) { + private setEmptyState(dataset: Commit[] | undefined, state: State) { + const $empty = document.getElementById('empty')!; + const $header = document.getElementById('header')!; + + if (state.uri != null) { + if (dataset?.length === 0) { + $empty.innerHTML = '

    No commits found for the specified time period.

    '; + $empty.removeAttribute('hidden'); + } else { + $empty.setAttribute('hidden', ''); + } + $header.removeAttribute('hidden'); + } else if (dataset == null) { + $empty.innerHTML = '

    There are no editors open that can provide file history information.

    '; + $empty.removeAttribute('hidden'); + $header.setAttribute('hidden', ''); + } else { + $empty.setAttribute('hidden', ''); + $header.removeAttribute('hidden'); + } + } + + private _loading: Deferred | undefined; + + async updateChart(state: State) { this._dateFormat = state.dateFormat; this._shortDateFormat = state.shortDateFormat; @@ -92,29 +119,26 @@ export class TimelineChart { this._authorsByIndex.clear(); this._indexByAuthors.clear(); - if (state?.dataset == null || state.dataset.length === 0) { + let dataset = state?.dataset; + if (dataset == null && !state.access.allowed && this.placement === 'editor') { + dataset = generateRandomTimelineDataset(); + } + + this.setEmptyState(dataset, state); + if (dataset == null || dataset.length === 0) { this._chart?.destroy(); this._chart = undefined; - const $overlay = document.getElementById('chart-empty-overlay') as HTMLDivElement; - $overlay?.classList.toggle('hidden', false); - - const $emptyMessage = $overlay.querySelector('[data-bind="empty"]')!; - $emptyMessage.textContent = state.emptyMessage ?? ''; - return; } - const $overlay = document.getElementById('chart-empty-overlay') as HTMLDivElement; - $overlay?.classList.toggle('hidden', true); - - const xs: { [key: string]: string } = {}; - const colors: { [key: string]: string } = {}; - const names: { [key: string]: string } = {}; - const axes: { [key: string]: string } = {}; - const types: { [key: string]: ChartTypes } = {}; + const xs: Record = {}; + const colors: Record = {}; + const names: Record = {}; + const axes: Record = {}; + const types: Record = {}; const groups: string[][] = []; - const series: { [key: string]: any } = {}; + const series: Record = {}; const group = []; let index = 0; @@ -129,7 +153,7 @@ export class TimelineChart { // let minChanges = Infinity; // let maxChanges = -Infinity; - // for (const commit of state.dataset) { + // for (const commit of dataset) { // const changes = commit.additions + commit.deletions; // if (changes < minChanges) { // minChanges = changes; @@ -141,7 +165,9 @@ export class TimelineChart { // const bubbleScale = scaleSqrt([minChanges, maxChanges], [6, 100]); - for (commit of state.dataset) { + const { bb, bar, bubble, zoom } = await import(/* webpackChunkName: "lib-billboard" */ 'billboard.js'); + + for (commit of dataset) { ({ author, date, additions, deletions } = commit); if (!this._indexByAuthors.has(author)) { @@ -212,52 +238,82 @@ export class TimelineChart { // eslint-disable-next-line @typescript-eslint/no-unsafe-return const columns = Object.entries(series).map(([key, value]) => [key, ...value]); - if (this._chart == null) { - const options = this.getChartOptions(); + // The the chart is already loading, cancel and destroy it -- otherwise it won't load the data properly + if (this._chart != null && this._loading != null) { + this._loading.cancel(); + this._loading = undefined; - if (options.axis == null) { - options.axis = { y: { tick: {} } }; - } - if (options.axis.y == null) { - options.axis.y = { tick: {} }; - } - if (options.axis.y.tick == null) { - options.axis.y.tick = {}; - } + this._chart?.destroy(); + this._chart = undefined; + } - options.axis.y.min = index - 2; - options.axis.y.tick.values = [...this._authorsByIndex.keys()]; - - options.data = { - ...options.data, - axes: axes, - colors: colors, - columns: columns, - groups: groups, - names: names, - types: types, - xs: xs, - }; + this._loading = defer(); + + try { + if (this._chart == null) { + const options = this.getChartOptions(zoom); + + if (options.axis == null) { + options.axis = { y: { tick: {} } }; + } + if (options.axis.y == null) { + options.axis.y = { tick: {} }; + } + if (options.axis.y.tick == null) { + options.axis.y.tick = {}; + } + + options.axis.y.min = index - 2; + options.axis.y.tick.values = [...this._authorsByIndex.keys()]; + + options.data = { + ...options.data, + axes: axes, + colors: colors, + columns: columns, + groups: groups, + names: names, + types: types, + xs: xs, + }; + + options.onafterinit = () => + setTimeout(() => { + this._loading?.fulfill(); + this._loading = undefined; + }, 250); + + this._chart = bb.generate(options); + } else { + this._chart.config('axis.y.tick.values', [...this._authorsByIndex.keys()], false); + this._chart.config('axis.y.min', index - 2, false); + this._chart.groups(groups); + + this._chart.load({ + axes: axes, + colors: colors, + columns: columns, + names: names, + types: types, + xs: xs, + unload: true, + done: () => { + setTimeout(() => { + this._loading?.fulfill(); + this._loading = undefined; + }, 250); + }, + }); + } - this._chart = bb.generate(options); - } else { - this._chart.config('axis.y.tick.values', [...this._authorsByIndex.keys()], false); - this._chart.config('axis.y.min', index - 2, false); - this._chart.groups(groups); - - this._chart.load({ - axes: axes, - colors: colors, - columns: columns, - names: names, - types: types, - xs: xs, - unload: true, - }); + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + return await this._loading.promise; + } catch (_ex) { + debugger; } } - private getChartOptions() { + private getChartOptions(zoom: typeof import(/* webpackChunkName: "lib-billboard" */ 'billboard.js').zoom) { const config: ChartOptions = { bindto: this._selector, data: { @@ -276,16 +332,20 @@ export class TimelineChart { type: 'timeseries', clipPath: false, localtime: true, + show: true, tick: { - // autorotate: true, centered: true, culling: false, fit: false, format: (x: number | Date) => - typeof x === 'number' ? x : formatDate(x, this._shortDateFormat ?? 'short'), + this.compact + ? '' + : typeof x === 'number' + ? x + : formatDate(x, this._shortDateFormat ?? 'short'), multiline: false, - // rotate: 15, show: false, + outer: !this.compact, }, }, y: { @@ -296,22 +356,30 @@ export class TimelineChart { }, show: true, tick: { - format: (y: number) => this._authorsByIndex.get(y) ?? '', - outer: false, + format: (y: number) => (this.compact ? '' : this._authorsByIndex.get(y) ?? ''), + outer: !this.compact, + show: this.compact, }, }, y2: { - label: { - text: 'Lines changed', - position: 'outer-middle', - }, + padding: this.compact + ? { + top: 0, + bottom: 0, + } + : undefined, + label: this.compact + ? undefined + : { + text: 'Lines changed', + position: 'outer-middle', + }, // min: 0, show: true, - // tick: { - // outer: true, - // // culling: true, - // // stepSize: 1, - // }, + tick: { + format: (y: number) => (this.compact ? '' : y), + outer: !this.compact, + }, }, }, bar: { @@ -341,16 +409,17 @@ export class TimelineChart { }, }, legend: { - show: true, + show: !this.compact, + // hide: this.compact ? [...this._authorsByIndex.values()] : undefined, padding: 10, }, + point: { + sensitivity: 'radius', + }, resize: { auto: false, }, - size: { - height: this._chartDimensions.height - 10, - width: this._chartDimensions.width, - }, + size: this._size, tooltip: { grouped: true, format: { @@ -393,7 +462,7 @@ export class TimelineChart { return config; } - private getTooltipName(name: string, ratio: number, id: string, index: number) { + private getTooltipName(name: string, _ratio: number, id: string, index: number) { if (id === 'additions' || /*id === 'changes' ||*/ id === 'deletions') return name; const date = new Date(this._chart!.data(id)[0].values[index].x); @@ -410,7 +479,7 @@ export class TimelineChart { return `${commit.author}, ${formattedDate}`; } - private getTooltipValue(value: unknown, ratio: number, id: string, index: number): string { + private getTooltipValue(value: unknown, _ratio: number, id: string, index: number): string { if (id === 'additions' || /*id === 'changes' ||*/ id === 'deletions') { return value === 0 ? undefined! : (value as string); } @@ -437,3 +506,27 @@ export class TimelineChart { function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } + +function generateRandomTimelineDataset(): Commit[] { + const dataset: Commit[] = []; + const authors = ['Eric Amodio', 'Justin Roberts', 'Keith Daulton', 'Ramin Tadayon', 'Ada Lovelace', 'Grace Hopper']; + + const count = 10; + for (let i = 0; i < count; i++) { + // Generate a random date between now and 3 months ago + const date = new Date(new Date().getTime() - Math.floor(Math.random() * (3 * 30 * 24 * 60 * 60 * 1000))); + + dataset.push({ + commit: String(i), + author: authors[Math.floor(Math.random() * authors.length)], + date: date.toISOString(), + message: '', + // Generate random additions/deletions between 1 and 20, but ensure we have a tiny and large commit + additions: i === 0 ? 2 : i === count - 1 ? 50 : Math.floor(Math.random() * 20) + 1, + deletions: i === 0 ? 1 : i === count - 1 ? 25 : Math.floor(Math.random() * 20) + 1, + sort: date.getTime(), + }); + } + + return dataset.sort((a, b) => b.sort - a.sort); +} diff --git a/src/webviews/apps/plus/timeline/partials/state.free-preview-trial-expired.html b/src/webviews/apps/plus/timeline/partials/state.free-preview-trial-expired.html deleted file mode 100644 index 2f5a717a0dadf..0000000000000 --- a/src/webviews/apps/plus/timeline/partials/state.free-preview-trial-expired.html +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/src/webviews/apps/plus/timeline/partials/state.free.html b/src/webviews/apps/plus/timeline/partials/state.free.html deleted file mode 100644 index 6b99c87a637f4..0000000000000 --- a/src/webviews/apps/plus/timeline/partials/state.free.html +++ /dev/null @@ -1,12 +0,0 @@ - diff --git a/src/webviews/apps/plus/timeline/partials/state.plus-trial-expired.html b/src/webviews/apps/plus/timeline/partials/state.plus-trial-expired.html deleted file mode 100644 index a62db4ee89b81..0000000000000 --- a/src/webviews/apps/plus/timeline/partials/state.plus-trial-expired.html +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/src/webviews/apps/plus/timeline/partials/state.verify-email.html b/src/webviews/apps/plus/timeline/partials/state.verify-email.html deleted file mode 100644 index 2a5469794e243..0000000000000 --- a/src/webviews/apps/plus/timeline/partials/state.verify-email.html +++ /dev/null @@ -1,8 +0,0 @@ - diff --git a/src/webviews/apps/plus/timeline/timeline.html b/src/webviews/apps/plus/timeline/timeline.html index 4ddee1c42c859..1a537fb88ef9e 100644 --- a/src/webviews/apps/plus/timeline/timeline.html +++ b/src/webviews/apps/plus/timeline/timeline.html @@ -1,20 +1,47 @@ - + - + - + +

    + Visual File History + + — visualize the evolution of a file and quickly identify when the most impactful changes were made + and by whom. +

    -
    -

    -

    -

    + +
    +
    -
    - #{endOfBody} - - - - - - - <%= require('html-loader?{"esModule":false}!./partials/state.free.html') %> - <%= require('html-loader?{"esModule":false}!./partials/state.free-preview-trial-expired.html') %> - <%= require('html-loader?{"esModule":false}!./partials/state.plus-trial-expired.html') %> - <%= require('html-loader?{"esModule":false}!./partials/state.verify-email.html') %> diff --git a/src/webviews/apps/plus/timeline/timeline.scss b/src/webviews/apps/plus/timeline/timeline.scss index 533324211481c..3f30192c22a34 100644 --- a/src/webviews/apps/plus/timeline/timeline.scss +++ b/src/webviews/apps/plus/timeline/timeline.scss @@ -1,31 +1,55 @@ +@use '../../shared/styles/properties'; +@use '../../shared/styles/theme'; + * { box-sizing: border-box; } -html { - height: 100%; +.vscode-high-contrast, +.vscode-dark { + --progress-bar-color: var(--color-background--lighten-15); + --card-background: var(--color-background--lighten-075); + --card-hover-background: var(--color-background--lighten-10); + --popover-bg: var(--color-background--lighten-15); +} + +.vscode-high-contrast-light, +.vscode-light { + --progress-bar-color: var(--color-background--darken-15); + --card-background: var(--color-background--darken-075); + --card-hover-background: var(--color-background--darken-10); + --popover-bg: var(--color-background--darken-15); +} + +:root { font-size: 62.5%; + font-family: var(--font-family); + box-sizing: border-box; + height: 100%; } body { - background-color: var(--color-view-background); color: var(--color-view-foreground); font-family: var(--font-family); + font-size: var(--font-size); height: 100%; - line-height: 1.4; - font-size: 100% !important; overflow: hidden; - margin: 0 20px 20px 20px; + margin: 0; padding: 0; - - min-width: 400px; - overflow-x: scroll; } body[data-placement='editor'] { background-color: var(--color-background); } +a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + .container { display: grid; grid-template-rows: min-content 1fr min-content; @@ -58,72 +82,40 @@ h4 { margin: 0.5rem 0 1rem 0; } -a { - text-decoration: none; - - &:focus { - outline-color: var(--focus-border); - } - - &:hover { - text-decoration: underline; - } -} - -b { - font-weight: 600; -} - -p { - margin-bottom: 0; -} - -vscode-button:not([appearance='icon']) { - align-self: center; - margin-top: 1.5rem; - max-width: 300px; - width: 100%; -} - -span.button-subaction { - align-self: center; - margin-top: 0.75rem; -} - -@media (min-width: 640px) { - vscode-button:not([appearance='icon']) { - align-self: flex-start; - } - span.button-subaction { - align-self: flex-start; - } -} - .header { display: grid; - grid-template-columns: max-content min-content minmax(min-content, 1fr) max-content; + grid-template-columns: 1fr min-content; align-items: baseline; - grid-template-areas: 'title sha description toolbox'; - justify-content: start; - margin-bottom: 1rem; - - @media all and (max-width: 500px) { - grid-template-areas: - 'title sha description' - 'empty toolbox'; - grid-template-columns: max-content min-content minmax(min-content, 1fr); + grid-template-areas: 'context toolbox'; + margin: 0.5rem 0.75rem; + + body[data-placement='view'] & { + margin-top: 0; } - h2[data-bind='title'] { - grid-area: title; - margin-bottom: 0; + &--context { + grid-area: context; + display: grid; + grid-template-columns: minmax(0, min-content) minmax(0, min-content) minmax(0, 1fr); + gap: 0.5rem; + align-items: baseline; + font-size: 1rem; + + h2 { + margin: 0; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + body:not([data-placement='editor']) & { + display: none; + } } h2[data-bind='sha'] { - grid-area: sha; font-size: 1.3em; font-weight: 200; - margin-left: 1.5rem; opacity: 0.7; white-space: nowrap; @@ -133,17 +125,25 @@ span.button-subaction { } h2[data-bind='description'] { - grid-area: description; font-size: 1.3em; font-weight: 200; - margin-left: 1.5rem; + margin-left: 0.5rem; opacity: 0.7; - overflow-wrap: anywhere; } .toolbox { grid-area: toolbox; + align-items: center; display: flex; + gap: 0.3rem; + + gl-feature-badge { + padding-bottom: 0.4rem; + + body[data-placement='editor'] & { + padding-left: 0.4rem; + } + } } } @@ -154,98 +154,75 @@ span.button-subaction { flex: 100% 0 1; label { - margin: 0 1em; + margin: 0 1em 0 0.5rem; font-size: var(--font-size); } } #content { position: relative; - overflow: hidden; width: 100%; + height: 100%; } #chart { + position: absolute !important; height: 100%; width: 100%; - overflow: hidden; -} -#chart-empty-overlay { - display: flex; - align-items: center; - justify-content: center; + body:not([data-placement='editor']) & { + left: -16px; + } +} +#empty { position: absolute; top: 0; left: 0; - width: 100vw; - height: 100vh; + bottom: 0; + right: 0; + padding: 0.4rem 2rem 1.3rem 2rem; - h1 { - font-weight: 600; - padding-bottom: 10%; + font-size: var(--font-size); + + p { + margin-top: 0; } } +[hidden], [data-visible] { - display: none; + display: none !important; } -#overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - font-size: 1.3em; - min-height: 100%; - padding: 0 2rem 2rem 2rem; - - backdrop-filter: blur(3px) saturate(0.8); - - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; +body[data-placement='editor'] { + [data-placement-hidden='editor'], + [data-placement-visible]:not([data-placement-visible='editor']) { + display: none !important; + } } -.modal { - max-width: 600px; - background: var(--color-hover-background) no-repeat left top; - background-image: var(--gl-plus-bg); - border: 1px solid var(--color-hover-border); - border-radius: 0.4rem; - margin: 1rem; - padding: 1.2rem; - - > p:first-child { - margin-top: 0; +body[data-placement='view'] { + [data-placement-hidden='view'], + [data-placement-visible]:not([data-placement-visible='view']) { + display: none !important; } +} - vscode-button:not([appearance='icon']) { - align-self: center !important; +body:not([data-placement='editor']) { + .bb-tooltip-container { + padding-left: 16px; } } -.hidden { +[hidden] { display: none !important; } -@import './chart'; -@import '../../shared/codicons.scss'; -@import '../../shared/glicons.scss'; - -.glicon { - vertical-align: middle; -} - -.glicon, -.codicon { - position: relative; - top: -1px; +gl-feature-gate gl-feature-badge { + vertical-align: super; + margin-left: 0.4rem; + margin-right: 0.4rem; } -.mt-tight { - margin-top: 0.3rem; -} +@import './chart'; diff --git a/src/webviews/apps/plus/timeline/timeline.ts b/src/webviews/apps/plus/timeline/timeline.ts index ee4c9091df9a1..0549ddf3ecaab 100644 --- a/src/webviews/apps/plus/timeline/timeline.ts +++ b/src/webviews/apps/plus/timeline/timeline.ts @@ -1,21 +1,25 @@ /*global*/ import './timeline.scss'; -import { provideVSCodeDesignSystem, vsCodeButton, vsCodeDropdown, vsCodeOption } from '@vscode/webview-ui-toolkit'; -import { GlyphChars } from '../../../../constants'; -import type { State } from '../../../../plus/webviews/timeline/protocol'; +import { provideVSCodeDesignSystem, vsCodeDropdown, vsCodeOption } from '@vscode/webview-ui-toolkit'; +import { isSubscriptionPaid } from '../../../../plus/gk/account/subscription'; +import type { Period, State } from '../../../../plus/webviews/timeline/protocol'; import { - DidChangeNotificationType, - OpenDataPointCommandType, - UpdatePeriodCommandType, + DidChangeNotification, + OpenDataPointCommand, + UpdatePeriodCommand, } from '../../../../plus/webviews/timeline/protocol'; -import { SubscriptionPlanId, SubscriptionState } from '../../../../subscription'; import type { IpcMessage } from '../../../protocol'; -import { ExecuteCommandType, onIpc } from '../../../protocol'; import { App } from '../../shared/appBase'; +import type { GlFeatureBadge } from '../../shared/components/feature-badge'; +import type { GlFeatureGate } from '../../shared/components/feature-gate'; import { DOM } from '../../shared/dom'; import type { DataPointClickEvent } from './chart'; import { TimelineChart } from './chart'; import '../../shared/components/code-icon'; +import '../../shared/components/progress'; +import '../../shared/components/button'; +import '../../shared/components/feature-gate'; +import '../../shared/components/feature-badge'; export class TimelineApp extends App { private _chart: TimelineChart | undefined; @@ -25,7 +29,7 @@ export class TimelineApp extends App { } protected override onInitialize() { - provideVSCodeDesignSystem().register(vsCodeButton(), vsCodeDropdown(), vsCodeOption()); + provideVSCodeDesignSystem().register(vsCodeDropdown(), vsCodeOption()); this.updateState(); } @@ -34,43 +38,31 @@ export class TimelineApp extends App { const disposables = super.onBind?.() ?? []; disposables.push( - DOM.on('[data-action]', 'click', (e, target: HTMLElement) => this.onActionClicked(e, target)), DOM.on(document, 'keydown', (e: KeyboardEvent) => this.onKeyDown(e)), DOM.on(document.getElementById('periods')! as HTMLSelectElement, 'change', (e, target) => this.onPeriodChanged(e, target), ), + { dispose: () => this._chart?.dispose() }, ); return disposables; } - protected override onMessageReceived(e: MessageEvent) { - const msg = e.data as IpcMessage; - - switch (msg.method) { - case DidChangeNotificationType.method: - this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); - - onIpc(DidChangeNotificationType, msg, params => { - this.state = params.state; - this.updateState(); - }); + protected override onMessageReceived(msg: IpcMessage) { + switch (true) { + case DidChangeNotification.is(msg): + this.state = msg.params.state; + this.setState(this.state); + this.updateState(); break; default: - super.onMessageReceived?.(e); - } - } - - private onActionClicked(e: MouseEvent, target: HTMLElement) { - const action = target.dataset.action; - if (action?.startsWith('command:')) { - this.sendCommand(ExecuteCommandType, { command: action.slice(8) }); + super.onMessageReceived?.(msg); } } private onChartDataPointClicked(e: DataPointClickEvent) { - this.sendCommand(OpenDataPointCommandType, e); + this.sendCommand(OpenDataPointCommand, e); } private onKeyDown(e: KeyboardEvent) { @@ -85,73 +77,56 @@ export class TimelineApp extends App { this.log(`onPeriodChanged(): name=${element.name}, value=${value}`); - this.sendCommand(UpdatePeriodCommandType, { period: value }); + this.updateLoading(true); + this.sendCommand(UpdatePeriodCommand, { period: value }); } - private updateState(): void { - const $overlay = document.getElementById('overlay') as HTMLDivElement; - $overlay.classList.toggle('hidden', this.state.access.allowed === true); - - const $slot = document.getElementById('overlay-slot') as HTMLDivElement; - - if (this.state.access.allowed === false) { - const { current: subscription, required } = this.state.access.subscription; - - const requiresPublic = required === SubscriptionPlanId.FreePlus; - const options = { visible: { public: requiresPublic, private: !requiresPublic } }; - - if (subscription.account?.verified === false) { - DOM.insertTemplate('state:verify-email', $slot, options); - return; - } + private updateState() { + const $gate = document.getElementById('subscription-gate')! as GlFeatureGate; + if ($gate != null) { + $gate.source = { source: 'timeline', detail: 'gate' }; + $gate.state = this.state.access.subscription.current.state; + $gate.visible = this.state.access.allowed !== true; // && this.state.uri != null; + } - switch (subscription.state) { - case SubscriptionState.Free: - DOM.insertTemplate('state:free', $slot, options); - break; - case SubscriptionState.FreePreviewTrialExpired: - DOM.insertTemplate('state:free-preview-trial-expired', $slot, options); - break; - case SubscriptionState.FreePlusTrialExpired: - DOM.insertTemplate('state:plus-trial-expired', $slot, options); - break; - } + const showBadge = + this.state.access.subscription?.current == null || + !isSubscriptionPaid(this.state.access.subscription?.current); - if (this.state.dataset == null) return; - } else { - $slot.innerHTML = ''; + const els = document.querySelectorAll('gl-feature-badge'); + for (const el of els) { + el.source = { source: 'timeline', detail: 'badge' }; + el.subscription = this.state.access.subscription.current; + el.hidden = !showBadge; } if (this._chart == null) { - this._chart = new TimelineChart('#chart'); + this._chart = new TimelineChart('#chart', this.placement); this._chart.onDidClickDataPoint(this.onChartDataPointClicked, this); } let { title, sha } = this.state; let description = ''; - const index = title.lastIndexOf('/'); - if (index >= 0) { - const name = title.substring(index + 1); - description = title.substring(0, index); - title = name; + if (title != null) { + const index = title.lastIndexOf('/'); + if (index >= 0) { + const name = title.substring(index + 1); + description = title.substring(0, index); + title = name; + } + } else if (this.placement === 'editor' && this.state.dataset == null && !this.state.access.allowed) { + title = 'index.ts'; + description = 'src/app'; } - function updateBoundData( - key: string, - value: string | undefined, - options?: { hideIfEmpty?: boolean; html?: boolean }, - ) { + function updateBoundData(key: string, value: string | undefined, options?: { html?: boolean }) { const $el = document.querySelector(`[data-bind="${key}"]`); if ($el != null) { - const empty = value == null || value.length === 0; - if (options?.hideIfEmpty) { - $el.classList.toggle('hidden', empty); - } - if (options?.html && !empty) { - $el.innerHTML = value; + if (options?.html) { + $el.innerHTML = value ?? ''; } else { - $el.textContent = String(value) || GlyphChars.Space; + $el.textContent = value ?? ''; } } } @@ -164,7 +139,6 @@ export class TimelineApp extends App { ? /*html*/ `${sha}` : undefined, { - hideIfEmpty: true, html: true, }, ); @@ -172,19 +146,28 @@ export class TimelineApp extends App { const $periods = document.getElementById('periods') as HTMLSelectElement; if ($periods != null) { const period = this.state?.period; - for (let i = 0, len = $periods.options.length; i < len; ++i) { - if ($periods.options[i].value === period) { - $periods.selectedIndex = i; - break; + + const $periodOptions = $periods.getElementsByTagName('vscode-option'); + for (const $option of $periodOptions) { + if (period === $option.getAttribute('value')) { + $option.setAttribute('selected', ''); + } else { + $option.removeAttribute('selected'); } } } - this._chart.updateChart(this.state); + void this._chart.updateChart(this.state).finally(() => this.updateLoading(false)); + } + + private updateLoading(loading: boolean) { + document.getElementById('spinner')?.setAttribute('active', loading ? 'true' : 'false'); } } -function assertPeriod(period: string): asserts period is `${number}|${'D' | 'M' | 'Y'}` { +function assertPeriod(period: string): asserts period is Period { + if (period === 'all') return; + const [value, unit] = period.split('|'); if (isNaN(Number(value)) || (unit !== 'D' && unit !== 'M' && unit !== 'Y')) { throw new Error(`Invalid period: ${period}`); diff --git a/src/webviews/apps/rebase/rebase.html b/src/webviews/apps/rebase/rebase.html index feae4d2fa608b..926a05a1fdd52 100644 --- a/src/webviews/apps/rebase/rebase.html +++ b/src/webviews/apps/rebase/rebase.html @@ -1,10 +1,21 @@ - + + - +

    GitLens Interactive Rebase

    @@ -61,12 +72,5 @@

    #{endOfBody} - diff --git a/src/webviews/apps/rebase/rebase.scss b/src/webviews/apps/rebase/rebase.scss index 89b44c14c681a..8eb570b062b7f 100644 --- a/src/webviews/apps/rebase/rebase.scss +++ b/src/webviews/apps/rebase/rebase.scss @@ -1,9 +1,10 @@ +@use '../shared/styles/theme'; @import '../shared/base'; @import '../shared/buttons'; @import '../shared/utils'; body { - --avatar-size: 2.2rem; + --gk-avatar-size: 2.2rem; overflow: overlay; } @@ -16,7 +17,6 @@ body { .container { display: grid; - font-size: 1.3em; grid-template-areas: 'header' 'entries' 'footer'; grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; @@ -40,7 +40,6 @@ header { flex: auto 0 1; margin-top: 0.5em; margin-right: 1em; - font-size: 2.3rem; } h4 { @@ -66,7 +65,7 @@ header { } h4 { - font-size: 1.5rem; + font-size: 1.4rem; opacity: 0.8; } diff --git a/src/webviews/apps/rebase/rebase.ts b/src/webviews/apps/rebase/rebase.ts index e733f372d5405..cceac003b0ff5 100644 --- a/src/webviews/apps/rebase/rebase.ts +++ b/src/webviews/apps/rebase/rebase.ts @@ -1,25 +1,23 @@ /*global document window*/ import './rebase.scss'; +import { Avatar, AvatarGroup, defineGkElement } from '@gitkraken/shared-web-components'; import Sortable from 'sortablejs'; -import { onIpc } from '../../protocol'; +import type { IpcMessage } from '../../protocol'; import type { RebaseEntry, RebaseEntryAction, State } from '../../rebase/protocol'; import { - AbortCommandType, - ChangeEntryCommandType, - DidChangeNotificationType, - DisableCommandType, - MoveEntryCommandType, - ReorderCommandType, - SearchCommandType, - StartCommandType, - SwitchCommandType, - UpdateSelectionCommandType, + AbortCommand, + ChangeEntryCommand, + DidChangeNotification, + DisableCommand, + MoveEntryCommand, + ReorderCommand, + SearchCommand, + StartCommand, + SwitchCommand, + UpdateSelectionCommand, } from '../../rebase/protocol'; import { App } from '../shared/appBase'; -import type { AvatarItem } from '../shared/components/avatars/avatar-item'; import { DOM } from '../shared/dom'; -import '../shared/components/avatars/avatar-item'; -import '../shared/components/avatars/avatar-stack'; const rebaseActions = ['pick', 'reword', 'edit', 'squash', 'fixup', 'drop']; const rebaseActionsMap = new Map([ @@ -52,6 +50,7 @@ class RebaseEditor extends App { } protected override onBind() { + defineGkElement(Avatar, AvatarGroup); const disposables = super.onBind?.() ?? []; const $container = document.getElementById('entries')!; @@ -105,6 +104,7 @@ class RebaseEditor extends App { onMove: e => !e.related.classList.contains('entry--base'), }); + // eslint-disable-next-line @typescript-eslint/no-deprecated if (window.navigator.platform.startsWith('Mac')) { let $shortcut = document.querySelector('[data-action="start"] .shortcut')!; $shortcut.textContent = 'Cmd+Enter'; @@ -232,9 +232,9 @@ class RebaseEditor extends App { } } }), - DOM.on('li[data-sha]', 'focus', (e, target: HTMLLIElement) => this.onSelectionChanged(target.dataset.sha)), - DOM.on('select[data-sha]', 'input', (e, target: HTMLSelectElement) => this.onSelectChanged(target)), - DOM.on('input[data-action="reorder"]', 'input', (e, target: HTMLInputElement) => + DOM.on('li[data-sha]', 'focus', (_e, target: HTMLLIElement) => this.onSelectionChanged(target.dataset.sha)), + DOM.on('select[data-sha]', 'input', (_e, target: HTMLSelectElement) => this.onSelectChanged(target)), + DOM.on('input[data-action="reorder"]', 'input', (_e, target: HTMLInputElement) => this.onOrderChanged(target), ), ); @@ -253,7 +253,7 @@ class RebaseEditor extends App { private moveEntry(sha: string, index: number, relative: boolean) { const entry = this.getEntry(sha); if (entry != null) { - this.sendCommand(MoveEntryCommandType, { + this.sendCommand(MoveEntryCommand, { sha: entry.sha, to: index, relative: relative, @@ -266,7 +266,7 @@ class RebaseEditor extends App { if (entry != null) { if (entry.action === action) return; - this.sendCommand(ChangeEntryCommandType, { + this.sendCommand(ChangeEntryCommand, { sha: entry.sha, action: action, }); @@ -274,15 +274,15 @@ class RebaseEditor extends App { } private onAbortClicked() { - this.sendCommand(AbortCommandType, undefined); + this.sendCommand(AbortCommand, undefined); } private onDisableClicked() { - this.sendCommand(DisableCommandType, undefined); + this.sendCommand(DisableCommand, undefined); } private onSearch() { - this.sendCommand(SearchCommandType, undefined); + this.sendCommand(SearchCommand, undefined); } private onSelectChanged($el: HTMLSelectElement) { @@ -293,23 +293,23 @@ class RebaseEditor extends App { } private onStartClicked() { - this.sendCommand(StartCommandType, undefined); + this.sendCommand(StartCommand, undefined); } private onSwitchClicked() { - this.sendCommand(SwitchCommandType, undefined); + this.sendCommand(SwitchCommand, undefined); } private onOrderChanged($el: HTMLInputElement) { const isChecked = $el.checked; - this.sendCommand(ReorderCommandType, { ascending: isChecked }); + this.sendCommand(ReorderCommand, { ascending: isChecked }); } private onSelectionChanged(sha: string | undefined) { if (sha == null) return; - this.sendCommand(UpdateSelectionCommandType, { sha: sha }); + this.sendCommand(UpdateSelectionCommand, { sha: sha }); } private setSelectedEntry(sha: string, focusSelect: boolean = false) { @@ -318,21 +318,16 @@ class RebaseEditor extends App { }); } - protected override onMessageReceived(e: MessageEvent) { - const msg = e.data; - - switch (msg.method) { - case DidChangeNotificationType.method: - this.log(`onMessageReceived(${msg.id}): name=${msg.method}`); - - onIpc(DidChangeNotificationType, msg, params => { - this.setState(params.state); - this.refresh(this.state); - }); + protected override onMessageReceived(msg: IpcMessage) { + switch (true) { + case DidChangeNotification.is(msg): + this.state = msg.params.state; + this.setState(this.state); + this.refresh(this.state); break; default: - super.onMessageReceived?.(e); + super.onMessageReceived?.(msg); } } @@ -507,14 +502,14 @@ class RebaseEditor extends App { const author = state.authors[commit.author]; const committer = state.authors[commit.committer]; if (author?.avatarUrl != null || committer?.avatarUrl != null) { - const $avatarStack = document.createElement('avatar-stack'); + const $avatarStack = document.createElement('gk-avatar-group'); $avatarStack.classList.add('entry-avatar'); const hasAuthor = author?.avatarUrl.length; const hasCommitter = author !== committer && author.author !== 'You' && committer?.avatarUrl.length; if (hasAuthor) { - const $avatar = document.createElement('avatar-item') as AvatarItem; - $avatar.media = author.avatarUrl; + const $avatar = document.createElement('gk-avatar'); + $avatar.src = author.avatarUrl; $avatar.ariaLabel = $avatar.title = hasCommitter ? `Authored by: ${author.author}` : author.author; @@ -522,8 +517,8 @@ class RebaseEditor extends App { } if (hasCommitter) { - const $avatar = document.createElement('avatar-item') as AvatarItem; - $avatar.media = committer.avatarUrl; + const $avatar = document.createElement('gk-avatar'); + $avatar.src = committer.avatarUrl; $avatar.ariaLabel = $avatar.title = hasAuthor ? `Committed by: ${committer.author}` : committer.author; @@ -546,7 +541,7 @@ class RebaseEditor extends App { const $sha = document.createElement('a'); $sha.classList.add('entry-sha', 'icon--commit'); $sha.href = state.commands.commit.replace(this.commitTokenRegex, commit?.sha ?? entry.sha); - $sha.textContent = entry.sha.substr(0, 7); + $sha.textContent = entry.sha.substring(0, 7); $content.appendChild($sha); return [$entry, tabIndex]; diff --git a/src/webviews/apps/settings/partials/autolinks.html b/src/webviews/apps/settings/partials/autolinks.html index cd8825df31b37..115c937662504 100644 --- a/src/webviews/apps/settings/partials/autolinks.html +++ b/src/webviews/apps/settings/partials/autolinks.html @@ -1,9 +1,8 @@ -
    #{endOfBody} - diff --git a/src/webviews/apps/welcome/welcome.scss b/src/webviews/apps/welcome/welcome.scss index d9546ec28fa4d..a7dd0447c594c 100644 --- a/src/webviews/apps/welcome/welcome.scss +++ b/src/webviews/apps/welcome/welcome.scss @@ -1,577 +1,584 @@ -@import '../shared/base'; -@import '../shared/buttons'; -@import '../shared/icons'; +@use '../shared/styles/utils'; +@use '../shared/styles/properties'; +@use '../shared/styles/normalize'; +@use '../shared/styles/theme'; -body { - &.vscode-light { - background-color: var(--color-background--darken-05); - } +.vscode-high-contrast, +.vscode-dark { + --promo-banner-dark-display: inline-block; } -header { - grid-area: header; - display: grid; - grid-template-columns: max-content minmax(396px, auto); - grid-gap: 1em 4em; - align-items: center; - margin: 0 2em; - - @media all and (max-width: 768px) { - grid-template-columns: auto; - justify-items: center; - grid-gap: 1rem; - } +.vscode-high-contrast-light, +.vscode-light { + --promo-banner-light-display: inline-block; } -.blurb { - font-size: 1.5rem; - font-weight: 200; - color: var(--color-foreground--65); - margin: 1em; +// normalize type +body { + line-height: 1.4; + --gl-indicator-size: 0.6rem; +} - b { - color: var(--color-foreground--85); - } +a { + text-decoration: none; - .vscode-light & { - color: var(--color-foreground--75); + &:hover { + text-decoration: underline; } } -.command { - font-weight: 600; - padding: 1px 3px; -} - -.container { - display: grid; - grid-template-areas: 'banner banner' 'header header' 'hero hero' 'content sidebar'; - grid-template-columns: repeat(1, 1fr min-content); - grid-gap: 1em 0; - margin: 1em auto; - max-width: 1200px; - min-width: 450px; +a.muted { + color: var(--color-foreground); } -.banner { - grid-area: banner; - margin: 1em; - display: flex; - - img { - border-radius: 8px; +a, +button:not([disabled]), +[tabindex]:not([tabindex='-1']) { + &:focus { + @include utils.focus(); } } -.content__area { - grid-area: content; - font-size: 1.4rem; - - .vscode-dark & { - background-color: var(--color-background--lighten-05); - } - - .vscode-light & { - background-color: var(--color-background); - } +nav { + margin-bottom: 1.6rem; +} - @media all and (max-width: 768px) { - grid-column: span 1; - } +h1, +h2, +p { + margin-top: 0; } -.content__area--full-scroll { - background-color: unset !important; - margin-bottom: 90vh; +h2 { + font-size: 1.8rem; + margin-top: 3.2rem; + margin-bottom: 0.6rem; - .section--settings { - margin: 0 0 1em 0; + &:first-child { + margin-top: 0; } } -.cta { - display: flex; - flex-wrap: wrap; - justify-content: center; - font-size: 1.3rem; - margin: 0; +h3 { + margin-top: 3.2rem; + margin-bottom: 0.4rem; + font-size: 1.6rem; + font-weight: 600; +} - & p { - margin-left: 10%; - margin-right: 10%; - margin-top: -0.5em; - opacity: 0.6; - } +h4 { + margin-top: 1rem; + margin-bottom: 0.4rem; + font-weight: normal; } -.cta--primary { - margin: 0 1em; +footer { + margin-top: 3.2rem; } -.cta--secondary { - margin: 0 1em; +.checkbox { + cursor: pointer; + position: relative; + text-align: left; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + display: inline-flex; + align-items: center; } -.header__blurb { - color: var(--color-foreground--75); - font-size: 1.5rem; - font-weight: 200; - margin: 0; - text-align: justify; +.checkbox-group { + display: flex; + align-items: center; + flex-direction: row; + + span { + display: inline-block; + margin-left: 0.6rem; + margin-right: 0.4rem; + } } -.header__link { - color: var(--color-foreground); +input[type='checkbox'] { + position: relative; + appearance: none; + cursor: pointer; + width: 2rem; + height: 2rem; + + border: 1px solid var(--vscode-input-border); + background-color: var(--vscode-checkbox-background); + border-color: var(--vscode-checkbox-border); + border-radius: 0.25rem; + color: var(--vscode-checkbox-foreground); outline: none; - margin: 0 1rem 0.75rem 2rem; + margin-right: 0.5rem; + vertical-align: middle; + transition: border-color 0.1s ease-in-out; + + &::after { + content: ''; + border: 0.2rem solid var(--vscode-checkbox-foreground); + border-width: 0 0.2rem 0.2rem 0; + position: absolute; + top: 0.2rem; + left: 0.6rem; + height: 1.2rem; + width: 0.6rem; + + opacity: 0; + transform: rotate(0deg); + transition: 0.2s ease-in-out; + } + + &:checked { + background-color: var(--vscode-checkbox-selectBackground); + border-color: var(--vscode-checkbox-selectBorder); + + &::after { + opacity: 1; + transform: rotate(45deg); + } + } &:hover, - &:active, &:focus { - color: var(--color-foreground); - outline: none; - } - - @media all and (max-width: 768px) { - margin: 0 0 0.75rem 0; + background-color: var(--vscode-checkbox-selectBackground); + border-color: var(--vscode-focusBorder); } } -.header__logo { - display: flex; - flex-wrap: nowrap; -} - -.header__title { - font-family: 'Segoe UI Semibold', var(--font-family); +label { + vertical-align: middle; + cursor: pointer; } -.header__title--highlight { - color: #914db3; -} - -.header__subtitle { +p, +li { color: var(--color-foreground--65); - font-family: 'Segoe UI Light', var(--font-family); - font-size: 2rem; - font-weight: 100; - margin: -0.5em 0 0 4px; - white-space: nowrap; } -.hero__area { - grid-area: hero; - color: var(--color-foreground--75); - font-size: 1.5rem; - font-weight: 200; - margin: 0 1em; +.t { + &-desc { + font-size: 1.4rem; + color: var(--color-foreground); + } - b { + &-feature { + font-size: 1.4rem; color: var(--color-foreground--85); + line-height: 2rem; + max-width: 692px; } -} - -.image__logo { - margin: 9px 1em 0 0; - max-height: 64px; - max-width: 64px; -} -.image__preview { - border-radius: 8px; - box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.8), 0px 0px 12px 1px rgba(0, 0, 0, 0.5); - width: 600px; -} - -.image__preview--overlay { - left: 0; - position: absolute; - top: 0; -} - -.link__configure, -.link__learn-more { - margin-left: 10px; -} + &-eyebrow { + text-transform: uppercase; + font-size: 1rem; + font-weight: 600; + color: var(--color-foreground--50); + margin: 0; + } -.presets { - align-items: baseline; - justify-content: center; - display: flex; - width: 100%; - flex-wrap: wrap; - gap: 1em; + &-nowrap { + white-space: nowrap; + } } -.preset { - text-align: center; - margin: 0 1em; - - p { - color: var(--color-foreground--75); - display: block; - font-weight: 200; - font-size: 1.3rem; - margin: -0.5em 1em 0.5em 1em; - text-align: center; +.h { + &-space-half { + margin-top: 1.6rem; + margin-bottom: 0.65rem; } - .image__preview { - display: flex; - width: auto; - margin-top: 1em; + @media (min-width: 744px) { + &-large-mt-0 { + margin-top: 0; + } } -} -section { - display: flex; - flex-wrap: wrap; + &-show-large { + display: none; - margin-bottom: 1em; - padding: 1em; + @media (min-width: 744px) { + display: block; + } + } - h2 { - flex: 1 0 auto; + &-show-small { + display: block; - display: flex; - margin-top: 0; - margin-bottom: 1em; + @media (min-width: 744px) { + display: none; + } } } -.section--full { - flex-flow: column; +gk-card p { + margin: 0; } -.section--settings { - flex: 0 1 auto; - - display: flex; - flex-wrap: wrap; +.promo-banner { + text-align: center; - border-radius: 6px; - margin: 1em; - padding: 1em; + &__media { + width: 100%; + max-width: 100%; + height: auto; - .vscode-dark & { - background: var(--color-background--lighten-075); - } + &.is-light { + display: var(--promo-banner-light-display, none); + } - .vscode-light & { - background: var(--color-background--darken-05); + &.is-dark { + display: var(--promo-banner-dark-display, none); + } } } -.section__content { - flex: 1 1 auto; +.welcome { + padding: var(--gitlens-gutter-width); - display: flex; - flex-flow: column wrap; -} - -.section__header { - display: flex; - align-items: baseline; - flex: 0 1 auto; - flex-flow: column; - margin-bottom: 1em; - margin-right: 1em; - position: relative; - - h2 { - margin-bottom: 0; + #version { + color: var(--color-foreground); + font-weight: 600; } - .link__configure, - .link__learn-more { - visibility: hidden; + &__header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1.6rem; + margin-bottom: 1.6rem; } - &:hover { - .link__configure, - .link__learn-more { - visibility: visible; + &__brand { + margin: 0; + + small { + display: inline-block; + font-size: 1.6rem; + font-weight: 200; + color: var(--color-foreground--50); + transform: translateY(0.3rem); + margin-left: 1rem; } } -} - -.section__header-hint { - color: var(--color-foreground--75); - font-weight: 200; - margin: 0.25em 0; -} - -.section__hint { - flex: 0 0 auto; - color: var(--color-foreground--75); - font-weight: 200; - margin: 0; -} + &__release { + display: flex; + flex-direction: column; + align-items: flex-end; + margin: 0; + } -.section__preview { - flex: 0 1 auto; - position: relative; - margin-left: auto; - margin-right: auto; -} + &__main { + // display: flex; + // flex-direction: column; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: min-content; + gap: 1.6rem 3rem; + } -.section__title { - flex: 1 0 auto; - margin: 0; -} + &__section { + & + & { + // border-top: 1px solid var(--vscode-editorWidget-border); + padding-top: 0.5rem; + max-width: 692px; + } -.section__title--primary { - font-size: 3rem; - margin: 0.3em 0; - justify-content: center; -} + > *:last-child { + margin-bottom: 0; + } + } -.section__title--break { - margin: 0.3em 15% 0 15%; - padding-top: 1em; - justify-content: center; + &__gates { + p:first-child { + margin-bottom: 0.5rem; + } - .vscode-dark & { - border-top: 1px solid var(--color-background--lighten-30); + p:last-child { + margin-top: 0.5rem; + margin-left: 2rem; + } } - .vscode-light & { - border-top: 1px solid var(--color-background--darken-30); + &__preview { + margin-left: 0.5rem; + font-size: 1.1rem; + font-weight: normal; + text-transform: uppercase; } -} -.section__whatsnew { - display: flex; - flex-direction: column; - align-items: center; - font-weight: 200; - margin: 1rem; + &__toolbar { + display: flex; + flex-direction: row; + align-items: center; + gap: 1.6rem; + justify-content: space-between; - img { - width: 100%; - max-width: 600px; - border-radius: 8px; - box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.8), 0px 0px 12px 1px rgba(0, 0, 0, 0.5); - margin-bottom: 2rem; + :last-child { + flex: none; + } } - a { - font-weight: 600; + &__illustration { + max-width: 69.2rem; + width: calc(100% - 2rem); + height: auto; + margin: 0 1rem; } -} -.button__subaction { - color: var(--color-foreground--65); - margin-top: -0.5rem; -} - -.setting { - flex: 0 1 auto; - position: relative; - margin-right: 1em; + &__plus-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.6rem; + } - & input[type='checkbox'] { - flex: 0 0 16px; - height: 16px; - margin: 0 10px 0 0; - position: relative; - top: 3px; - width: 16px; + &__starting-nav { + max-width: 69.2rem; + container-type: inline-size; + display: flex; + flex-direction: column; } - &[disabled] { - label { - color: var(--color-foreground--75); - cursor: default; + &__views-nav { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 0.64rem 2rem; + // width: min-content; + margin: 0 1rem 1.3rem; + + > * { + white-space: nowrap; } - } -} -.setting__input { - display: inline-flex; - flex-wrap: nowrap; - align-items: baseline; - line-height: normal; - margin: 0.5em 0; - - input, - select { - flex-grow: 1; - - & + .link__configure, - & + .link__learn-more { - margin-left: 0; + a { + color: var(--color-foreground); + } + code-icon { + margin-right: 0.8rem; + color: var(--color-foreground--50); + transform: translateY(0.2rem); + vertical-align: text-bottom; } } - input[type='text'], - input:not([type]) { - min-width: 245px; - } + &__resources-nav { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 0.64rem 2rem; - label { - flex-grow: 0; + > * { + white-space: nowrap; + } - > select { - margin-top: 0.25em; + h3 { + grid-column: 1 / -1; + // margin-bottom: -0.6rem; } - } - .link__learn-more, - .link__configure { - visibility: hidden; - max-height: 15px; + a { + // font-size: 1.4rem; + color: inherit; + } - .icon { - display: block; - top: unset; + code-icon { + color: var(--color-foreground--50); + margin-right: 0.8rem; } } - &:hover { - .link__learn-more, - .link__configure { - visibility: visible; + @media (min-width: 744px) { + &__main { + grid-template-columns: 3fr 2fr; + grid-template-rows: min-content min-content min-content 1fr; + } + &__section { + grid-column: 1; + + &:nth-child(3) { + grid-column: 2; + grid-row: 1 / 5; + border-top: none; + padding-top: 0; + } } - } -} -.setting__input--big { - font-size: 2.2rem; - font-weight: 200; - margin: 0; + &__plus-cards { + display: flex; + flex-direction: column; + } - & input[type='checkbox'] { - flex: 0 0 1.5em; - height: 1em; - margin: 0; - position: relative; - top: 3px; - left: -5px; - width: 1em; + footer { + margin-top: 0; + } } - & label { - white-space: nowrap; + @media (min-width: 880px) { + &__main { + grid-template-columns: 4fr 2fr; + } } - .link__learn-more, - .link__configure { - max-height: 17px; + @media (min-width: 1200px) { + margin: 0 auto; + max-width: 1200px; } } -.setting__input--format { - display: flex; - - input[type='text'], - input:not([type]) { - max-width: unset; - } +.codicon { + font-family: codicon; + cursor: default; + user-select: none; } -.setting__hint { - color: var(--color-foreground--75); - display: block; - font-weight: 200; - font-size: 1.3rem; - margin: 0 1em 0.5em 1em; +.glicon { + font-family: glicons; + cursor: default; + user-select: none; } -.settings { - flex: 1 0 auto; +body { + &[data-repos='blocked'] [data-requires='repo'] { + opacity: 0.5; + cursor: not-allowed; + } - display: flex; - flex-wrap: wrap; - // align-items: baseline; - // justify-content: space-between; + &:not([data-repos='blocked']) [data-requires='norepo'] { + display: none; + } - .setting { - margin-right: 3em; + &[data-org-ai='blocked'] [data-org-requires='ai'], + &[data-org-drafts='blocked'] [data-org-requires='drafts'] { + display: none; + } + + &[data-org-ai='allowed'] [data-org-requires='noai'], + &[data-org-drafts='allowed'] [data-org-requires='nodrafts'] { + display: none; } } -.settings--fixed { - display: block; +.button-container { + margin: 1rem auto 0; + text-align: left; + max-width: 30rem; + transition: max-width 0.2s ease-out; } -.sidebar { - grid-area: sidebar; - align-self: flex-start; - font-size: 1.3rem; - position: sticky; - top: 0; - z-index: 2; +@media (min-width: 640px) { + .button-container { + max-width: 100%; + } +} - @media all and (max-width: 768px) { - display: none; +.button-group { + display: inline-flex; + gap: 0.1rem; + + &--single { + width: 100%; + max-width: 30rem; } - li { - white-space: nowrap; + gl-button { + &:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + &:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } } - .button { - margin: 0; + video-button { + width: 100%; } } -.sidebar__group { - margin-top: 1em; +.alert { + max-width: 38.4rem; + position: relative; + padding: 0.8rem 1.2rem; + line-height: 1.2; + margin-bottom: 1.2rem; + background-color: var(--color-alert-neutralBackground); + border-left: 0.3rem solid var(--color-alert-neutralBorder); + color: var(--color-alert-foreground); + + &__title { + font-size: 1.4rem; + margin: 0; + } - h2 { - font-size: 2rem; + &__description { + font-size: 1.2rem; + margin: 0.4rem 0 0; } - p { - font-weight: 400; - opacity: 0.5; - text-align: center; + &__close { + position: absolute; + top: 0.8rem; + right: 0.8rem; + color: inherit; } -} -.sidebar__jump-link { - &.active { - font-weight: 700; + &--info { + background-color: var(--color-alert-infoBackground); + border-left-color: var(--color-alert-infoBorder); + } - &:before { - content: ' '; - border-left: 4px solid var(--color-link-foreground--darken-20); - position: absolute; - left: -1em; - height: 1em; - padding-bottom: 4px; + &--warning { + background-color: var(--color-alert-warningBackground); + border-left-color: var(--color-alert-warningBorder); + } - .vscode-light & { - border-left-color: var(--color-link-foreground--lighten-20); - } - } + &--danger { + background-color: var(--color-alert-errorBackground); + border-left-color: var(--color-alert-errorBorder); } } -.highlight { - background-color: #914db3; - border-bottom: 2px solid #914db3; - border-radius: 3px; - color: #f2f2f2; - margin: 0 0.25em; - padding: 2px 10px; - text-align: center; - vertical-align: bottom; +.sticky { + background-color: var(--color-background); + position: -webkit-sticky; + position: sticky; + top: -1px; + z-index: 2; + padding-top: 1px; + padding-bottom: 1px; } -.is-sidebar-hidden { - display: none; +.t-eyebrow.sticky { + top: 2.4rem; + z-index: 1; +} - @media all and (max-width: 768px) { - display: initial; - } +gl-feature-badge { + display: inline-block; } -@import '../shared/utils'; -// @import '../shared/snow'; +gl-feature-badge.super { + vertical-align: super; + margin-left: 0.2rem; +} + +gl-feature-badge.super.small { + --gl-feature-badge-font-size: 7px; + margin-left: 0.4rem; +} + +gl-indicator { + position: absolute; + top: -15px; + z-index: 1; +} -.sidebar { - margin-right: 14px; +hr { + border: none; + border-top: 1px solid var(--color-foreground--25); } diff --git a/src/webviews/apps/welcome/welcome.ts b/src/webviews/apps/welcome/welcome.ts index 4ebd73435141e..a490978c6a5f2 100644 --- a/src/webviews/apps/welcome/welcome.ts +++ b/src/webviews/apps/welcome/welcome.ts @@ -1,13 +1,163 @@ /*global*/ import './welcome.scss'; +import type { Disposable } from 'vscode'; +import type { IpcMessage } from '../../protocol'; import type { State } from '../../welcome/protocol'; -import { AppWithConfig } from '../shared/appWithConfigBase'; +import { DidChangeNotification, DidChangeOrgSettings, UpdateConfigurationCommand } from '../../welcome/protocol'; +import { App } from '../shared/appBase'; +import type { GlFeatureBadge } from '../shared/components/feature-badge'; +import { DOM } from '../shared/dom'; +import type { BlameSvg } from './components/svg-blame'; // import { Snow } from '../shared/snow'; +import '../shared/components/code-icon'; +import '../shared/components/button'; +import '../shared/components/feature-badge'; +import '../shared/components/overlays/tooltip'; +import './components/card'; +import './components/gitlens-logo'; +import './components/svg-annotations'; +import './components/svg-blame'; +import './components/svg-editor-toolbar'; +import './components/svg-focus'; +import './components/svg-graph'; +import './components/svg-launchpad'; +import './components/svg-revision-navigation'; +import './components/svg-timeline'; +import './components/svg-workspaces'; +import './components/video-button'; +import '../shared/components/indicators/indicator'; -export class WelcomeApp extends AppWithConfig { +export class WelcomeApp extends App { constructor() { super('WelcomeApp'); } + + protected override onInitialize() { + this.updateState(); + } + + protected override onBind(): Disposable[] { + const disposables = [ + ...(super.onBind?.() ?? []), + DOM.on('[data-feature]', 'change', (e, target: HTMLInputElement) => this.onFeatureToggled(e, target)), + DOM.on('[data-requires="repo"]', 'click', (e, target: HTMLElement) => this.onRepoFeatureClicked(e, target)), + ]; + return disposables; + } + + protected override onMessageReceived(msg: IpcMessage) { + switch (true) { + case DidChangeNotification.is(msg): + this.state = msg.params.state; + this.setState(this.state); + this.updateState(); + break; + + case DidChangeOrgSettings.is(msg): + this.state.orgSettings = msg.params.orgSettings; + this.setState(this.state); + this.updateOrgSettings(); + break; + + default: + super.onMessageReceived?.(msg); + break; + } + } + + private onRepoFeatureClicked(e: MouseEvent, _target: HTMLElement) { + if (this.state.repoFeaturesBlocked ?? false) { + e.preventDefault(); + e.stopPropagation(); + return false; + } + + return true; + } + + private onFeatureToggled(_e: Event, target: HTMLElement) { + const feature = target.dataset.feature; + if (!feature) return; + + let type: keyof State['config']; + switch (feature) { + case 'blame': + type = 'currentLine'; + break; + case 'codelens': + type = 'codeLens'; + break; + default: + return; + } + + const enabled = (target as HTMLInputElement).checked; + this.state.config[type] = enabled; + this.sendCommand(UpdateConfigurationCommand, { type: type, value: enabled }); + this.updateFeatures(); + } + + private updateState() { + this.updateVersion(); + this.updateFeatures(); + this.updateRepoState(); + this.updateAccountState(); + this.updatePromo(); + this.updateSource(); + this.updateOrgSettings(); + } + + private updateOrgSettings() { + const { + orgSettings: { drafts, ai }, + } = this.state; + + document.body.dataset.orgDrafts = drafts ? 'allowed' : 'blocked'; + document.body.dataset.orgAi = ai ? 'allowed' : 'blocked'; + } + + private updatePromo() { + const { canShowPromo } = this.state; + document.getElementById('promo')!.hidden = !(canShowPromo ?? false); + } + + private updateSource() { + const els = document.querySelectorAll('gl-feature-badge'); + for (const el of els) { + el.source = { source: 'welcome', detail: 'badge' }; + } + } + + private updateVersion() { + document.getElementById('version')!.textContent = this.state.version; + } + + private updateFeatures() { + const { config } = this.state; + + const $el = document.getElementById('blame') as BlameSvg; + $el.inline = config.currentLine ?? false; + $el.codelens = config.codeLens ?? false; + + let $input = document.getElementById('inline-blame') as HTMLInputElement; + $input.checked = config.currentLine ?? false; + + $input = document.getElementById('codelens') as HTMLInputElement; + $input.checked = config.codeLens ?? false; + } + + private updateRepoState() { + const { repoFeaturesBlocked } = this.state; + document.body.dataset.repos = repoFeaturesBlocked ? 'blocked' : 'allowed'; + } + + private updateAccountState() { + const { isTrialOrPaid } = this.state; + for (const el of document.querySelectorAll('[data-visible="try-pro"]')) { + (el as HTMLElement).hidden = isTrialOrPaid ?? false; + } + // document.getElementById('try-pro')!.hidden = isTrialOrPaid ?? false; + } } new WelcomeApp(); diff --git a/src/webviews/commitDetails/actions.ts b/src/webviews/commitDetails/actions.ts new file mode 100644 index 0000000000000..1dd97f760d700 --- /dev/null +++ b/src/webviews/commitDetails/actions.ts @@ -0,0 +1,28 @@ +import { Container } from '../../container'; +import type { CommitSelectedEvent } from '../../eventBus'; +import type { Repository } from '../../git/models/repository'; +import type { WebviewViewShowOptions } from '../webviewsController'; +import type { ShowWipArgs } from './protocol'; + +export async function showInspectView( + data: Partial | ShowWipArgs, + showOptions?: WebviewViewShowOptions, +): Promise { + return Container.instance.commitDetailsView.show(showOptions, data); +} + +export async function startCodeReview( + repository: Repository | undefined, + source: ShowWipArgs['source'], + showOptions?: WebviewViewShowOptions, +): Promise { + return showInspectView( + { + type: 'wip', + inReview: true, + repository: repository, + source: source, + } satisfies ShowWipArgs, + showOptions, + ); +} diff --git a/src/webviews/commitDetails/commitDetailsWebview.ts b/src/webviews/commitDetails/commitDetailsWebview.ts new file mode 100644 index 0000000000000..f42a9289b3df4 --- /dev/null +++ b/src/webviews/commitDetails/commitDetailsWebview.ts @@ -0,0 +1,1974 @@ +import { EntityIdentifierUtils } from '@gitkraken/provider-apis'; +import type { CancellationToken, ConfigurationChangeEvent, TextDocumentShowOptions } from 'vscode'; +import { CancellationTokenSource, Disposable, env, Uri, window } from 'vscode'; +import { extractDraftMessage } from '../../ai/aiProviderService'; +import type { MaybeEnrichedAutolink } from '../../annotations/autolinks'; +import { serializeAutolink } from '../../annotations/autolinks'; +import { getAvatarUri } from '../../avatars'; +import type { CopyMessageToClipboardCommandArgs } from '../../commands/copyMessageToClipboard'; +import type { CopyShaToClipboardCommandArgs } from '../../commands/copyShaToClipboard'; +import type { OpenPullRequestOnRemoteCommandArgs } from '../../commands/openPullRequestOnRemote'; +import { Commands } from '../../constants.commands'; +import type { ContextKeys } from '../../constants.context'; +import type { Sources } from '../../constants.telemetry'; +import type { Container } from '../../container'; +import type { CommitSelectedEvent } from '../../eventBus'; +import { executeGitCommand } from '../../git/actions'; +import { + openChanges, + openChangesWithWorking, + openComparisonChanges, + openFile, + openFileOnRemote, + showDetailsQuickPick, +} from '../../git/actions/commit'; +import * as RepoActions from '../../git/actions/repository'; +import { CommitFormatter } from '../../git/formatters/commitFormatter'; +import type { GitBranch } from '../../git/models/branch'; +import type { GitCommit } from '../../git/models/commit'; +import { isCommit, isStash } from '../../git/models/commit'; +import { uncommitted, uncommittedStaged } from '../../git/models/constants'; +import type { GitFileChange, GitFileChangeShape } from '../../git/models/file'; +import type { IssueOrPullRequest } from '../../git/models/issue'; +import { serializeIssueOrPullRequest } from '../../git/models/issue'; +import type { PullRequest } from '../../git/models/pullRequest'; +import { getComparisonRefsForPullRequest, serializePullRequest } from '../../git/models/pullRequest'; +import type { GitRevisionReference } from '../../git/models/reference'; +import { createReference, getReferenceFromRevision, shortenRevision } from '../../git/models/reference'; +import type { GitRemote } from '../../git/models/remote'; +import type { Repository } from '../../git/models/repository'; +import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; +import type { CreateDraftChange, Draft, DraftVisibility } from '../../gk/models/drafts'; +import { showPatchesView } from '../../plus/drafts/actions'; +import type { Subscription } from '../../plus/gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../../plus/gk/account/subscriptionService'; +import type { ConnectionStateChangeEvent } from '../../plus/integrations/integrationService'; +import { IssueIntegrationId } from '../../plus/integrations/providers/models'; +import { getEntityIdentifierInput } from '../../plus/integrations/providers/utils'; +import { confirmDraftStorage, ensureAccount } from '../../plus/utils'; +import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/protocol'; +import type { Change } from '../../plus/webviews/patchDetails/protocol'; +import { debug } from '../../system/decorators/log'; +import type { Deferrable } from '../../system/function'; +import { debounce } from '../../system/function'; +import { filterMap, map } from '../../system/iterable'; +import { Logger } from '../../system/logger'; +import { getLogScope } from '../../system/logger.scope'; +import { MRU } from '../../system/mru'; +import { getSettledValue, pauseOnCancelOrTimeoutMapTuplePromise } from '../../system/promise'; +import { + executeCommand, + executeCoreCommand, + executeCoreGitCommand, + registerCommand, +} from '../../system/vscode/command'; +import { configuration } from '../../system/vscode/configuration'; +import { getContext, onDidChangeContext } from '../../system/vscode/context'; +import type { Serialized } from '../../system/vscode/serialize'; +import { serialize } from '../../system/vscode/serialize'; +import type { LinesChangeEvent } from '../../trackers/lineTracker'; +import type { IpcCallMessageType, IpcMessage } from '../protocol'; +import { updatePendingContext } from '../webviewController'; +import type { WebviewHost, WebviewProvider, WebviewShowingArgs } from '../webviewProvider'; +import type { WebviewShowOptions } from '../webviewsController'; +import { isSerializedState } from '../webviewsController'; +import type { + CommitDetails, + CreatePatchFromWipParams, + DidChangeWipStateParams, + DidExplainParams, + DidGenerateParams, + ExecuteFileActionParams, + GitBranchShape, + Mode, + Preferences, + ShowWipArgs, + State, + SuggestChangesParams, + SwitchModeParams, + UpdateablePreferences, + Wip, + WipChange, +} from './protocol'; +import { + ChangeReviewModeCommand, + CreatePatchFromWipCommand, + DidChangeConnectedJiraNotification, + DidChangeDraftStateNotification, + DidChangeHasAccountNotification, + DidChangeNotification, + DidChangeWipStateNotification, + ExecuteCommitActionCommand, + ExecuteFileActionCommand, + ExplainRequest, + FetchCommand, + GenerateRequest, + messageHeadlineSplitterToken, + NavigateCommand, + OpenFileCommand, + OpenFileComparePreviousCommand, + OpenFileCompareWorkingCommand, + OpenFileOnRemoteCommand, + OpenPullRequestChangesCommand, + OpenPullRequestComparisonCommand, + OpenPullRequestDetailsCommand, + OpenPullRequestOnRemoteCommand, + PickCommitCommand, + PinCommand, + PublishCommand, + PullCommand, + PushCommand, + SearchCommitCommand, + ShowCodeSuggestionCommand, + StageFileCommand, + SuggestChangesCommand, + SwitchCommand, + SwitchModeCommand, + UnstageFileCommand, + UpdatePreferencesCommand, +} from './protocol'; +import type { CommitDetailsWebviewShowingArgs } from './registration'; + +type RepositorySubscription = { repo: Repository; subscription: Disposable }; + +// interface WipContext extends Wip +interface WipContext { + changes: WipChange | undefined; + repositoryCount: number; + branch?: GitBranch; + pullRequest?: PullRequest; + repo: Repository; + codeSuggestions?: Draft[]; +} + +interface Context { + mode: Mode; + navigationStack: { + count: number; + position: number; + hint?: string; + }; + pinned: boolean; + preferences: Preferences; + + commit: GitCommit | undefined; + richStateLoaded: boolean; + formattedMessage: string | undefined; + autolinkedIssues: IssueOrPullRequest[] | undefined; + pullRequest: PullRequest | undefined; + wip: WipContext | undefined; + inReview: boolean; + orgSettings: State['orgSettings']; + source?: Sources; + hasConnectedJira: boolean | undefined; + hasAccount: boolean | undefined; +} + +export class CommitDetailsWebviewProvider + implements WebviewProvider, CommitDetailsWebviewShowingArgs> +{ + private _bootstraping = true; + /** The context the webview has */ + private _context: Context; + /** The context the webview should have */ + private _pendingContext: Partial | undefined; + private readonly _disposable: Disposable; + private _pinned = false; + private _focused = false; + private _commitStack = new MRU(10, (a, b) => a.ref === b.ref); + + constructor( + private readonly container: Container, + private readonly host: WebviewHost, + private readonly options: { attachedTo: 'default' | 'graph' }, + ) { + this._context = { + mode: 'commit', + inReview: false, + navigationStack: { + count: 0, + position: 0, + }, + pinned: false, + preferences: this.getPreferences(), + + commit: undefined, + richStateLoaded: false, + formattedMessage: undefined, + autolinkedIssues: undefined, + pullRequest: undefined, + wip: undefined, + orgSettings: this.getOrgSettings(), + hasConnectedJira: undefined, + hasAccount: undefined, + }; + + this._disposable = Disposable.from( + configuration.onDidChangeAny(this.onAnyConfigurationChanged, this), + onDidChangeContext(this.onContextChanged, this), + this.container.subscription.onDidChange(this.onSubscriptionChanged, this), + container.integrations.onDidChangeConnectionState(this.onIntegrationConnectionStateChanged, this), + ); + } + + dispose() { + this._disposable.dispose(); + this._lineTrackerDisposable?.dispose(); + this._repositorySubscription?.subscription.dispose(); + this._selectionTrackerDisposable?.dispose(); + this._wipSubscription?.subscription.dispose(); + } + + private _skipNextRefreshOnVisibilityChange = false; + private _shouldRefreshPullRequestDetails = false; + + async onShowing( + _loading: boolean, + options?: WebviewShowOptions, + ...args: WebviewShowingArgs> + ): Promise { + const [arg] = args; + if ((arg as ShowWipArgs)?.type === 'wip') { + return this.onShowingWip(arg as ShowWipArgs); + } + return this.onShowingCommit(arg as Partial | undefined, options); + } + + private get inReview(): boolean { + return this._pendingContext?.inReview ?? this._context.inReview; + } + + async onShowingWip(arg: ShowWipArgs, options?: WebviewShowOptions): Promise { + this.updatePendingContext({ source: arg.source }); + const shouldChangeReview = arg.inReview != null && this.inReview != arg.inReview; + if (this.mode != 'wip' || (arg.repository != null && this._context.wip?.repo != arg.repository)) { + if (shouldChangeReview) { + this.updatePendingContext({ inReview: arg.inReview }); + } + await this.setMode('wip', arg.repository); + if (shouldChangeReview && arg.inReview === true) { + void this.trackOpenReviewMode(arg.source); + } + } else if (shouldChangeReview) { + await this.setInReview(arg.inReview!, arg.source); + } + + if (options?.preserveVisibility && !this.host.visible) return false; + + if (arg.source === 'launchpad' && this.host.visible) { + this._shouldRefreshPullRequestDetails = true; + this.onRefresh(); + } + + return true; + } + + async onShowingCommit( + arg: Partial | undefined, + options?: WebviewShowOptions, + ): Promise { + let data: Partial | undefined; + + if (isSerializedState>(arg)) { + const { commit: selected } = arg.state; + if (selected?.repoPath != null && selected?.sha != null) { + if (selected.stashNumber != null) { + data = { + commit: createReference(selected.sha, selected.repoPath, { + refType: 'stash', + name: selected.message, + number: selected.stashNumber, + }), + }; + } else { + data = { + commit: createReference(selected.sha, selected.repoPath, { + refType: 'revision', + message: selected.message, + }), + }; + } + } + } else if (arg != null && typeof arg === 'object') { + data = arg; + } else { + data = undefined; + } + + let commit; + if (data != null) { + if (data.preserveFocus) { + if (options == null) { + options = { preserveFocus: true }; + } else { + options.preserveFocus = true; + } + } + ({ commit, ...data } = data); + } + + if (commit != null && this.mode === 'wip' && data?.interaction !== 'passive') { + await this.setMode('commit'); + } + + if (commit == null) { + if (!this._pinned) { + commit = this.getBestCommitOrStash(); + } + } + + if (commit != null && !this._context.commit?.ref.startsWith(commit.ref)) { + await this.updateCommit(commit, { pinned: false }); + } + + if (data?.preserveVisibility && !this.host.visible) return false; + + this._skipNextRefreshOnVisibilityChange = true; + return true; + } + + async trackOpenReviewMode(source?: Sources) { + if (this._context.wip?.pullRequest == null) return; + + const provider = this._context.wip.pullRequest.provider.id; + const repoPrivacy = await this.container.git.visibility(this._context.wip.repo.path); + const filesChanged = this._context.wip.changes?.files.length ?? 0; + + this.container.telemetry.sendEvent('openReviewMode', { + provider: provider, + 'repository.visibility': repoPrivacy, + repoPrivacy: repoPrivacy, + source: source ?? 'inspect', + filesChanged: filesChanged, + }); + } + + includeBootstrap(): Promise> { + this._bootstraping = true; + + this._context = { ...this._context, ...this._pendingContext }; + this._pendingContext = undefined; + + return this.getState(this._context); + } + + registerCommands(): Disposable[] { + return [registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true))]; + } + + onFocusChanged(focused: boolean): void { + if (this._focused === focused) return; + + this._focused = focused; + if (focused && this.isLineTrackerSuspended) { + this.ensureTrackers(); + } + } + + onMessageReceived(e: IpcMessage) { + switch (true) { + case OpenFileOnRemoteCommand.is(e): + void this.openFileOnRemote(e.params); + break; + + case OpenFileCommand.is(e): + void this.openFile(e.params); + break; + + case OpenFileCompareWorkingCommand.is(e): + void this.openFileComparisonWithWorking(e.params); + break; + + case OpenFileComparePreviousCommand.is(e): + void this.openFileComparisonWithPrevious(e.params); + break; + + case ExecuteFileActionCommand.is(e): + void this.showFileActions(e.params); + break; + + case ExecuteCommitActionCommand.is(e): + switch (e.params.action) { + case 'graph': { + let ref: GitRevisionReference | undefined; + if (this._context.mode === 'wip') { + ref = + this._context.wip?.changes != null + ? createReference(uncommitted, this._context.wip.changes.repository.path, { + refType: 'revision', + }) + : undefined; + } else { + ref = + this._context.commit != null + ? getReferenceFromRevision(this._context.commit) + : undefined; + } + if (ref == null) return; + + void executeCommand( + this.options.attachedTo === 'graph' + ? Commands.ShowInCommitGraphView + : Commands.ShowInCommitGraph, + { ref: ref }, + ); + break; + } + case 'more': + this.showCommitActions(); + break; + + case 'scm': + void executeCoreCommand('workbench.view.scm'); + break; + + case 'sha': + if (this._context.commit != null) { + if (e.params.alt) { + void executeCommand( + Commands.CopyMessageToClipboard, + { + message: this._context.commit.message, + }, + ); + } else if (isStash(this._context.commit)) { + void env.clipboard.writeText(this._context.commit.stashName); + } else { + void executeCommand(Commands.CopyShaToClipboard, { + sha: this._context.commit.sha, + }); + } + } + break; + } + break; + + case PickCommitCommand.is(e): + this.showCommitPicker(); + break; + + case SearchCommitCommand.is(e): + this.showCommitSearch(); + break; + + case SwitchModeCommand.is(e): + this.switchMode(e.params); + break; + + case PinCommand.is(e): + this.updatePinned(e.params.pin ?? false, true); + break; + + case NavigateCommand.is(e): + this.navigateStack(e.params.direction); + break; + + case UpdatePreferencesCommand.is(e): + this.updatePreferences(e.params); + break; + + case ExplainRequest.is(e): + void this.explainRequest(ExplainRequest, e); + break; + + case GenerateRequest.is(e): + void this.generateRequest(GenerateRequest, e); + break; + + case StageFileCommand.is(e): + void this.stageFile(e.params); + break; + + case UnstageFileCommand.is(e): + void this.unstageFile(e.params); + break; + + case CreatePatchFromWipCommand.is(e): + this.createPatchFromWip(e.params); + break; + + case FetchCommand.is(e): + this.fetch(); + break; + + case PublishCommand.is(e): + this.publish(); + break; + + case PushCommand.is(e): + this.push(); + break; + + case PullCommand.is(e): + this.pull(); + break; + + case SwitchCommand.is(e): + this.switch(); + break; + case SuggestChangesCommand.is(e): + void this.suggestChanges(e.params); + break; + case ShowCodeSuggestionCommand.is(e): + this.showCodeSuggestion(e.params.id); + break; + case ChangeReviewModeCommand.is(e): + void this.setInReview(e.params.inReview, 'inspect-overview'); + break; + case OpenPullRequestChangesCommand.is(e): + void this.openPullRequestChanges(); + break; + case OpenPullRequestComparisonCommand.is(e): + void this.openPullRequestComparison(); + break; + case OpenPullRequestOnRemoteCommand.is(e): + void this.openPullRequestOnRemote(); + break; + case OpenPullRequestDetailsCommand.is(e): + void this.showPullRequestDetails(); + break; + } + } + + private getEncodedEntityid(pullRequest = this._context.wip?.pullRequest): string | undefined { + if (pullRequest == null) return undefined; + + const entity = getEntityIdentifierInput(pullRequest); + if (entity == null) return undefined; + + return EntityIdentifierUtils.encode(entity); + } + + private async trackCreateCodeSuggestion(draft: Draft, fileCount: number) { + if (this._context.wip?.pullRequest == null) return; + + const provider = this._context.wip.pullRequest.provider.id; + const repoPrivacy = await this.container.git.visibility(this._context.wip.repo.path); + + this.container.telemetry.sendEvent( + 'codeSuggestionCreated', + { + provider: provider, + 'repository.visibility': repoPrivacy, + repoPrivacy: repoPrivacy, + draftId: draft.id, + draftPrivacy: draft.visibility, + filesChanged: fileCount, + source: 'reviewMode', + }, + { + source: 'inspect-overview', + detail: { reviewMode: true }, + }, + ); + } + + private async suggestChanges(e: SuggestChangesParams) { + if ( + !(await ensureAccount(this.container, 'Code Suggestions are a Preview feature and require an account.', { + source: 'code-suggest', + detail: 'create', + })) || + !(await confirmDraftStorage(this.container)) + ) { + return; + } + + const createChanges: CreateDraftChange[] = []; + + const changes = Object.entries(e.changesets); + const ignoreChecked = changes.length === 1; + let createFileCount = 0; + + for (const [_, change] of changes) { + if (!ignoreChecked && change.checked === false) continue; + + // we only support a single repo for now + const repository = + this._context.wip!.repo.id === change.repository.path ? this._context.wip!.repo : undefined; + if (repository == null) continue; + + const { checked } = change; + let changeRevision = { to: uncommitted, from: 'HEAD' }; + if (checked === 'staged') { + changeRevision = { ...changeRevision, to: uncommittedStaged }; + } + + const prEntityId = this.getEncodedEntityid(); + if (prEntityId == null) continue; + + if (change.files && change.files.length > 0) { + if (checked === 'staged') { + createFileCount += change.files.filter(f => f.staged === true).length; + } else { + createFileCount += change.files.length; + } + } + + createChanges.push({ + repository: repository, + revision: changeRevision, + prEntityId: prEntityId, + }); + } + + if (createChanges.length === 0) return; + + try { + const entityIdentifier = getEntityIdentifierInput(this._context.wip!.pullRequest!); + const prEntityId = EntityIdentifierUtils.encode(entityIdentifier); + + const options = { + description: e.description, + visibility: 'provider_access' as DraftVisibility, + prEntityId: prEntityId, + }; + + const draft = await this.container.drafts.createDraft( + 'suggested_pr_change', + e.title, + createChanges, + options, + ); + + async function showNotification() { + const view = { title: 'View Code Suggestions' }; + const copy = { title: 'Copy Link' }; + let copied = false; + while (true) { + const result = await window.showInformationMessage( + `Code Suggestion successfully created${copied ? '\u2014 link copied to the clipboard' : ''}`, + view, + copy, + ); + + if (result === copy) { + void env.clipboard.writeText(draft.deepLinkUrl); + copied = true; + continue; + } + + if (result === view) { + void showPatchesView({ mode: 'view', draft: draft, source: 'notification' }); + } + + break; + } + } + + void showNotification(); + void this.setInReview(false); + + void this.trackCreateCodeSuggestion(draft, createFileCount); + } catch (ex) { + debugger; + + void window.showErrorMessage(`Unable to create draft: ${ex.message}`); + } + } + + private getRepoActionPath() { + if (this._context.mode === 'wip') { + return this._context.wip?.repo.path; + } + return this._context.commit?.repoPath; + } + + private fetch() { + const path = this.getRepoActionPath(); + if (path == null) return; + void RepoActions.fetch(path); + } + + private publish() { + const path = this.getRepoActionPath(); + if (path == null) return; + void executeCoreGitCommand('git.publish', Uri.file(path)); + } + + private push() { + const path = this.getRepoActionPath(); + if (path == null) return; + void RepoActions.push(path); + } + + private pull() { + const path = this.getRepoActionPath(); + if (path == null) return; + void RepoActions.pull(path); + } + + private switch() { + const path = this.getRepoActionPath(); + if (path == null) return; + void RepoActions.switchTo(path); + } + + private get pullRequestContext(): + | { pr: PullRequest; repoPath: string; branch?: GitBranch; commit?: GitCommit } + | undefined { + if (this.mode === 'wip') { + if (this._context.wip?.pullRequest == null) return; + + return { + repoPath: this._context.wip.repo.path, + branch: this._context.wip.branch, + pr: this._context.wip.pullRequest, + }; + } + + if (this._context.pullRequest == null) return; + + return { + repoPath: this._context.commit!.repoPath, + commit: this._context.commit!, + pr: this._context.pullRequest, + }; + } + + private openPullRequestChanges() { + if (this.pullRequestContext == null) return; + + const { repoPath, pr } = this.pullRequestContext; + if (pr.refs == null) return; + + const refs = getComparisonRefsForPullRequest(repoPath, pr.refs); + return openComparisonChanges( + this.container, + { + repoPath: refs.repoPath, + lhs: refs.base.ref, + rhs: refs.head.ref, + }, + { title: `Changes in Pull Request #${pr.id}` }, + ); + } + + private openPullRequestComparison() { + if (this.pullRequestContext == null) return; + + const { repoPath, pr } = this.pullRequestContext; + if (pr.refs == null) return; + + const refs = getComparisonRefsForPullRequest(repoPath, pr.refs); + return this.container.searchAndCompareView.compare(refs.repoPath, refs.head, refs.base); + } + + private async openPullRequestOnRemote(clipboard?: boolean) { + if (this.pullRequestContext == null) return; + + const { + pr: { url }, + } = this.pullRequestContext; + return executeCommand(Commands.OpenPullRequestOnRemote, { + pr: { url: url }, + clipboard: clipboard, + }); + } + + private async showPullRequestDetails() { + if (this.pullRequestContext == null) return; + + const { pr, repoPath, branch, commit } = this.pullRequestContext; + if (pr == null) return; + + return this.container.pullRequestView.showPullRequest(pr, commit ?? branch ?? repoPath); + } + + onRefresh(_force?: boolean | undefined): void { + if (this._pinned) return; + + if (this.mode === 'wip') { + const uri = this._context.wip?.changes?.repository.uri; + void this.updateWipState( + this.container.git.getBestRepositoryOrFirst(uri != null ? Uri.parse(uri) : undefined), + ); + } else { + const commit = this._pendingContext?.commit ?? this.getBestCommitOrStash(); + void this.updateCommit(commit, { immediate: false }); + } + } + + onReloaded(): void { + void this.notifyDidChangeState(true); + } + + onVisibilityChanged(visible: boolean) { + this.ensureTrackers(); + if (!visible) return; + + const skipRefresh = this._skipNextRefreshOnVisibilityChange; + if (skipRefresh) { + this._skipNextRefreshOnVisibilityChange = false; + } + + // Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data + if (this._bootstraping) { + this._bootstraping = false; + + if (this._pendingContext == null) return; + + this.updateState(); + } else { + if (!skipRefresh) { + this.onRefresh(); + } + this.updateState(true); + } + } + + private onAnyConfigurationChanged(e: ConfigurationChangeEvent) { + if ( + configuration.changed(e, [ + 'defaultDateFormat', + 'defaultDateStyle', + 'views.commitDetails.files', + 'views.commitDetails.avatars', + ]) || + configuration.changedCore(e, 'workbench.tree.renderIndentGuides') || + configuration.changedCore(e, 'workbench.tree.indent') + ) { + this.updatePendingContext({ + preferences: { + ...this._context.preferences, + ...this._pendingContext?.preferences, + ...this.getPreferences(), + }, + }); + this.updateState(); + } + + if ( + this._context.commit != null && + configuration.changed(e, ['views.commitDetails.autolinks', 'views.commitDetails.pullRequests']) + ) { + void this.updateCommit(this._context.commit, { force: true }); + this.updateState(); + } + } + + private onSubscriptionChanged(e: SubscriptionChangeEvent) { + void this.updateCodeSuggestions(); + this.updateHasAccount(e.current); + } + + updateHasAccount(subscription: Subscription) { + const hasAccount = subscription.account != null; + if (this._context.hasAccount == hasAccount) return; + + this._context.hasAccount = hasAccount; + void this.host.notify(DidChangeHasAccountNotification, { hasAccount: hasAccount }); + } + + onIntegrationConnectionStateChanged(e: ConnectionStateChangeEvent) { + if (e.key === 'jira') { + const hasConnectedJira = e.reason === 'connected'; + if (this._context.hasConnectedJira === hasConnectedJira) return; + + this._context.hasConnectedJira = hasConnectedJira; + void this.host.notify(DidChangeConnectedJiraNotification, { + hasConnectedJira: this._context.hasConnectedJira, + }); + } + } + + async getHasJiraConnection(force = false): Promise { + if (this._context.hasConnectedJira != null && !force) return this._context.hasConnectedJira; + + const jira = await this.container.integrations.get(IssueIntegrationId.Jira); + if (jira == null) { + this._context.hasConnectedJira = false; + } else { + this._context.hasConnectedJira = jira.maybeConnected ?? (await jira.isConnected()); + } + + return this._context.hasConnectedJira; + } + + async getHasAccount(force = false): Promise { + if (this._context.hasAccount != null && !force) return this._context.hasAccount; + + this._context.hasAccount = (await this.container.subscription.getSubscription())?.account != null; + + return this._context.hasAccount; + } + + private getPreferences(): Preferences { + return { + autolinksExpanded: this.container.storage.getWorkspace('views:commitDetails:autolinksExpanded') ?? true, + pullRequestExpanded: this.container.storage.getWorkspace('views:commitDetails:pullRequestExpanded') ?? true, + avatars: configuration.get('views.commitDetails.avatars'), + dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma', + dateStyle: configuration.get('defaultDateStyle') ?? 'relative', + files: configuration.get('views.commitDetails.files'), + indentGuides: configuration.getCore('workbench.tree.renderIndentGuides') ?? 'onHover', + indent: configuration.getCore('workbench.tree.indent'), + }; + } + + private onContextChanged(key: keyof ContextKeys) { + if (['gitlens:gk:organization:ai:enabled', 'gitlens:gk:organization:drafts:enabled'].includes(key)) { + this.updatePendingContext({ orgSettings: this.getOrgSettings() }); + this.updateState(); + } + } + + private getOrgSettings(): State['orgSettings'] { + return { + ai: getContext('gitlens:gk:organization:ai:enabled', false), + drafts: getContext('gitlens:gk:organization:drafts:enabled', false), + }; + } + + private onCommitSelected(e: CommitSelectedEvent) { + if ( + e.data == null || + (this.options.attachedTo === 'graph' && e.source !== 'gitlens.views.graph') || + (this.options.attachedTo === 'default' && e.source === 'gitlens.views.graph') + ) { + return; + } + + if (this.options.attachedTo === 'graph' /*|| e.source === 'gitlens.graph'*/) { + if (e.data.commit.ref === uncommitted) { + if (this.mode !== 'wip') { + void this.setMode('wip', this.container.git.getRepository(e.data.commit.repoPath)); + } else if (e.data.commit.repoPath !== this._context.wip?.changes?.repository.path) { + void this.updateWipState(this.container.git.getRepository(e.data.commit.repoPath)); + } + } else { + if (this._pinned && e.data.interaction === 'passive') { + this._commitStack.insert(getReferenceFromRevision(e.data.commit)); + this.updateNavigation(); + } + + if (this.mode !== 'commit') { + void this.setMode('commit', this.container.git.getRepository(e.data.commit.repoPath)); + } + + if (!this._pinned || e.data.interaction !== 'passive') { + void this.host.show(false, { preserveFocus: e.data.preserveFocus }, e.data); + } + } + + return; + } + + if (this.mode === 'wip') { + if (e.data.commit.repoPath !== this._context.wip?.changes?.repository.path) { + void this.updateWipState(this.container.git.getRepository(e.data.commit.repoPath)); + } + + return; + } + + if (this._pinned && e.data.interaction === 'passive') { + this._commitStack.insert(getReferenceFromRevision(e.data.commit)); + this.updateNavigation(); + } else { + void this.host.show(false, { preserveFocus: e.data.preserveFocus }, e.data); + } + } + + private _lineTrackerDisposable: Disposable | undefined; + private _selectionTrackerDisposable: Disposable | undefined; + private ensureTrackers(): void { + this._selectionTrackerDisposable?.dispose(); + this._selectionTrackerDisposable = undefined; + this._lineTrackerDisposable?.dispose(); + this._lineTrackerDisposable = undefined; + + if (!this.host.visible) return; + + this._selectionTrackerDisposable = this.container.events.on('commit:selected', this.onCommitSelected, this); + + if (this._pinned) return; + + if (this.options.attachedTo !== 'graph') { + const { lineTracker } = this.container; + this._lineTrackerDisposable = lineTracker.subscribe( + this, + lineTracker.onDidChangeActiveLines(this.onActiveEditorLinesChanged, this), + ); + } + } + + private get isLineTrackerSuspended() { + return this.options.attachedTo !== 'graph' ? this._lineTrackerDisposable == null : false; + } + + private suspendLineTracker() { + // Defers the suspension of the line tracker, so that the focus change event can be handled first + setTimeout(() => { + this._lineTrackerDisposable?.dispose(); + this._lineTrackerDisposable = undefined; + }, 100); + } + + private createPatchFromWip(e: CreatePatchFromWipParams) { + if (e.changes == null) return; + + const change: Change = { + type: 'wip', + repository: { + name: e.changes.repository.name, + path: e.changes.repository.path, + uri: e.changes.repository.uri, + }, + files: e.changes.files, + revision: { to: uncommitted, from: 'HEAD' }, + checked: e.checked, + }; + + void showPatchesView({ mode: 'create', create: { changes: [change] } }); + } + + private showCodeSuggestion(id: string) { + const draft = this._context.wip?.codeSuggestions?.find(draft => draft.id === id); + if (draft == null) return; + + void showPatchesView({ mode: 'view', draft: draft, source: 'inspect' }); + } + + private onActiveEditorLinesChanged(e: LinesChangeEvent) { + if (e.pending || e.editor == null || e.suspended) return; + + if (this.mode === 'wip') { + const repo = this.container.git.getBestRepositoryOrFirst(e.editor); + void this.updateWipState(repo, true); + + return; + } + + const line = e.selections?.[0]?.active; + const commit = line != null ? this.container.lineTracker.getState(line)?.commit : undefined; + void this.updateCommit(commit); + } + + private _wipSubscription: RepositorySubscription | undefined; + + private get mode(): Mode { + return this._pendingContext?.mode ?? this._context.mode; + } + + private async setMode(mode: Mode, repository?: Repository): Promise { + this.updatePendingContext({ mode: mode }); + if (mode === 'commit') { + this.updateState(true); + } else { + await this.updateWipState(repository ?? this.container.git.getBestRepositoryOrFirst()); + } + + this.updateTitle(); + } + + private updateTitle() { + if (this.mode === 'commit') { + if (this._context.commit == null) { + this.host.title = this.host.originalTitle; + } else { + let following = 'Commit Details'; + if (this._context.commit.refType === 'stash') { + following = 'Stash Details'; + } else if (this._context.commit.isUncommitted) { + following = 'Uncommitted Changes'; + } + + this.host.title = `${this.host.originalTitle}: ${following}`; + } + } else { + this.host.title = `${this.host.originalTitle}: Overview`; + } + } + + private async explainRequest(requestType: T, msg: IpcCallMessageType) { + let params: DidExplainParams; + try { + const summary = await ( + await this.container.ai + )?.explainCommit( + this._context.commit!, + { source: 'inspect', type: isStash(this._context.commit) ? 'stash' : 'commit' }, + { progress: { location: { viewId: this.host.id } } }, + ); + if (summary == null) throw new Error('Error retrieving content'); + + params = { summary: summary }; + } catch (ex) { + debugger; + params = { error: { message: ex.message } }; + } + + void this.host.respond(requestType, msg, params); + } + + private async generateRequest(requestType: T, msg: IpcCallMessageType) { + const repo: Repository | undefined = this._context.wip?.repo; + + if (!repo) { + void this.host.respond(requestType, msg, { error: { message: 'Unable to find changes' } }); + return; + } + + let params: DidGenerateParams; + + try { + // TODO@eamodio HACK -- only works for the first patch + // const patch = await this.getDraftPatch(this._context.draft); + // if (patch == null) throw new Error('Unable to find patch'); + + // const commit = await this.getOrCreateCommitForPatch(patch.gkRepositoryId); + // if (commit == null) throw new Error('Unable to find commit'); + + const summary = await ( + await this.container.ai + )?.generateDraftMessage( + repo, + { source: 'inspect', type: 'suggested_pr_change' }, + { progress: { location: { viewId: this.host.id } } }, + ); + if (summary == null) throw new Error('Error retrieving content'); + + params = extractDraftMessage(summary); + } catch (ex) { + debugger; + params = { error: { message: ex.message } }; + } + + void this.host.respond(requestType, msg, params); + } + + private navigateStack(direction: 'back' | 'forward') { + const commit = this._commitStack.navigate(direction); + if (commit == null) return; + + void this.updateCommit(commit, { immediate: true, skipStack: true }); + } + + private _cancellationTokenSource: CancellationTokenSource | undefined = undefined; + + @debug({ args: false }) + protected async getState(current: Context): Promise> { + if (this._cancellationTokenSource != null) { + this._cancellationTokenSource.cancel(); + this._cancellationTokenSource = undefined; + } + + let details; + if (current.commit != null) { + details = await this.getDetailsModel(current.commit, current.formattedMessage); + + if (!current.richStateLoaded) { + this._cancellationTokenSource = new CancellationTokenSource(); + + const cancellation = this._cancellationTokenSource.token; + setTimeout(() => { + if (cancellation.isCancellationRequested) return; + void this.updateRichState(current, cancellation); + }, 100); + } + } + + const wip = current.wip; + if (wip == null && this._repositorySubscription) { + if (this._cancellationTokenSource == null) { + this._cancellationTokenSource = new CancellationTokenSource(); + } + const cancellation = this._cancellationTokenSource.token; + setTimeout(() => { + if (cancellation.isCancellationRequested) return; + void this.updateWipState(this._repositorySubscription?.repo); + }, 100); + } + + if (current.hasConnectedJira == null) { + current.hasConnectedJira = await this.getHasJiraConnection(); + } + + if (current.hasAccount == null) { + current.hasAccount = await this.getHasAccount(); + } + + const state = serialize({ + ...this.host.baseWebviewState, + mode: current.mode, + commit: details, + navigationStack: current.navigationStack, + pinned: current.pinned, + preferences: current.preferences, + includeRichContent: current.richStateLoaded, + autolinkedIssues: current.autolinkedIssues?.map(serializeIssueOrPullRequest), + pullRequest: current.pullRequest != null ? serializePullRequest(current.pullRequest) : undefined, + wip: serializeWipContext(wip), + orgSettings: current.orgSettings, + inReview: current.inReview, + hasConnectedJira: current.hasConnectedJira, + hasAccount: current.hasAccount, + }); + return state; + } + + @debug({ args: false }) + private async updateWipState(repository: Repository | undefined, onlyOnRepoChange = false): Promise { + if (this._wipSubscription != null) { + const { repo, subscription } = this._wipSubscription; + if (repository?.path !== repo.path) { + subscription.dispose(); + this._wipSubscription = undefined; + } else if (onlyOnRepoChange) { + return; + } + } + + let wip: WipContext | undefined = undefined; + let inReview = this.inReview; + + if (repository != null) { + if (this._wipSubscription == null) { + this._wipSubscription = { repo: repository, subscription: this.subscribeToRepositoryWip(repository) }; + } + + const changes = await this.getWipChange(repository); + wip = { + changes: changes, + repo: repository, + repositoryCount: this.container.git.openRepositoryCount, + }; + + if (changes != null) { + const branchDetails = await this.getWipBranchDetails(repository, changes.branchName); + if (branchDetails != null) { + wip.branch = branchDetails.branch; + wip.pullRequest = branchDetails.pullRequest; + wip.codeSuggestions = branchDetails.codeSuggestions; + } + } + + if (wip.pullRequest?.state !== 'opened') { + inReview = false; + } + + // TODO: Move this into the correct place. It is being called here temporarily to guarantee it gets an up-to-date PR. + // Once moved, we may not need the "source" property on context anymore. + if ( + this._shouldRefreshPullRequestDetails && + wip.pullRequest != null && + (this._context.source === 'launchpad' || this._pendingContext?.source === 'launchpad') + ) { + void this.container.pullRequestView.showPullRequest(wip.pullRequest, wip.branch ?? repository.path); + this._shouldRefreshPullRequestDetails = false; + } + + if (this._pendingContext == null) { + const success = await this.host.notify( + DidChangeWipStateNotification, + serialize({ + wip: serializeWipContext(wip), + inReview: inReview, + }) as DidChangeWipStateParams, + ); + if (success) { + this._context.wip = wip; + this._context.inReview = inReview; + return; + } + } + } + + this.updatePendingContext({ wip: wip, inReview: inReview }); + this.updateState(true); + } + + private async getWipBranchDetails( + repository: Repository, + branchName: string, + ): Promise<{ branch: GitBranch; pullRequest: PullRequest | undefined; codeSuggestions: Draft[] } | undefined> { + const branch = await repository.getBranch(branchName); + if (branch == null) return undefined; + + if (this.mode === 'commit') { + return { + branch: branch, + pullRequest: undefined, + codeSuggestions: [], + }; + } + + const pullRequest = await branch.getAssociatedPullRequest({ + expiryOverride: 1000 * 60 * 5, // 5 minutes + }); + + let codeSuggestions: Draft[] = []; + if (pullRequest != null) { + const results = await this.getCodeSuggestions(pullRequest, repository); + if (results.length) { + codeSuggestions = results; + } + } + + return { + branch: branch, + pullRequest: pullRequest, + codeSuggestions: codeSuggestions, + }; + } + + private async canAccessDrafts(): Promise { + if ((await this.getHasAccount()) === false) return false; + + return getContext('gitlens:gk:organization:drafts:enabled', false); + } + + private async getCodeSuggestions(pullRequest: PullRequest, repository: Repository): Promise { + if (!(await this.canAccessDrafts())) return []; + + const results = await this.container.drafts.getCodeSuggestions(pullRequest, repository); + + for (const draft of results) { + if (draft.author.avatarUri != null || draft.organizationId == null) continue; + + let email = draft.author.email; + if (email == null) { + const user = await this.container.organizations.getMemberById(draft.author.id, draft.organizationId); + email = user?.email; + } + if (email == null) continue; + + draft.author.avatarUri = getAvatarUri(email); + } + + return results; + } + + private async updateCodeSuggestions() { + if (this.mode !== 'wip' || this._context.wip?.pullRequest == null) { + return; + } + + const wip = this._context.wip; + const { pullRequest, repo } = wip; + + wip.codeSuggestions = await this.getCodeSuggestions(pullRequest!, repo); + + if (this._pendingContext == null) { + const success = await this.host.notify( + DidChangeWipStateNotification, + serialize({ + wip: serializeWipContext(wip), + }) as DidChangeWipStateParams, + ); + if (success) { + this._context.wip = wip; + return; + } + } + + this.updatePendingContext({ wip: wip }); + this.updateState(true); + } + + @debug({ args: false }) + private async updateRichState(current: Context, cancellation: CancellationToken): Promise { + const { commit } = current; + if (commit == null) return; + + const remote = await this.container.git.getBestRemoteWithIntegration(commit.repoPath); + + if (cancellation.isCancellationRequested) return; + + const [enrichedAutolinksResult, prResult] = + remote?.provider != null + ? await Promise.allSettled([ + configuration.get('views.commitDetails.autolinks.enabled') && + configuration.get('views.commitDetails.autolinks.enhanced') + ? pauseOnCancelOrTimeoutMapTuplePromise(commit.getEnrichedAutolinks(remote)) + : undefined, + configuration.get('views.commitDetails.pullRequests.enabled') + ? commit.getAssociatedPullRequest(remote) + : undefined, + ]) + : []; + + if (cancellation.isCancellationRequested) return; + + const enrichedAutolinks = getSettledValue(enrichedAutolinksResult)?.value; + const pr = getSettledValue(prResult); + + const formattedMessage = this.getFormattedMessage(commit, remote, enrichedAutolinks); + + this.updatePendingContext({ + richStateLoaded: true, + formattedMessage: formattedMessage, + autolinkedIssues: + enrichedAutolinks != null + ? [...filterMap(enrichedAutolinks.values(), ([issueOrPullRequest]) => issueOrPullRequest?.value)] + : undefined, + pullRequest: pr, + }); + + this.updateState(); + + // return { + // formattedMessage: formattedMessage, + // pullRequest: pr, + // autolinkedIssues: + // autolinkedIssuesAndPullRequests != null + // ? [...autolinkedIssuesAndPullRequests.values()].filter((i: T | undefined): i is T => i != null) + // : undefined, + // }; + } + + private _repositorySubscription: RepositorySubscription | undefined; + + private async updateCommit( + commitish: GitCommit | GitRevisionReference | undefined, + options?: { force?: boolean; pinned?: boolean; immediate?: boolean; skipStack?: boolean }, + ) { + // this.commits = [commit]; + if (!options?.force && this._context.commit?.sha === commitish?.ref) return; + + let commit: GitCommit | undefined; + if (isCommit(commitish)) { + commit = commitish; + } else if (commitish != null) { + if (commitish.refType === 'stash') { + const stash = await this.container.git.getStash(commitish.repoPath); + commit = stash?.commits.get(commitish.ref); + } else { + commit = await this.container.git.getCommit(commitish.repoPath, commitish.ref); + } + } + + let wip = this._pendingContext?.wip ?? this._context.wip; + + if (this._repositorySubscription != null) { + const { repo, subscription } = this._repositorySubscription; + if (commit?.repoPath !== repo.path) { + subscription.dispose(); + this._repositorySubscription = undefined; + wip = undefined; + } + } + + if (this._repositorySubscription == null && commit != null) { + const repo = await this.container.git.getOrOpenRepository(commit.repoPath); + if (repo != null) { + this._repositorySubscription = { repo: repo, subscription: this.subscribeToRepositoryWip(repo) }; + + if (this.mode === 'wip') { + void this.updateWipState(repo); + } else { + wip = undefined; + } + } + } + + this.updatePendingContext( + { + commit: commit, + richStateLoaded: + Boolean(commit?.isUncommitted) || + (commit != null + ? !getContext('gitlens:repos:withHostingIntegrationsConnected')?.includes(commit.repoPath) + : !getContext('gitlens:repos:withHostingIntegrationsConnected')), + formattedMessage: undefined, + autolinkedIssues: undefined, + pullRequest: undefined, + wip: wip, + }, + options?.force, + ); + + if (options?.pinned != null) { + this.updatePinned(options?.pinned); + } + + if (this.isLineTrackerSuspended) { + this.ensureTrackers(); + } + + if (commit != null) { + if (!options?.skipStack) { + this._commitStack.add(getReferenceFromRevision(commit)); + } + + this.updateNavigation(); + } + this.updateState(options?.immediate ?? true); + this.updateTitle(); + } + + private subscribeToRepositoryWip(repo: Repository) { + return Disposable.from( + repo.watchFileSystem(1000), + repo.onDidChangeFileSystem(() => this.onWipChanged(repo)), + repo.onDidChange(e => { + if (e.changed(RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) { + this.onWipChanged(repo); + } + }), + ); + } + + private onWipChanged(repository: Repository) { + void this.updateWipState(repository); + } + + private async getWipChange(repository: Repository): Promise { + const status = await this.container.git.getStatusForRepo(repository.path); + if (status == null) return undefined; + + const files: GitFileChangeShape[] = []; + for (const file of status.files) { + const change = { + repoPath: file.repoPath, + path: file.path, + status: file.status, + originalPath: file.originalPath, + staged: file.staged, + }; + + files.push(change); + if (file.staged && file.wip) { + files.push({ ...change, staged: false }); + } + } + + return { + repository: { + name: repository.name, + path: repository.path, + uri: repository.uri.toString(), + }, + branchName: status.branch, + files: files, + }; + } + + private updatePinned(pinned: boolean, immediate?: boolean) { + if (pinned === this._context.pinned) return; + + this._pinned = pinned; + this.ensureTrackers(); + + this.updatePendingContext({ pinned: pinned }); + this.updateState(immediate); + } + + private updatePreferences(preferences: UpdateablePreferences) { + if ( + this._context.preferences?.autolinksExpanded === preferences.autolinksExpanded && + this._context.preferences?.pullRequestExpanded === preferences.pullRequestExpanded && + this._context.preferences?.files?.compact === preferences.files?.compact && + this._context.preferences?.files?.icon === preferences.files?.icon && + this._context.preferences?.files?.layout === preferences.files?.layout && + this._context.preferences?.files?.threshold === preferences.files?.threshold + ) { + return; + } + + const changes: Preferences = { + ...this._context.preferences, + ...this._pendingContext?.preferences, + }; + + if ( + preferences.autolinksExpanded != null && + this._context.preferences?.autolinksExpanded !== preferences.autolinksExpanded + ) { + void this.container.storage.storeWorkspace( + 'views:commitDetails:autolinksExpanded', + preferences.autolinksExpanded, + ); + + changes.autolinksExpanded = preferences.autolinksExpanded; + } + + if ( + preferences.pullRequestExpanded != null && + this._context.preferences?.pullRequestExpanded !== preferences.pullRequestExpanded + ) { + void this.container.storage.storeWorkspace( + 'views:commitDetails:pullRequestExpanded', + preferences.pullRequestExpanded, + ); + + changes.pullRequestExpanded = preferences.pullRequestExpanded; + } + + if (preferences.files != null) { + if (this._context.preferences?.files?.compact !== preferences.files?.compact) { + void configuration.updateEffective('views.commitDetails.files.compact', preferences.files?.compact); + } + if (this._context.preferences?.files?.icon !== preferences.files?.icon) { + void configuration.updateEffective('views.commitDetails.files.icon', preferences.files?.icon); + } + if (this._context.preferences?.files?.layout !== preferences.files?.layout) { + void configuration.updateEffective('views.commitDetails.files.layout', preferences.files?.layout); + } + if (this._context.preferences?.files?.threshold !== preferences.files?.threshold) { + void configuration.updateEffective('views.commitDetails.files.threshold', preferences.files?.threshold); + } + + changes.files = preferences.files; + } + + this.updatePendingContext({ preferences: changes }); + this.updateState(); + } + + private updatePendingContext(context: Partial, force: boolean = false): boolean { + const [changed, pending] = updatePendingContext(this._context, this._pendingContext, context, force); + if (changed) { + this._pendingContext = pending; + } + + return changed; + } + + private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; + + private updateState(immediate: boolean = false) { + if (immediate) { + void this.notifyDidChangeState(); + return; + } + + if (this._notifyDidChangeStateDebounced == null) { + this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); + } + + this._notifyDidChangeStateDebounced(); + } + + private updateNavigation() { + let sha = this._commitStack.get(this._commitStack.position - 1)?.ref; + if (sha != null) { + sha = shortenRevision(sha); + } + this.updatePendingContext({ + navigationStack: { + count: this._commitStack.count, + position: this._commitStack.position, + hint: sha, + }, + }); + this.updateState(); + } + + private async setInReview(inReview: boolean, source?: ShowWipArgs['source']) { + if (this.inReview === inReview) return; + + if (this._pendingContext == null) { + const success = await this.host.notify(DidChangeDraftStateNotification, { inReview: inReview }); + if (success) { + this._context.inReview = inReview; + } + } + + this.updatePendingContext({ inReview: inReview }); + this.updateState(true); + + if (inReview) { + void this.trackOpenReviewMode(source); + } + } + + private async notifyDidChangeState(force: boolean = false) { + const scope = getLogScope(); + + this._notifyDidChangeStateDebounced?.cancel(); + if (!force && this._pendingContext == null) return false; + + let context: Context; + if (this._pendingContext != null) { + context = { ...this._context, ...this._pendingContext }; + this._context = context; + this._pendingContext = undefined; + } else { + context = this._context; + } + + return window.withProgress({ location: { viewId: this.host.id } }, async () => { + try { + await this.host.notify(DidChangeNotification, { + state: await this.getState(context), + }); + } catch (ex) { + Logger.error(ex, scope); + debugger; + } + }); + } + + private getBestCommitOrStash(): GitCommit | GitRevisionReference | undefined { + if (this._pinned) return undefined; + + let commit; + + if (this.options.attachedTo !== 'graph' && window.activeTextEditor != null) { + const { lineTracker } = this.container; + const line = lineTracker.selections?.[0].active; + if (line != null) { + commit = lineTracker.getState(line)?.commit; + } + } else { + commit = this._pendingContext?.commit; + if (commit == null) { + const args = this.container.events.getCachedEventArgs('commit:selected'); + commit = args?.commit; + } + } + + return commit; + } + + private async getDetailsModel(commit: GitCommit, formattedMessage?: string): Promise { + const [commitResult, avatarUriResult, remoteResult] = await Promise.allSettled([ + !commit.hasFullDetails() ? commit.ensureFullDetails().then(() => commit) : commit, + commit.author.getAvatarUri(commit, { size: 32 }), + this.container.git.getBestRemoteWithIntegration(commit.repoPath, { includeDisconnected: true }), + ]); + + commit = getSettledValue(commitResult, commit); + const avatarUri = getSettledValue(avatarUriResult); + const remote = getSettledValue(remoteResult); + + if (formattedMessage == null) { + formattedMessage = this.getFormattedMessage(commit, remote); + } + + const autolinks = + commit.message != null ? await this.container.autolinks.getAutolinks(commit.message, remote) : undefined; + + return { + repoPath: commit.repoPath, + sha: commit.sha, + shortSha: commit.shortSha, + author: { ...commit.author, avatar: avatarUri?.toString(true) }, + // committer: { ...commit.committer, avatar: committerAvatar?.toString(true) }, + message: formattedMessage, + parents: commit.parents, + stashNumber: commit.refType === 'stash' ? commit.number : undefined, + files: commit.files, + stats: commit.stats, + autolinks: autolinks != null ? [...map(autolinks.values(), serializeAutolink)] : undefined, + }; + } + + private getFormattedMessage( + commit: GitCommit, + remote: GitRemote | undefined, + enrichedAutolinks?: Map, + ) { + let message = CommitFormatter.fromTemplate(`\${message}`, commit); + const index = message.indexOf('\n'); + if (index !== -1) { + message = `${message.substring(0, index)}${messageHeadlineSplitterToken}${message.substring(index + 1)}`; + } + + if (!configuration.get('views.commitDetails.autolinks.enabled')) return message; + + return this.container.autolinks.linkify( + message, + 'html', + remote != null ? [remote] : undefined, + enrichedAutolinks, + ); + } + + private async getFileCommitFromParams( + params: ExecuteFileActionParams, + ): Promise<[commit: GitCommit, file: GitFileChange] | undefined> { + let commit: GitCommit | undefined; + if (this.mode === 'wip') { + const uri = this._context.wip?.changes?.repository.uri; + if (uri == null) return; + + commit = await this.container.git.getCommit(Uri.parse(uri), uncommitted); + } else { + commit = this._context.commit; + } + + commit = await commit?.getCommitForFile(params.path, params.staged); + return commit != null ? [commit, commit.file!] : undefined; + } + + private showCommitPicker() { + void executeGitCommand({ + command: 'log', + state: { + reference: 'HEAD', + repo: this._context.commit?.repoPath, + openPickInView: true, + }, + }); + } + + private showCommitSearch() { + void executeGitCommand({ command: 'search', state: { openPickInView: true } }); + } + + private showCommitActions() { + if (this._context.commit == null || this._context.commit.isUncommitted) return; + + void showDetailsQuickPick(this._context.commit); + } + + private async showFileActions(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file] = result; + + this.suspendLineTracker(); + void showDetailsQuickPick(commit, file); + } + + private switchMode(params: SwitchModeParams) { + if (this.mode === params.mode) return; + + let repo; + if (params.mode === 'wip') { + let { repoPath } = params; + if (repoPath == null) { + repo = this.container.git.getBestRepositoryOrFirst(); + if (repo == null) return; + + repoPath = repo.path; + } else { + repo = this.container.git.getRepository(repoPath)!; + } + } + + void this.setMode(params.mode, repo); + } + + private async openFileComparisonWithWorking(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file] = result; + + this.suspendLineTracker(); + void openChangesWithWorking(file, commit, { + preserveFocus: true, + preview: true, + ...this.getShowOptions(params), + }); + } + + private async openFileComparisonWithPrevious(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file] = result; + + this.suspendLineTracker(); + void openChanges(file, commit, { + preserveFocus: true, + preview: true, + ...this.getShowOptions(params), + }); + this.container.events.fire('file:selected', { uri: file.uri }, { source: this.host.id }); + } + + private async openFile(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file] = result; + + this.suspendLineTracker(); + void openFile(file, commit, { + preserveFocus: true, + preview: true, + ...this.getShowOptions(params), + }); + } + + private async openFileOnRemote(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file] = result; + + void openFileOnRemote(file, commit); + } + + private async stageFile(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file] = result; + + await this.container.git.stageFile(commit.repoPath, file.path); + } + + private async unstageFile(params: ExecuteFileActionParams) { + const result = await this.getFileCommitFromParams(params); + if (result == null) return; + + const [commit, file] = result; + + await this.container.git.unstageFile(commit.repoPath, file.path); + } + + private getShowOptions(params: ExecuteFileActionParams): TextDocumentShowOptions | undefined { + return params.showOptions; + + // return getContext('gitlens:webview:graph:active') || getContext('gitlens:webview:rebase:active') + // ? { ...params.showOptions, viewColumn: ViewColumn.Beside } : params.showOptions; + } +} + +// async function summaryModel(commit: GitCommit): Promise { +// return { +// sha: commit.sha, +// shortSha: commit.shortSha, +// summary: commit.summary, +// message: commit.message, +// author: commit.author, +// avatar: (await commit.getAvatarUri())?.toString(true), +// }; +// } + +function serializeBranch(branch?: GitBranch): GitBranchShape | undefined { + if (branch == null) return undefined; + + return { + name: branch.name, + repoPath: branch.repoPath, + upstream: branch.upstream, + tracking: { + ahead: branch.state.ahead, + behind: branch.state.behind, + }, + }; +} + +function serializeWipContext(wip?: WipContext): Wip | undefined { + if (wip == null) return undefined; + + return { + changes: wip.changes, + repositoryCount: wip.repositoryCount, + branch: serializeBranch(wip.branch), + repo: { + uri: wip.repo.uri.toString(), + name: wip.repo.name, + path: wip.repo.path, + // type: wip.repo.provider.name, + }, + pullRequest: wip.pullRequest != null ? serializePullRequest(wip.pullRequest) : undefined, + codeSuggestions: wip.codeSuggestions?.map(draft => serializeDraft(draft)), + }; +} + +function serializeDraft(draft: Draft): Serialized { + // Inspect doesn't need changesets for the draft list + return serialize({ + ...draft, + changesets: undefined, + }); +} diff --git a/src/webviews/commitDetails/commitDetailsWebviewView.ts b/src/webviews/commitDetails/commitDetailsWebviewView.ts deleted file mode 100644 index e02a31fc13985..0000000000000 --- a/src/webviews/commitDetails/commitDetailsWebviewView.ts +++ /dev/null @@ -1,879 +0,0 @@ -import type { CancellationToken, ConfigurationChangeEvent, TextDocumentShowOptions } from 'vscode'; -import { CancellationTokenSource, Disposable, Uri, ViewColumn, window } from 'vscode'; -import { serializeAutolink } from '../../annotations/autolinks'; -import type { CopyShaToClipboardCommandArgs } from '../../commands'; -import { configuration } from '../../configuration'; -import { Commands, ContextKeys, CoreCommands } from '../../constants'; -import type { Container } from '../../container'; -import { getContext } from '../../context'; -import type { CommitSelectedEvent } from '../../eventBus'; -import { executeGitCommand } from '../../git/actions'; -import { - openChanges, - openChangesWithWorking, - openFile, - openFileOnRemote, - showDetailsQuickPick, -} from '../../git/actions/commit'; -import { CommitFormatter } from '../../git/formatters/commitFormatter'; -import type { GitCommit } from '../../git/models/commit'; -import { isCommit } from '../../git/models/commit'; -import type { GitFileChange } from '../../git/models/file'; -import { GitFile } from '../../git/models/file'; -import type { IssueOrPullRequest } from '../../git/models/issue'; -import { serializeIssueOrPullRequest } from '../../git/models/issue'; -import type { PullRequest } from '../../git/models/pullRequest'; -import { serializePullRequest } from '../../git/models/pullRequest'; -import type { GitRevisionReference } from '../../git/models/reference'; -import { GitReference } from '../../git/models/reference'; -import type { GitRemote } from '../../git/models/remote'; -import { Logger } from '../../logger'; -import { getLogScope } from '../../logScope'; -import type { ShowInCommitGraphCommandArgs } from '../../plus/webviews/graph/graphWebview'; -import { executeCommand, executeCoreCommand } from '../../system/command'; -import type { DateTimeFormat } from '../../system/date'; -import { debug, log } from '../../system/decorators/log'; -import type { Deferrable } from '../../system/function'; -import { debounce } from '../../system/function'; -import { map, union } from '../../system/iterable'; -import type { PromiseCancelledError } from '../../system/promise'; -import { getSettledValue } from '../../system/promise'; -import type { Serialized } from '../../system/serialize'; -import { serialize } from '../../system/serialize'; -import type { LinesChangeEvent } from '../../trackers/lineTracker'; -import { CommitFileNode } from '../../views/nodes/commitFileNode'; -import { CommitNode } from '../../views/nodes/commitNode'; -import { FileRevisionAsCommitNode } from '../../views/nodes/fileRevisionAsCommitNode'; -import { StashFileNode } from '../../views/nodes/stashFileNode'; -import { StashNode } from '../../views/nodes/stashNode'; -import type { IpcMessage } from '../protocol'; -import { onIpc } from '../protocol'; -import { WebviewViewBase } from '../webviewViewBase'; -import type { CommitDetails, FileActionParams, Preferences, State } from './protocol'; -import { - AutolinkSettingsCommandType, - CommitActionsCommandType, - DidChangeNotificationType, - FileActionsCommandType, - messageHeadlineSplitterToken, - OpenFileCommandType, - OpenFileComparePreviousCommandType, - OpenFileCompareWorkingCommandType, - OpenFileOnRemoteCommandType, - PickCommitCommandType, - PinCommitCommandType, - PreferencesCommandType, - SearchCommitCommandType, -} from './protocol'; - -interface Context { - pinned: boolean; - commit: GitCommit | undefined; - preferences: Preferences | undefined; - richStateLoaded: boolean; - formattedMessage: string | undefined; - autolinkedIssues: IssueOrPullRequest[] | undefined; - pullRequest: PullRequest | undefined; - - // commits: GitCommit[] | undefined; - dateFormat: DateTimeFormat | string; - // indent: number; - indentGuides: 'none' | 'onHover' | 'always'; -} - -export class CommitDetailsWebviewView extends WebviewViewBase> { - private _bootstraping = true; - /** The context the webview has */ - private _context: Context; - /** The context the webview should have */ - private _pendingContext: Partial | undefined; - - private _pinned = false; - - constructor(container: Container) { - super( - container, - 'gitlens.views.commitDetails', - 'commitDetails.html', - 'Commit Details', - `${ContextKeys.WebviewViewPrefix}commitDetails`, - 'commitDetailsView', - ); - - this._context = { - pinned: false, - commit: undefined, - preferences: undefined, - richStateLoaded: false, - formattedMessage: undefined, - autolinkedIssues: undefined, - pullRequest: undefined, - dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma', - // indent: configuration.getAny('workbench.tree.indent') ?? 8, - indentGuides: configuration.getAny('workbench.tree.renderIndentGuides') ?? 'onHover', - }; - - this.disposables.push( - configuration.onDidChange(this.onConfigurationChanged, this), - configuration.onDidChangeAny(this.onAnyConfigurationChanged, this), - this.container.events.on('commit:selected', debounce(this.onCommitSelected, 250), this), - ); - } - - onCommitSelected(e: CommitSelectedEvent) { - if (e.data == null) return; - - void this.show(e.data); - } - - @log({ - args: { - 0: o => - `{"commit":${o?.commit?.ref},"pin":${o?.pin},"preserveFocus":${o?.preserveFocus},"preserveVisibility":${o?.preserveVisibility}}`, - }, - }) - override async show(options?: { - commit?: GitRevisionReference | GitCommit; - pin?: boolean; - preserveFocus?: boolean | undefined; - preserveVisibility?: boolean | undefined; - }): Promise { - if (this._pinned && !options?.pin && this.visible) return; - - if (options != null) { - let commit; - let pin; - ({ commit, pin, ...options } = options); - if (commit == null) { - commit = this.getBestCommitOrStash(); - } - if (commit != null && !this._context.commit?.ref.startsWith(commit.ref)) { - if (!isCommit(commit)) { - if (commit.refType === 'stash') { - const stash = await this.container.git.getStash(commit.repoPath); - commit = stash?.commits.get(commit.ref); - } else { - commit = await this.container.git.getCommit(commit.repoPath, commit.ref); - } - } - this.updateCommit(commit, { pinned: pin ?? false }); - } - } - - if (options?.preserveVisibility) return; - - return super.show(options); - } - - protected override async includeBootstrap(): Promise> { - this._bootstraping = true; - - this._context = { ...this._context, ...this._pendingContext }; - this._pendingContext = undefined; - - return this.getState(this._context); - } - - protected override onInitializing(): Disposable[] | undefined { - if (this._context.preferences == null) { - this.updatePendingContext({ - preferences: { - autolinksExpanded: this.container.storage.getWorkspace('views:commitDetails:autolinksExpanded'), - avatars: configuration.get('views.commitDetails.avatars'), - dismissed: this.container.storage.get('views:commitDetails:dismissed'), - files: configuration.get('views.commitDetails.files'), - }, - }); - } - - if (this._context.commit == null) { - const commit = this.getBestCommitOrStash(); - if (commit != null) { - this.updateCommit(commit, { immediate: false }); - } - } - - return undefined; - } - - private _visibilityDisposable: Disposable | undefined; - protected override onVisibilityChanged(visible: boolean) { - this.ensureTrackers(); - if (!visible) return; - - // Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data - if (this._bootstraping) { - this._bootstraping = false; - - if (this._pendingContext == null) return; - } - - this.updateState(true); - } - - private onAnyConfigurationChanged(e: ConfigurationChangeEvent) { - // if (e.affectsConfiguration('workbench.tree.indent')) { - // this.updatePendingContext({ indent: configuration.getAny('workbench.tree.indent') ?? 8 }); - // this.updateState(); - // } - - if (e.affectsConfiguration('workbench.tree.renderIndentGuides')) { - this.updatePendingContext({ - indentGuides: configuration.getAny('workbench.tree.renderIndentGuides') ?? 'onHover', - }); - this.updateState(); - } - } - - private onConfigurationChanged(e: ConfigurationChangeEvent) { - if (configuration.changed(e, 'defaultDateFormat')) { - this.updatePendingContext({ dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma' }); - this.updateState(); - } - - if (configuration.changed(e, 'views.commitDetails')) { - if ( - configuration.changed(e, 'views.commitDetails.files') || - configuration.changed(e, 'views.commitDetails.avatars') - ) { - this.updatePendingContext({ - preferences: { - ...this._context.preferences, - ...this._pendingContext?.preferences, - avatars: configuration.get('views.commitDetails.avatars'), - files: configuration.get('views.commitDetails.files'), - }, - }); - } - - if ( - this._context.commit != null && - (configuration.changed(e, 'views.commitDetails.autolinks') || - configuration.changed(e, 'views.commitDetails.pullRequests')) - ) { - this.updateCommit(this._context.commit, { force: true }); - } - - this.updateState(); - } - } - - private ensureTrackers(): void { - this._visibilityDisposable?.dispose(); - this._visibilityDisposable = undefined; - - if (this._pinned || !this.visible) return; - - const { lineTracker } = this.container; - this._visibilityDisposable = Disposable.from( - lineTracker.subscribe(this, lineTracker.onDidChangeActiveLines(this.onActiveLinesChanged, this)), - ); - - const commit = this._pendingContext?.commit ?? this.getBestCommitOrStash(); - this.updateCommit(commit, { immediate: false }); - } - - protected override onReady(): void { - this.updateState(false); - } - - protected override onMessageReceived(e: IpcMessage) { - switch (e.method) { - case OpenFileOnRemoteCommandType.method: - onIpc(OpenFileOnRemoteCommandType, e, params => void this.openFileOnRemote(params)); - break; - case OpenFileCommandType.method: - onIpc(OpenFileCommandType, e, params => void this.openFile(params)); - break; - case OpenFileCompareWorkingCommandType.method: - onIpc(OpenFileCompareWorkingCommandType, e, params => void this.openFileComparisonWithWorking(params)); - break; - case OpenFileComparePreviousCommandType.method: - onIpc( - OpenFileComparePreviousCommandType, - e, - params => void this.openFileComparisonWithPrevious(params), - ); - break; - case FileActionsCommandType.method: - onIpc(FileActionsCommandType, e, params => void this.showFileActions(params)); - break; - case CommitActionsCommandType.method: - onIpc(CommitActionsCommandType, e, params => { - switch (params.action) { - case 'graph': - if (this._context.commit == null) return; - - void executeCommand(Commands.ShowInCommitGraph, { - ref: GitReference.fromRevision(this._context.commit), - }); - break; - case 'more': - this.showCommitActions(); - break; - case 'scm': - void executeCoreCommand(CoreCommands.ShowSCM); - break; - case 'sha': - if (params.alt) { - this.showCommitPicker(); - } else if (this._context.commit != null) { - void executeCommand(Commands.CopyShaToClipboard, { - sha: this._context.commit.sha, - }); - } - break; - } - }); - break; - case PickCommitCommandType.method: - onIpc(PickCommitCommandType, e, _params => this.showCommitPicker()); - break; - case SearchCommitCommandType.method: - onIpc(SearchCommitCommandType, e, _params => this.showCommitSearch()); - break; - case AutolinkSettingsCommandType.method: - onIpc(AutolinkSettingsCommandType, e, _params => this.showAutolinkSettings()); - break; - case PinCommitCommandType.method: - onIpc(PinCommitCommandType, e, params => this.updatePinned(params.pin ?? false, true)); - break; - case PreferencesCommandType.method: - onIpc(PreferencesCommandType, e, params => this.updatePreferences(params)); - break; - } - } - - private onActiveLinesChanged(e: LinesChangeEvent) { - if (e.pending) return; - - let commit; - if (e.editor == null) { - if (getContext('gitlens:webview:graph:active') || getContext('gitlens:webview:rebaseEditor:active')) { - commit = this._pendingContext?.commit ?? this._context.commit; - if (commit == null) return; - } - } - - if (commit == null) { - commit = - e.selections != null ? this.container.lineTracker.getState(e.selections[0].active)?.commit : undefined; - } - this.updateCommit(commit); - } - - private _cancellationTokenSource: CancellationTokenSource | undefined = undefined; - - @debug({ args: false }) - protected async getState(current: Context): Promise> { - if (this._cancellationTokenSource != null) { - this._cancellationTokenSource.cancel(); - this._cancellationTokenSource.dispose(); - this._cancellationTokenSource = undefined; - } - - let details; - if (current.commit != null) { - details = await this.getDetailsModel(current.commit, current.formattedMessage); - - if (!current.richStateLoaded) { - this._cancellationTokenSource = new CancellationTokenSource(); - - const cancellation = this._cancellationTokenSource.token; - setTimeout(() => { - if (cancellation.isCancellationRequested) return; - void this.updateRichState(current, cancellation); - }, 100); - } - } - - // const commitChoices = await Promise.all(this.commits.map(async commit => summaryModel(commit))); - - const state = serialize({ - pinned: current.pinned, - includeRichContent: current.richStateLoaded, - // commits: commitChoices, - preferences: current.preferences, - selected: details, - autolinkedIssues: current.autolinkedIssues?.map(serializeIssueOrPullRequest), - pullRequest: current.pullRequest != null ? serializePullRequest(current.pullRequest) : undefined, - dateFormat: current.dateFormat, - // indent: current.indent, - indentGuides: current.indentGuides, - }); - return state; - } - - private async updateRichState(current: Context, cancellation: CancellationToken): Promise { - const { commit } = current; - if (commit == null) return; - - const remote = await this.container.git.getBestRemoteWithRichProvider(commit.repoPath); - - if (cancellation.isCancellationRequested) return; - - let autolinkedIssuesOrPullRequests; - let pr; - - if (remote?.provider != null) { - const [autolinkedIssuesOrPullRequestsResult, prResult] = await Promise.allSettled([ - configuration.get('views.commitDetails.autolinks.enabled') && - configuration.get('views.commitDetails.autolinks.enhanced') - ? this.container.autolinks.getLinkedIssuesAndPullRequests(commit.message ?? commit.summary, remote) - : undefined, - configuration.get('views.commitDetails.pullRequests.enabled') - ? commit.getAssociatedPullRequest({ remote: remote }) - : undefined, - ]); - - if (cancellation.isCancellationRequested) return; - - autolinkedIssuesOrPullRequests = getSettledValue(autolinkedIssuesOrPullRequestsResult); - pr = getSettledValue(prResult); - } - - const formattedMessage = this.getFormattedMessage(commit, remote, autolinkedIssuesOrPullRequests); - - // Remove possible duplicate pull request - if (pr != null) { - autolinkedIssuesOrPullRequests?.delete(pr.id); - } - - this.updatePendingContext({ - richStateLoaded: true, - formattedMessage: formattedMessage, - autolinkedIssues: - autolinkedIssuesOrPullRequests != null ? [...autolinkedIssuesOrPullRequests.values()] : undefined, - pullRequest: pr, - }); - - this.updateState(); - - // return { - // formattedMessage: formattedMessage, - // pullRequest: pr, - // autolinkedIssues: - // autolinkedIssuesOrPullRequests != null - // ? [...autolinkedIssuesOrPullRequests.values()].filter((i: T | undefined): i is T => i != null) - // : undefined, - // }; - } - - private _commitDisposable: Disposable | undefined; - - private updateCommit( - commit: GitCommit | undefined, - options?: { force?: boolean; pinned?: boolean; immediate?: boolean }, - ) { - // this.commits = [commit]; - if (!options?.force && this._context.commit?.sha === commit?.sha) return; - - this._commitDisposable?.dispose(); - - if (commit?.isUncommitted) { - const repository = this.container.git.getRepository(commit.repoPath)!; - this._commitDisposable = Disposable.from( - repository.startWatchingFileSystem(), - repository.onDidChangeFileSystem(() => { - // this.updatePendingContext({ commit: undefined }); - this.updatePendingContext({ commit: commit }, true); - this.updateState(); - }), - ); - } - - this.updatePendingContext( - { - commit: commit, - richStateLoaded: Boolean(commit?.isUncommitted) || !getContext(ContextKeys.HasConnectedRemotes), - formattedMessage: undefined, - autolinkedIssues: undefined, - pullRequest: undefined, - }, - options?.force, - ); - - if (options?.pinned != null) { - this.updatePinned(options?.pinned); - } - - this.updateState(options?.immediate ?? true); - } - - private updatePinned(pinned: boolean, immediate?: boolean) { - if (pinned === this._context.pinned) return; - - this._pinned = pinned; - this.ensureTrackers(); - - this.updatePendingContext({ pinned: pinned }); - this.updateState(immediate); - } - - private updatePreferences(preferences: Preferences) { - if ( - this._context.preferences?.autolinksExpanded === preferences.autolinksExpanded && - this._context.preferences?.avatars === preferences.avatars && - this._context.preferences?.dismissed === preferences.dismissed && - this._context.preferences?.files === preferences.files && - this._context.preferences?.files?.compact === preferences.files?.compact && - this._context.preferences?.files?.layout === preferences.files?.layout && - this._context.preferences?.files?.threshold === preferences.files?.threshold - ) { - return; - } - - const changes: Preferences = { - ...this._context.preferences, - ...this._pendingContext?.preferences, - }; - - if ( - preferences.autolinksExpanded != null && - this._context.preferences?.autolinksExpanded !== preferences.autolinksExpanded - ) { - void this.container.storage.storeWorkspace( - 'views:commitDetails:autolinksExpanded', - preferences.autolinksExpanded, - ); - - changes.autolinksExpanded = preferences.autolinksExpanded; - } - - if (preferences.avatars != null && this._context.preferences?.avatars !== preferences.avatars) { - void configuration.updateEffective('views.commitDetails.avatars', preferences.avatars); - - changes.avatars = preferences.avatars; - } - - if (preferences.dismissed != null && this._context.preferences?.dismissed !== preferences.dismissed) { - void this.container.storage.store('views:commitDetails:dismissed', preferences.dismissed); - - changes.dismissed = preferences.dismissed; - } - - if (preferences.files != null && this._context.preferences?.files !== preferences.files) { - if (this._context.preferences?.files?.compact !== preferences.files?.compact) { - void configuration.updateEffective('views.commitDetails.files.compact', preferences.files?.compact); - } - if (this._context.preferences?.files?.layout !== preferences.files?.layout) { - void configuration.updateEffective('views.commitDetails.files.layout', preferences.files?.layout); - } - if (this._context.preferences?.files?.threshold !== preferences.files?.threshold) { - void configuration.updateEffective('views.commitDetails.files.threshold', preferences.files?.threshold); - } - - changes.files = preferences.files; - } - - this.updatePendingContext({ preferences: changes }); - } - - private updatePendingContext(context: Partial, force: boolean = false): boolean { - let changed = false; - for (const [key, value] of Object.entries(context)) { - const current = (this._context as unknown as Record)[key]; - if ( - !force && - (current instanceof Uri || value instanceof Uri) && - (current as any)?.toString() === value?.toString() - ) { - continue; - } - - if (!force && current === value) { - if ( - (value !== undefined || key in this._context) && - (this._pendingContext == null || !(key in this._pendingContext)) - ) { - continue; - } - } - - if (this._pendingContext == null) { - this._pendingContext = {}; - } - - (this._pendingContext as Record)[key] = value; - changed = true; - } - - return changed; - } - - private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined; - - private updateState(immediate: boolean = false) { - if (!this.isReady || !this.visible) return; - - if (immediate) { - void this.notifyDidChangeState(); - return; - } - - if (this._notifyDidChangeStateDebounced == null) { - this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500); - } - - this._notifyDidChangeStateDebounced(); - } - - private async notifyDidChangeState() { - if (!this.isReady || !this.visible) return false; - - const scope = getLogScope(); - - this._notifyDidChangeStateDebounced?.cancel(); - if (this._pendingContext == null) return false; - - const context = { ...this._context, ...this._pendingContext }; - - return window.withProgress({ location: { viewId: this.id } }, async () => { - try { - const success = await this.notify(DidChangeNotificationType, { - state: await this.getState(context), - }); - if (success) { - this._context = context; - this._pendingContext = undefined; - } - } catch (ex) { - Logger.error(scope, ex); - debugger; - } - }); - } - - // private async updateRichState() { - // if (this.commit == null) return; - - // const richState = await this.getRichState(this.commit); - // if (richState != null) { - // void this.notify(DidChangeRichStateNotificationType, richState); - // } - // } - - private getBestCommitOrStash(): GitCommit | undefined { - if (this._pinned) return undefined; - - let commit; - - if (window.activeTextEditor != null) { - const { lineTracker } = this.container; - const line = lineTracker.selections?.[0].active; - if (line != null) { - commit = lineTracker.getState(line)?.commit; - if (commit != null) return commit; - } - } else if (getContext('gitlens:webview:graph:active') || getContext('gitlens:webview:rebaseEditor:active')) { - commit = this._pendingContext?.commit ?? this._context.commit; - if (commit != null) return commit; - } - - const { commitsView } = this.container; - let node = commitsView.activeSelection; - if ( - node != null && - (node instanceof CommitNode || node instanceof FileRevisionAsCommitNode || node instanceof CommitFileNode) - ) { - commit = node.commit; - if (commit != null) return commit; - } - - const { stashesView } = this.container; - node = stashesView.activeSelection; - if (node != null && (node instanceof StashNode || node instanceof StashFileNode)) { - commit = node.commit; - if (commit != null) return commit; - } - - return commit; - } - - private async getDetailsModel(commit: GitCommit, formattedMessage?: string): Promise { - const [commitResult, avatarUriResult, remoteResult] = await Promise.allSettled([ - !commit.hasFullDetails() ? commit.ensureFullDetails().then(() => commit) : commit, - commit.author.getAvatarUri(commit, { size: 32 }), - this.container.git.getBestRemoteWithRichProvider(commit.repoPath, { includeDisconnected: true }), - ]); - - commit = getSettledValue(commitResult, commit); - const avatarUri = getSettledValue(avatarUriResult); - const remote = getSettledValue(remoteResult); - - if (formattedMessage == null) { - formattedMessage = this.getFormattedMessage(commit, remote); - } - - let autolinks; - if (commit.message != null) { - const customAutolinks = this.container.autolinks.getAutolinks(commit.message); - if (remote != null) { - const providerAutolinks = this.container.autolinks.getAutolinks(commit.message, remote); - autolinks = new Map(union(providerAutolinks, customAutolinks)); - } else { - autolinks = customAutolinks; - } - } - - return { - sha: commit.sha, - shortSha: commit.shortSha, - isStash: commit.refType === 'stash', - message: formattedMessage, - author: { ...commit.author, avatar: avatarUri?.toString(true) }, - // committer: { ...commit.committer, avatar: committerAvatar?.toString(true) }, - files: commit.files?.map(({ status, repoPath, path, originalPath }) => { - const icon = GitFile.getStatusIcon(status); - return { - path: path, - originalPath: originalPath, - status: status, - repoPath: repoPath, - icon: { - dark: this._view!.webview.asWebviewUri( - Uri.joinPath(this.container.context.extensionUri, 'images', 'dark', icon), - ).toString(), - light: this._view!.webview.asWebviewUri( - Uri.joinPath(this.container.context.extensionUri, 'images', 'light', icon), - ).toString(), - }, - }; - }), - stats: commit.stats, - autolinks: autolinks != null ? [...map(autolinks.values(), serializeAutolink)] : undefined, - }; - } - - private getFormattedMessage( - commit: GitCommit, - remote: GitRemote | undefined, - issuesOrPullRequests?: Map, - ) { - let message = CommitFormatter.fromTemplate(`\${message}`, commit); - const index = message.indexOf('\n'); - if (index !== -1) { - message = `${message.substring(0, index)}${messageHeadlineSplitterToken}${message.substring(index + 1)}`; - } - - if (!configuration.get('views.commitDetails.autolinks.enabled')) return message; - - return this.container.autolinks.linkify( - message, - 'html', - remote != null ? [remote] : undefined, - issuesOrPullRequests, - ); - } - - private async getFileCommitFromParams( - params: FileActionParams, - ): Promise<[commit: GitCommit, file: GitFileChange] | undefined> { - const commit = await this._context.commit?.getCommitForFile(params.path); - return commit != null ? [commit, commit.file!] : undefined; - } - - private showAutolinkSettings() { - void executeCommand(Commands.ShowSettingsPageAndJumpToAutolinks); - } - - private showCommitSearch() { - void executeGitCommand({ command: 'search', state: { openPickInView: true } }); - } - - private showCommitPicker() { - void executeGitCommand({ - command: 'log', - state: { - reference: 'HEAD', - repo: this._context.commit?.repoPath, - openPickInView: true, - }, - }); - } - - private showCommitActions() { - if (this._context.commit == null || this._context.commit.isUncommitted) return; - - void showDetailsQuickPick(this._context.commit); - } - - private async showFileActions(params: FileActionParams) { - const result = await this.getFileCommitFromParams(params); - if (result == null) return; - - const [commit, file] = result; - - this.updatePinned(true, true); - void showDetailsQuickPick(commit, file); - } - - private async openFileComparisonWithWorking(params: FileActionParams) { - const result = await this.getFileCommitFromParams(params); - if (result == null) return; - - const [commit, file] = result; - - this.updatePinned(true, true); - void openChangesWithWorking(file.path, commit, { - preserveFocus: true, - preview: true, - ...this.getShowOptions(params), - }); - } - - private async openFileComparisonWithPrevious(params: FileActionParams) { - const result = await this.getFileCommitFromParams(params); - if (result == null) return; - - const [commit, file] = result; - - this.updatePinned(true, true); - void openChanges(file.path, commit, { - preserveFocus: true, - preview: true, - ...this.getShowOptions(params), - }); - this.container.events.fire('file:selected', { uri: file.uri }, { source: this.id }); - } - - private async openFile(params: FileActionParams) { - const result = await this.getFileCommitFromParams(params); - if (result == null) return; - - const [commit, file] = result; - - this.updatePinned(true, true); - void openFile(file.path, commit, { - preserveFocus: true, - preview: true, - ...this.getShowOptions(params), - }); - } - - private async openFileOnRemote(params: FileActionParams) { - const result = await this.getFileCommitFromParams(params); - if (result == null) return; - - const [commit, file] = result; - - void openFileOnRemote(file.path, commit); - } - - private getShowOptions(params: FileActionParams): TextDocumentShowOptions | undefined { - return getContext('gitlens:webview:graph:active') || getContext('gitlens:webview:rebaseEditor:active') - ? { ...params.showOptions, viewColumn: ViewColumn.Beside } - : params.showOptions; - } -} - -// async function summaryModel(commit: GitCommit): Promise { -// return { -// sha: commit.sha, -// shortSha: commit.shortSha, -// summary: commit.summary, -// message: commit.message, -// author: commit.author, -// avatar: (await commit.getAvatarUri())?.toString(true), -// }; -// } diff --git a/src/webviews/commitDetails/protocol.ts b/src/webviews/commitDetails/protocol.ts index 7d6137043c6bd..51fc528b90c95 100644 --- a/src/webviews/commitDetails/protocol.ts +++ b/src/webviews/commitDetails/protocol.ts @@ -1,106 +1,256 @@ import type { TextDocumentShowOptions } from 'vscode'; import type { Autolink } from '../../annotations/autolinks'; -import type { Config } from '../../config'; +import type { Config, DateStyle } from '../../config'; +import type { Sources } from '../../constants.telemetry'; import type { GitCommitIdentityShape, GitCommitStats } from '../../git/models/commit'; import type { GitFileChangeShape } from '../../git/models/file'; import type { IssueOrPullRequest } from '../../git/models/issue'; import type { PullRequestShape } from '../../git/models/pullRequest'; -import type { Serialized } from '../../system/serialize'; -import { IpcCommandType, IpcNotificationType } from '../protocol'; +import type { Repository } from '../../git/models/repository'; +import type { Draft, DraftVisibility } from '../../gk/models/drafts'; +import type { Change, DraftUserSelection } from '../../plus/webviews/patchDetails/protocol'; +import type { DateTimeFormat } from '../../system/date'; +import type { Serialized } from '../../system/vscode/serialize'; +import type { IpcScope, WebviewState } from '../protocol'; +import { IpcCommand, IpcNotification, IpcRequest } from '../protocol'; + +export const scope: IpcScope = 'commitDetails'; export const messageHeadlineSplitterToken = '\x00\n\x00'; export type FileShowOptions = TextDocumentShowOptions; -export type CommitSummary = { +export interface CommitSummary { sha: string; shortSha: string; // summary: string; message: string; author: GitCommitIdentityShape & { avatar: string | undefined }; // committer: GitCommitIdentityShape & { avatar: string | undefined }; - isStash: boolean; -}; + parents: string[]; + repoPath: string; + stashNumber?: string; +} -export type CommitDetails = CommitSummary & { +export interface CommitDetails extends CommitSummary { autolinks?: Autolink[]; - files?: (GitFileChangeShape & { icon: { dark: string; light: string } })[]; + files?: readonly GitFileChangeShape[]; stats?: GitCommitStats; -}; +} + +export interface Preferences { + autolinksExpanded: boolean; + pullRequestExpanded: boolean; + avatars: boolean; + dateFormat: DateTimeFormat | string; + dateStyle: DateStyle; + files: Config['views']['commitDetails']['files']; + indent: number | undefined; + indentGuides: 'none' | 'onHover' | 'always'; +} +export type UpdateablePreferences = Partial>; -export type Preferences = { - autolinksExpanded?: boolean; - avatars?: boolean; - dismissed?: string[]; - files?: Config['views']['commitDetails']['files']; -}; +export interface WipChange { + branchName: string; + repository: { name: string; path: string; uri: string }; + files: GitFileChangeShape[]; +} + +export type Mode = 'commit' | 'wip'; + +export interface GitBranchShape { + name: string; + repoPath: string; + upstream?: { name: string; missing: boolean }; + tracking?: { + ahead: number; + behind: number; + }; +} + +export interface Wip { + changes: WipChange | undefined; + repositoryCount: number; + branch?: GitBranchShape; + pullRequest?: PullRequestShape; + codeSuggestions?: Serialized[]; + repo: { + uri: string; + name: string; + path: string; + }; +} + +export interface DraftState { + inReview: boolean; +} + +export interface State extends WebviewState { + mode: Mode; -export type State = { pinned: boolean; - preferences?: Preferences; - // commits?: CommitSummary[]; + navigationStack: { + count: number; + position: number; + hint?: string; + }; + preferences: Preferences; + orgSettings: { + ai: boolean; + drafts: boolean; + }; includeRichContent?: boolean; - selected?: CommitDetails; + commit?: CommitDetails; autolinkedIssues?: IssueOrPullRequest[]; pullRequest?: PullRequestShape; - - dateFormat: string; - // indent: number; - indentGuides: 'none' | 'onHover' | 'always'; -}; + wip?: Wip; + inReview?: boolean; + hasConnectedJira: boolean; + hasAccount: boolean; +} export type ShowCommitDetailsViewCommandArgs = string[]; +export interface ShowWipArgs { + type: 'wip'; + inReview?: boolean; + repository?: Repository; + source: Sources; +} + // COMMANDS -export interface CommitActionsParams { +export interface SuggestChangesParams { + title: string; + description?: string; + visibility: DraftVisibility; + changesets: Record; + userSelections: DraftUserSelection[] | undefined; +} +export const SuggestChangesCommand = new IpcCommand(scope, 'commit/suggestChanges'); + +export interface ExecuteCommitActionsParams { action: 'graph' | 'more' | 'scm' | 'sha'; alt?: boolean; } -export const CommitActionsCommandType = new IpcCommandType('commit/actions'); - -export interface FileActionParams { - path: string; - repoPath: string; +export const ExecuteCommitActionCommand = new IpcCommand(scope, 'commit/actions/execute'); +export interface ExecuteFileActionParams extends GitFileChangeShape { showOptions?: TextDocumentShowOptions; } -export const FileActionsCommandType = new IpcCommandType('commit/file/actions'); -export const OpenFileCommandType = new IpcCommandType('commit/file/open'); -export const OpenFileOnRemoteCommandType = new IpcCommandType('commit/file/openOnRemote'); -export const OpenFileCompareWorkingCommandType = new IpcCommandType('commit/file/compareWorking'); -export const OpenFileComparePreviousCommandType = new IpcCommandType('commit/file/comparePrevious'); +export const ExecuteFileActionCommand = new IpcCommand(scope, 'file/actions/execute'); +export const OpenFileCommand = new IpcCommand(scope, 'file/open'); +export const OpenFileOnRemoteCommand = new IpcCommand(scope, 'file/openOnRemote'); +export const OpenFileCompareWorkingCommand = new IpcCommand(scope, 'file/compareWorking'); +export const OpenFileComparePreviousCommand = new IpcCommand(scope, 'file/comparePrevious'); -export const PickCommitCommandType = new IpcCommandType('commit/pickCommit'); -export const SearchCommitCommandType = new IpcCommandType('commit/searchCommit'); -export const AutolinkSettingsCommandType = new IpcCommandType('commit/autolinkSettings'); +export const StageFileCommand = new IpcCommand(scope, 'file/stage'); +export const UnstageFileCommand = new IpcCommand(scope, 'file/unstage'); + +export const PickCommitCommand = new IpcCommand(scope, 'pickCommit'); +export const SearchCommitCommand = new IpcCommand(scope, 'searchCommit'); + +export interface SwitchModeParams { + repoPath?: string; + mode: Mode; +} +export const SwitchModeCommand = new IpcCommand(scope, 'switchMode'); + +export const AutolinkSettingsCommand = new IpcCommand(scope, 'autolinkSettings'); export interface PinParams { pin: boolean; } -export const PinCommitCommandType = new IpcCommandType('commit/pin'); +export const PinCommand = new IpcCommand(scope, 'pin'); + +export interface NavigateParams { + direction: 'back' | 'forward'; +} +export const NavigateCommand = new IpcCommand(scope, 'navigate'); + +export type UpdatePreferenceParams = UpdateablePreferences; +export const UpdatePreferencesCommand = new IpcCommand(scope, 'preferences/update'); + +export interface CreatePatchFromWipParams { + changes: WipChange; + checked: boolean | 'staged'; +} +export const CreatePatchFromWipCommand = new IpcCommand(scope, 'wip/createPatch'); + +export interface ChangeReviewModeParams { + inReview: boolean; +} +export const ChangeReviewModeCommand = new IpcCommand(scope, 'wip/changeReviewMode'); -export interface PreferenceParams { - autolinksExpanded?: boolean; - avatars?: boolean; - dismissed?: string[]; - files?: Config['views']['commitDetails']['files']; +export interface ShowCodeSuggestionParams { + id: string; } -export const PreferencesCommandType = new IpcCommandType('commit/preferences'); +export const ShowCodeSuggestionCommand = new IpcCommand(scope, 'wip/showCodeSuggestion'); + +export const FetchCommand = new IpcCommand(scope, 'fetch'); +export const PublishCommand = new IpcCommand(scope, 'publish'); +export const PushCommand = new IpcCommand(scope, 'push'); +export const PullCommand = new IpcCommand(scope, 'pull'); +export const SwitchCommand = new IpcCommand(scope, 'switch'); + +export const OpenPullRequestChangesCommand = new IpcCommand(scope, 'openPullRequestChanges'); +export const OpenPullRequestComparisonCommand = new IpcCommand(scope, 'openPullRequestComparison'); +export const OpenPullRequestOnRemoteCommand = new IpcCommand(scope, 'openPullRequestOnRemote'); +export const OpenPullRequestDetailsCommand = new IpcCommand(scope, 'openPullRequestDetails'); + +// REQUESTS + +export type DidExplainParams = + | { + summary: string | undefined; + error?: undefined; + } + | { error: { message: string } }; +export const ExplainRequest = new IpcRequest(scope, 'explain'); + +export type DidGenerateParams = + | { + title: string | undefined; + description: string | undefined; + error?: undefined; + } + | { error: { message: string } }; +export const GenerateRequest = new IpcRequest(scope, 'generate'); // NOTIFICATIONS export interface DidChangeParams { state: Serialized; } -export const DidChangeNotificationType = new IpcNotificationType('commit/didChange'); +export const DidChangeNotification = new IpcNotification(scope, 'didChange', true); -export type DidChangeRichStateParams = { - formattedMessage?: string; - autolinkedIssues?: IssueOrPullRequest[]; - pullRequest?: PullRequestShape; -}; -export const DidChangeRichStateNotificationType = new IpcNotificationType( - 'commit/didChange/rich', +export type DidChangeWipStateParams = Pick, 'wip' | 'inReview'>; +export const DidChangeWipStateNotification = new IpcNotification(scope, 'didChange/wip'); + +export type DidChangeOrgSettings = Pick, 'orgSettings'>; +export const DidChangeOrgSettingsNotification = new IpcNotification( + scope, + 'org/settings/didChange', ); + +export interface DidChangeConnectedJiraParams { + hasConnectedJira: boolean; +} +export const DidChangeConnectedJiraNotification = new IpcNotification( + scope, + 'didChange/jira', +); + +export interface DidChangeHasAccountParams { + hasAccount: boolean; +} +export const DidChangeHasAccountNotification = new IpcNotification( + scope, + 'didChange/account', +); + +export interface DidChangeDraftStateParams { + inReview: boolean; +} +export const DidChangeDraftStateNotification = new IpcNotification(scope, 'didChange/patch'); diff --git a/src/webviews/commitDetails/registration.ts b/src/webviews/commitDetails/registration.ts new file mode 100644 index 0000000000000..aa5cc7c113aca --- /dev/null +++ b/src/webviews/commitDetails/registration.ts @@ -0,0 +1,50 @@ +import type { CommitSelectedEvent } from '../../eventBus'; +import type { Serialized } from '../../system/vscode/serialize'; +import type { WebviewsController } from '../webviewsController'; +import type { ShowWipArgs, State } from './protocol'; + +export type CommitDetailsWebviewShowingArgs = [Partial | ShowWipArgs]; + +export function registerCommitDetailsWebviewView(controller: WebviewsController) { + return controller.registerWebviewView, CommitDetailsWebviewShowingArgs>( + { + id: 'gitlens.views.commitDetails', + fileName: 'commitDetails.html', + title: 'Inspect', + contextKeyPrefix: `gitlens:webviewView:commitDetails`, + trackingFeature: 'commitDetailsView', + plusFeature: false, + webviewHostOptions: { + retainContextWhenHidden: false, + }, + }, + async (container, host) => { + const { CommitDetailsWebviewProvider } = await import( + /* webpackChunkName: "webview-commitDetails" */ './commitDetailsWebview' + ); + return new CommitDetailsWebviewProvider(container, host, { attachedTo: 'default' }); + }, + ); +} + +export function registerGraphDetailsWebviewView(controller: WebviewsController) { + return controller.registerWebviewView, CommitDetailsWebviewShowingArgs>( + { + id: 'gitlens.views.graphDetails', + fileName: 'commitDetails.html', + title: 'Commit Graph Inspect', + contextKeyPrefix: `gitlens:webviewView:graphDetails`, + trackingFeature: 'graphDetailsView', + plusFeature: false, + webviewHostOptions: { + retainContextWhenHidden: false, + }, + }, + async (container, host) => { + const { CommitDetailsWebviewProvider } = await import( + /* webpackChunkName: "webview-commitDetails" */ './commitDetailsWebview' + ); + return new CommitDetailsWebviewProvider(container, host, { attachedTo: 'graph' }); + }, + ); +} diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts new file mode 100644 index 0000000000000..ebbe5c0546e3b --- /dev/null +++ b/src/webviews/home/homeWebview.ts @@ -0,0 +1,202 @@ +import { Disposable, workspace } from 'vscode'; +import { getAvatarUriFromGravatarEmail } from '../../avatars'; +import type { ContextKeys } from '../../constants.context'; +import type { Container } from '../../container'; +import type { Subscription } from '../../plus/gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../../plus/gk/account/subscriptionService'; +import { registerCommand } from '../../system/vscode/command'; +import { getContext, onDidChangeContext } from '../../system/vscode/context'; +import type { IpcMessage } from '../protocol'; +import type { WebviewHost, WebviewProvider } from '../webviewProvider'; +import type { CollapseSectionParams, DidChangeRepositoriesParams, State } from './protocol'; +import { + CollapseSectionCommand, + DidChangeIntegrationsConnections, + DidChangeOrgSettings, + DidChangeRepositories, + DidChangeSubscription, +} from './protocol'; + +const emptyDisposable = Object.freeze({ + dispose: () => { + /* noop */ + }, +}); + +export class HomeWebviewProvider implements WebviewProvider { + private readonly _disposable: Disposable; + + constructor( + private readonly container: Container, + private readonly host: WebviewHost, + ) { + this._disposable = Disposable.from( + this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), + !workspace.isTrusted + ? workspace.onDidGrantWorkspaceTrust(this.notifyDidChangeRepositories, this) + : emptyDisposable, + this.container.subscription.onDidChange(this.onSubscriptionChanged, this), + onDidChangeContext(this.onContextChanged, this), + this.container.integrations.onDidChangeConnectionState(this.onChangeConnectionState, this), + ); + } + + dispose() { + this._disposable.dispose(); + } + + private onChangeConnectionState() { + this.notifyDidChangeOnboardingIntegration(); + } + + private onRepositoriesChanged() { + this.notifyDidChangeRepositories(); + } + + registerCommands(): Disposable[] { + return [registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true), this)]; + } + + onMessageReceived(e: IpcMessage) { + switch (true) { + case CollapseSectionCommand.is(e): + this.onCollapseSection(e.params); + break; + } + } + + includeBootstrap(): Promise { + return this.getState(); + } + + onReloaded() { + this.notifyDidChangeRepositories(); + } + + private onCollapseSection(params: CollapseSectionParams) { + const collapsed = this.container.storage.get('home:sections:collapsed'); + if (collapsed == null) { + if (params.collapsed === true) { + void this.container.storage.store('home:sections:collapsed', [params.section]); + } + return; + } + + const idx = collapsed.indexOf(params.section); + if (params.collapsed === true) { + if (idx === -1) { + void this.container.storage.store('home:sections:collapsed', [...collapsed, params.section]); + } + + return; + } + + if (idx !== -1) { + collapsed.splice(idx, 1); + void this.container.storage.store('home:sections:collapsed', collapsed); + } + } + + private getWalkthroughCollapsed() { + return this.container.storage.get('home:sections:collapsed')?.includes('walkthrough') ?? false; + } + + private getOrgSettings(): State['orgSettings'] { + return { + drafts: getContext('gitlens:gk:organization:drafts:enabled', false), + }; + } + + private onContextChanged(key: keyof ContextKeys) { + if (key === 'gitlens:gk:organization:drafts:enabled') { + this.notifyDidChangeOrgSettings(); + } + } + + private onSubscriptionChanged(e: SubscriptionChangeEvent) { + void this.notifyDidChangeSubscription(e.current); + } + + private async getState(subscription?: Subscription): Promise { + const subResult = await this.getSubscription(subscription); + + return { + ...this.host.baseWebviewState, + repositories: this.getRepositoriesState(), + webroot: this.host.getWebRoot(), + subscription: subResult.subscription, + avatar: subResult.avatar, + organizationsCount: subResult.organizationsCount, + orgSettings: this.getOrgSettings(), + walkthroughCollapsed: this.getWalkthroughCollapsed(), + hasAnyIntegrationConnected: this.isAnyIntegrationConnected(), + }; + } + + private getRepositoriesState(): DidChangeRepositoriesParams { + return { + count: this.container.git.repositoryCount, + openCount: this.container.git.openRepositoryCount, + hasUnsafe: this.container.git.hasUnsafeRepositories(), + trusted: workspace.isTrusted, + }; + } + + private _hostedIntegrationConnected: boolean | undefined; + private isAnyIntegrationConnected(force = false) { + if (this._hostedIntegrationConnected == null || force === true) { + this._hostedIntegrationConnected = + [ + ...this.container.integrations.getConnected('hosting'), + ...this.container.integrations.getConnected('issues'), + ].length > 0; + } + return this._hostedIntegrationConnected; + } + + private async getSubscription(subscription?: Subscription) { + subscription ??= await this.container.subscription.getSubscription(true); + + let avatar; + if (subscription.account?.email) { + avatar = getAvatarUriFromGravatarEmail(subscription.account.email, 34).toString(); + } else { + avatar = `${this.host.getWebRoot() ?? ''}/media/gitlens-logo.webp`; + } + + return { + subscription: subscription, + avatar: avatar, + organizationsCount: + subscription != null ? ((await this.container.organizations.getOrganizations()) ?? []).length : 0, + }; + } + + private notifyDidChangeRepositories() { + void this.host.notify(DidChangeRepositories, this.getRepositoriesState()); + } + + private notifyDidChangeOnboardingIntegration() { + // force rechecking + const isConnected = this.isAnyIntegrationConnected(true); + void this.host.notify(DidChangeIntegrationsConnections, { + hasAnyIntegrationConnected: isConnected, + }); + } + + private async notifyDidChangeSubscription(subscription?: Subscription) { + const subResult = await this.getSubscription(subscription); + + void this.host.notify(DidChangeSubscription, { + subscription: subResult.subscription, + avatar: subResult.avatar, + organizationsCount: subResult.organizationsCount, + }); + } + + private notifyDidChangeOrgSettings() { + void this.host.notify(DidChangeOrgSettings, { + orgSettings: this.getOrgSettings(), + }); + } +} diff --git a/src/webviews/home/homeWebviewView.ts b/src/webviews/home/homeWebviewView.ts deleted file mode 100644 index f9d499dfc8ea7..0000000000000 --- a/src/webviews/home/homeWebviewView.ts +++ /dev/null @@ -1,317 +0,0 @@ -import type { ConfigurationChangeEvent, Disposable } from 'vscode'; -import { window } from 'vscode'; -import { getAvatarUriFromGravatarEmail } from '../../avatars'; -import { ViewsLayout } from '../../commands/setViewsLayout'; -import { configuration } from '../../configuration'; -import { ContextKeys, CoreCommands } from '../../constants'; -import type { Container } from '../../container'; -import { getContext, onDidChangeContext } from '../../context'; -import type { RepositoriesVisibility } from '../../git/gitProviderService'; -import type { SubscriptionChangeEvent } from '../../plus/subscription/subscriptionService'; -import { ensurePlusFeaturesEnabled } from '../../plus/subscription/utils'; -import type { StorageChangeEvent } from '../../storage'; -import type { Subscription } from '../../subscription'; -import { executeCoreCommand, registerCommand } from '../../system/command'; -import type { Deferrable } from '../../system/function'; -import { debounce } from '../../system/function'; -import type { IpcMessage } from '../protocol'; -import { onIpc } from '../protocol'; -import { WebviewViewBase } from '../webviewViewBase'; -import type { CompleteStepParams, DismissBannerParams, DismissSectionParams, State } from './protocol'; -import { - CompletedActions, - CompleteStepCommandType, - DidChangeConfigurationType, - DidChangeExtensionEnabledType, - DidChangeLayoutType, - DidChangeSubscriptionNotificationType, - DismissBannerCommandType, - DismissSectionCommandType, - DismissStatusCommandType, -} from './protocol'; - -export class HomeWebviewView extends WebviewViewBase { - constructor(container: Container) { - super(container, 'gitlens.views.home', 'home.html', 'Home', `${ContextKeys.WebviewViewPrefix}home`, 'homeView'); - - this.disposables.push( - this.container.subscription.onDidChange(this.onSubscriptionChanged, this), - onDidChangeContext(key => { - if (key !== ContextKeys.Disabled) return; - this.notifyExtensionEnabled(); - }), - configuration.onDidChange(e => { - this.onConfigurationChanged(e); - }, this), - this.container.storage.onDidChange(e => { - this.onStorageChanged(e); - }), - ); - } - - override async show(options?: { preserveFocus?: boolean | undefined }): Promise { - if (!(await ensurePlusFeaturesEnabled())) return; - return super.show(options); - } - - private async onSubscriptionChanged(e: SubscriptionChangeEvent) { - await this.container.storage.store('home:status:pinned', true); - void this.notifyDidChangeData(e.current); - } - - private onConfigurationChanged(e: ConfigurationChangeEvent) { - if (!configuration.changed(e, 'plusFeatures.enabled')) { - return; - } - - this.notifyDidChangeConfiguration(); - } - - private onStorageChanged(e: StorageChangeEvent) { - if (e.key !== 'views:layout') return; - - this.notifyDidChangeLayout(); - } - - protected override onVisibilityChanged(visible: boolean): void { - if (!visible) { - this._validateSubscriptionDebounced?.cancel(); - return; - } - - queueMicrotask(() => void this.validateSubscription()); - } - - protected override onWindowFocusChanged(focused: boolean): void { - if (!focused || !this.visible) { - this._validateSubscriptionDebounced?.cancel(); - return; - } - - queueMicrotask(() => void this.validateSubscription()); - } - - protected override registerCommands(): Disposable[] { - return [ - registerCommand(`${this.id}.refresh`, () => this.refresh(), this), - registerCommand('gitlens.home.toggleWelcome', async () => { - const welcomeVisible = !this.welcomeVisible; - await this.container.storage.store('views:welcome:visible', welcomeVisible); - if (welcomeVisible) { - await this.container.storage.store('home:actions:completed', []); - await this.container.storage.store('home:steps:completed', []); - await this.container.storage.store('home:sections:dismissed', []); - } - - void this.refresh(); - }), - registerCommand('gitlens.home.restoreWelcome', async () => { - await this.container.storage.store('home:steps:completed', []); - await this.container.storage.store('home:sections:dismissed', []); - - void this.refresh(); - }), - - registerCommand('gitlens.home.showSCM', async () => { - const completedActions = this.container.storage.get('home:actions:completed', []); - if (!completedActions.includes(CompletedActions.OpenedSCM)) { - completedActions.push(CompletedActions.OpenedSCM); - await this.container.storage.store('home:actions:completed', completedActions); - - void this.notifyDidChangeData(); - } - - await executeCoreCommand(CoreCommands.ShowSCM); - }), - ]; - } - - protected override onMessageReceived(e: IpcMessage) { - switch (e.method) { - case CompleteStepCommandType.method: - onIpc(CompleteStepCommandType, e, params => this.completeStep(params)); - break; - case DismissSectionCommandType.method: - onIpc(DismissSectionCommandType, e, params => this.dismissSection(params)); - break; - case DismissStatusCommandType.method: - onIpc(DismissStatusCommandType, e, _params => this.dismissPinStatus()); - break; - case DismissBannerCommandType.method: - onIpc(DismissBannerCommandType, e, params => this.dismissBanner(params)); - break; - } - } - - private completeStep({ id, completed = false }: CompleteStepParams) { - const steps = this.container.storage.get('home:steps:completed', []); - - const hasStep = steps.includes(id); - if (!hasStep && completed) { - steps.push(id); - } else if (hasStep && !completed) { - steps.splice(steps.indexOf(id), 1); - } - void this.container.storage.store('home:steps:completed', steps); - } - - private dismissSection(params: DismissSectionParams) { - const sections = this.container.storage.get('home:sections:dismissed', []); - if (sections.includes(params.id)) { - return; - } - - sections.push(params.id); - void this.container.storage.store('home:sections:dismissed', sections); - } - - private dismissBanner(params: DismissBannerParams) { - const banners = this.container.storage.get('home:banners:dismissed', []); - - if (!banners.includes(params.id)) { - banners.push(params.id); - } - - void this.container.storage.store('home:banners:dismissed', banners); - } - - private dismissPinStatus() { - void this.container.storage.store('home:status:pinned', false); - } - - protected override async includeBootstrap(): Promise { - return this.getState(); - } - - private get welcomeVisible(): boolean { - return this.container.storage.get('views:welcome:visible', true); - } - - private async getRepoVisibility(): Promise { - const visibility = await this.container.git.visibility(); - return visibility; - } - - private async getSubscription(subscription?: Subscription) { - // Make sure to make a copy of the array otherwise it will be live to the storage value - const completedActions = [...this.container.storage.get('home:actions:completed', [])]; - if (!this.welcomeVisible) { - completedActions.push(CompletedActions.DismissedWelcome); - } - - const subscriptionState = subscription ?? (await this.container.subscription.getSubscription(true)); - - let avatar; - if (subscriptionState.account?.email) { - avatar = getAvatarUriFromGravatarEmail(subscriptionState.account.email, 34).toString(); - } else { - avatar = `${this.getWebRoot() ?? ''}/media/gitlens-logo.webp`; - } - - return { - subscription: subscriptionState, - completedActions: completedActions, - avatar: avatar, - }; - } - - private getPinStatus() { - return this.container.storage.get('home:status:pinned') ?? true; - } - - private async getState(subscription?: Subscription): Promise { - const subscriptionState = await this.getSubscription(subscription); - const steps = this.container.storage.get('home:steps:completed', []); - const sections = this.container.storage.get('home:sections:dismissed', []); - const dismissedBanners = this.container.storage.get('home:banners:dismissed', []); - - return { - extensionEnabled: this.getExtensionEnabled(), - webroot: this.getWebRoot(), - subscription: subscriptionState.subscription, - completedActions: subscriptionState.completedActions, - plusEnabled: this.getPlusEnabled(), - visibility: await this.getRepoVisibility(), - completedSteps: steps, - dismissedSections: sections, - avatar: subscriptionState.avatar, - layout: this.getLayout(), - pinStatus: this.getPinStatus(), - dismissedBanners: dismissedBanners, - }; - } - - private notifyDidChangeData(subscription?: Subscription) { - if (!this.isReady) return false; - - const getSub = async () => { - const sub = await this.getSubscription(subscription); - - return { - ...sub, - pinStatus: this.getPinStatus(), - }; - }; - - return window.withProgress({ location: { viewId: this.id } }, async () => - this.notify(DidChangeSubscriptionNotificationType, await getSub()), - ); - } - - private getExtensionEnabled() { - return !getContext(ContextKeys.Disabled, false); - } - - private notifyExtensionEnabled() { - if (!this.isReady) return; - - void this.notify(DidChangeExtensionEnabledType, { - extensionEnabled: this.getExtensionEnabled(), - }); - } - - private getPlusEnabled() { - return configuration.get('plusFeatures.enabled'); - } - - private notifyDidChangeConfiguration() { - if (!this.isReady) return; - - void this.notify(DidChangeConfigurationType, { - plusEnabled: this.getPlusEnabled(), - }); - } - - private getLayout() { - const layout = this.container.storage.get('views:layout'); - return layout != null ? (layout as ViewsLayout) : ViewsLayout.SourceControl; - } - - private notifyDidChangeLayout() { - if (!this.isReady) return; - - void this.notify(DidChangeLayoutType, { layout: this.getLayout() }); - } - - private _validateSubscriptionDebounced: Deferrable | undefined = undefined; - - private async validateSubscription(): Promise { - if (this._validateSubscriptionDebounced == null) { - this._validateSubscriptionDebounced = debounce(this.validateSubscriptionCore, 1000); - } - - await this._validateSubscriptionDebounced(); - } - - private _validating: Promise | undefined; - private async validateSubscriptionCore() { - if (this._validating == null) { - this._validating = this.container.subscription.validate(); - try { - await this._validating; - } finally { - this._validating = undefined; - } - } - } -} diff --git a/src/webviews/home/protocol.ts b/src/webviews/home/protocol.ts index 422f3fa43f4cb..8923a8af81114 100644 --- a/src/webviews/home/protocol.ts +++ b/src/webviews/home/protocol.ts @@ -1,71 +1,56 @@ -import type { ViewsLayout } from '../../commands/setViewsLayout'; -import type { RepositoriesVisibility } from '../../git/gitProviderService'; -import type { Subscription } from '../../subscription'; -import { IpcCommandType, IpcNotificationType } from '../protocol'; +import type { Subscription } from '../../plus/gk/account/subscription'; +import type { IpcScope, WebviewState } from '../protocol'; +import { IpcCommand, IpcNotification } from '../protocol'; -export const enum CompletedActions { - DismissedWelcome = 'dismissed:welcome', - OpenedSCM = 'opened:scm', -} +export const scope: IpcScope = 'home'; -export interface State { - extensionEnabled: boolean; +export interface State extends WebviewState { + repositories: DidChangeRepositoriesParams; webroot?: string; subscription: Subscription; - completedActions: CompletedActions[]; - completedSteps?: string[]; - dismissedBanners?: string[]; - dismissedSections?: string[]; - plusEnabled: boolean; - visibility: RepositoriesVisibility; + orgSettings: { + drafts: boolean; + }; + walkthroughCollapsed: boolean; + hasAnyIntegrationConnected: boolean; avatar?: string; - layout: ViewsLayout; - pinStatus: boolean; + organizationsCount?: number; } -export interface CompleteStepParams { - id: string; - completed: boolean; -} -export const CompleteStepCommandType = new IpcCommandType('home/step/complete'); +// COMMANDS -export interface DismissSectionParams { - id: string; +export interface CollapseSectionParams { + section: string; + collapsed: boolean; } -export const DismissSectionCommandType = new IpcCommandType('home/section/dismiss'); +export const CollapseSectionCommand = new IpcCommand(scope, 'section/collapse'); -export const DismissStatusCommandType = new IpcCommandType('home/status/dismiss'); +// NOTIFICATIONS -export interface DismissBannerParams { - id: string; +export interface DidChangeRepositoriesParams { + count: number; + openCount: number; + hasUnsafe: boolean; + trusted: boolean; } -export const DismissBannerCommandType = new IpcCommandType('home/banner/dismiss'); +export const DidChangeRepositories = new IpcNotification(scope, 'repositories/didChange'); -export interface DidChangeSubscriptionParams { - subscription: Subscription; - completedActions: CompletedActions[]; - avatar?: string; - pinStatus: boolean; +export interface DidChangeIntegrationsParams { + hasAnyIntegrationConnected: boolean; } -export const DidChangeSubscriptionNotificationType = new IpcNotificationType( - 'subscription/didChange', +export const DidChangeIntegrationsConnections = new IpcNotification( + scope, + 'integrations/didChange', ); -export interface DidChangeExtensionEnabledParams { - extensionEnabled: boolean; -} -export const DidChangeExtensionEnabledType = new IpcNotificationType( - 'extensionEnabled/didChange', -); - -export interface DidChangeConfigurationParams { - plusEnabled: boolean; +export interface DidChangeSubscriptionParams { + subscription: Subscription; + avatar: string; + organizationsCount: number; } -export const DidChangeConfigurationType = new IpcNotificationType( - 'configuration/didChange', -); +export const DidChangeSubscription = new IpcNotification(scope, 'subscription/didChange'); -export interface DidChangeLayoutParams { - layout: ViewsLayout; +export interface DidChangeOrgSettingsParams { + orgSettings: State['orgSettings']; } -export const DidChangeLayoutType = new IpcNotificationType('layout/didChange'); +export const DidChangeOrgSettings = new IpcNotification(scope, 'org/settings/didChange'); diff --git a/src/webviews/home/registration.ts b/src/webviews/home/registration.ts new file mode 100644 index 0000000000000..dbfba84ecb536 --- /dev/null +++ b/src/webviews/home/registration.ts @@ -0,0 +1,22 @@ +import type { WebviewsController } from '../webviewsController'; +import type { State } from './protocol'; + +export function registerHomeWebviewView(controller: WebviewsController) { + return controller.registerWebviewView( + { + id: 'gitlens.views.home', + fileName: 'home.html', + title: 'Home', + contextKeyPrefix: `gitlens:webviewView:home`, + trackingFeature: 'homeView', + plusFeature: false, + webviewHostOptions: { + retainContextWhenHidden: false, + }, + }, + async (container, host) => { + const { HomeWebviewProvider } = await import(/* webpackChunkName: "webview-home" */ './homeWebview'); + return new HomeWebviewProvider(container, host); + }, + ); +} diff --git a/src/webviews/protocol.ts b/src/webviews/protocol.ts index 91d2e30ff298b..d200208e46309 100644 --- a/src/webviews/protocol.ts +++ b/src/webviews/protocol.ts @@ -1,95 +1,154 @@ import type { Config } from '../config'; -import type { ConfigPath, ConfigPathValue } from '../configuration'; -import type { CustomConfigPath, CustomConfigPathValue } from './webviewWithConfigBase'; - -export interface IpcMessage { +import type { + CustomEditorIds, + CustomEditorTypes, + WebviewIds, + WebviewTypes, + WebviewViewIds, + WebviewViewTypes, +} from '../constants.views'; +import type { ConfigPath, ConfigPathValue, Path, PathValue } from '../system/vscode/configuration'; + +export type IpcScope = 'core' | CustomEditorTypes | WebviewTypes | WebviewViewTypes; + +export interface IpcMessage { id: string; + scope: IpcScope; method: string; - params?: unknown; + packed?: boolean; + params: T; completionId?: string; } -abstract class IpcMessageType { - _?: Params; // Required for type inferencing to work properly - constructor(public readonly method: string, public readonly overwriteable: boolean = false) {} +abstract class IpcCall { + public readonly method: string; + + constructor( + public readonly scope: IpcScope, + method: string, + public readonly reset: boolean = false, + public readonly pack: boolean = false, + ) { + this.method = `${scope}/${method}`; + } + + is(msg: IpcMessage): msg is IpcMessage { + return msg.method === this.method; + } } -export type IpcMessageParams = T extends IpcMessageType ? P : never; + +export type IpcCallMessageType = T extends IpcCall ? IpcMessage

    : never; +export type IpcCallParamsType = IpcCallMessageType['params']; +export type IpcCallResponseType = T extends IpcRequest ? T['response'] : never; +export type IpcCallResponseMessageType = IpcCallMessageType>; +export type IpcCallResponseParamsType = IpcCallResponseMessageType['params']; /** * Commands are sent from the webview to the extension */ -export class IpcCommandType extends IpcMessageType {} +export class IpcCommand extends IpcCall {} + /** - * Notifications are sent from the extension to the webview + * Requests are sent from the webview to the extension and expect a response back */ -export class IpcNotificationType extends IpcMessageType {} +export class IpcRequest extends IpcCall { + public readonly response: IpcNotification; -export function onIpc>( - type: T, - msg: IpcMessage, - fn: (params: IpcMessageParams, type: T) => unknown, -) { - if (type.method !== msg.method) return; + constructor(scope: IpcScope, method: string, reset?: boolean, pack?: boolean) { + super(scope, method, reset, pack); - fn(msg.params as IpcMessageParams, type); + this.response = new IpcNotification(this.scope, `${method}/completion`, this.reset, this.pack); + } } +/** + * Notifications are sent from the extension to the webview + */ +export class IpcNotification extends IpcCall {} + // COMMANDS -export const WebviewReadyCommandType = new IpcCommandType('webview/ready'); +export const WebviewReadyCommand = new IpcCommand('core', 'webview/ready'); export interface WebviewFocusChangedParams { focused: boolean; inputFocused: boolean; } -export const WebviewFocusChangedCommandType = new IpcCommandType('webview/focus'); +export const WebviewFocusChangedCommand = new IpcCommand('core', 'webview/focus/changed'); export interface ExecuteCommandParams { command: string; args?: []; } -export const ExecuteCommandType = new IpcCommandType('command/execute'); - -export interface GenerateCommitPreviewParams { - key: string; - type: 'commit' | 'commit-uncommitted'; - format: string; -} - -type GenerateConfigurationPreviewParams = GenerateCommitPreviewParams; -export const GenerateConfigurationPreviewCommandType = new IpcCommandType( - 'configuration/preview', -); +export const ExecuteCommand = new IpcCommand('core', 'command/execute'); export interface UpdateConfigurationParams { changes: { [key in ConfigPath | CustomConfigPath]?: ConfigPathValue | CustomConfigPathValue; }; - removes: string[]; + removes: (keyof { [key in ConfigPath | CustomConfigPath]?: ConfigPathValue })[]; scope?: 'user' | 'workspace'; uri?: string; } -export const UpdateConfigurationCommandType = new IpcCommandType('configuration/update'); +export const UpdateConfigurationCommand = new IpcCommand('core', 'configuration/update'); // NOTIFICATIONS +export interface DidChangeHostWindowFocusParams { + focused: boolean; +} +export const DidChangeHostWindowFocusNotification = new IpcNotification( + 'core', + 'window/focus/didChange', +); + +export interface DidChangeWebviewFocusParams { + focused: boolean; +} +export const DidChangeWebviewFocusNotification = new IpcCommand( + 'core', + 'webview/focus/didChange', +); + export interface DidChangeConfigurationParams { config: Config; customSettings: Record; } -export const DidChangeConfigurationNotificationType = new IpcNotificationType( +export const DidChangeConfigurationNotification = new IpcNotification( + 'core', 'configuration/didChange', ); -export interface DidGenerateConfigurationPreviewParams { - preview: string; +interface CustomConfig { + rebaseEditor: { + enabled: boolean; + }; + currentLine: { + useUncommittedChangesFormat: boolean; + }; +} + +export type CustomConfigPath = Path; +export type CustomConfigPathValue

    = PathValue; + +const customConfigKeys: readonly CustomConfigPath[] = [ + 'rebaseEditor.enabled', + 'currentLine.useUncommittedChangesFormat', +]; + +export function isCustomConfigKey(key: string): key is CustomConfigPath { + return customConfigKeys.includes(key as CustomConfigPath); } -export const DidGenerateConfigurationPreviewNotificationType = - new IpcNotificationType('configuration/didPreview'); +export function assertsConfigKeyValue( + _key: T, + value: unknown, +): asserts value is ConfigPathValue { + // Noop +} -export interface DidOpenAnchorParams { - anchor: string; - scrollBehavior: 'auto' | 'smooth'; +export interface WebviewState { + webviewId: Id; + webviewInstanceId: string | undefined; + timestamp: number; } -export const DidOpenAnchorNotificationType = new IpcNotificationType('webview/didOpenAnchor'); diff --git a/src/webviews/rebase/protocol.ts b/src/webviews/rebase/protocol.ts index 1bcb23731d4d0..70826f4c2664b 100644 --- a/src/webviews/rebase/protocol.ts +++ b/src/webviews/rebase/protocol.ts @@ -1,6 +1,10 @@ -import { IpcCommandType, IpcNotificationType } from '../protocol'; +import type { CustomEditorIds } from '../../constants.views'; +import type { IpcScope, WebviewState } from '../protocol'; +import { IpcCommand, IpcNotification } from '../protocol'; -export interface State { +export const scope: IpcScope = 'rebase'; + +export interface State extends WebviewState { branch: string; onto: { sha: string; commit?: Commit } | undefined; @@ -44,38 +48,38 @@ export interface Commit { // COMMANDS -export const AbortCommandType = new IpcCommandType('rebase/abort'); -export const DisableCommandType = new IpcCommandType('rebase/disable'); -export const SearchCommandType = new IpcCommandType('rebase/search'); -export const StartCommandType = new IpcCommandType('rebase/start'); -export const SwitchCommandType = new IpcCommandType('rebase/switch'); +export const AbortCommand = new IpcCommand(scope, 'abort'); +export const DisableCommand = new IpcCommand(scope, 'disable'); +export const SearchCommand = new IpcCommand(scope, 'search'); +export const StartCommand = new IpcCommand(scope, 'start'); +export const SwitchCommand = new IpcCommand(scope, 'switch'); export interface ReorderParams { ascending: boolean; } -export const ReorderCommandType = new IpcCommandType('rebase/reorder'); +export const ReorderCommand = new IpcCommand(scope, 'reorder'); export interface ChangeEntryParams { sha: string; action: RebaseEntryAction; } -export const ChangeEntryCommandType = new IpcCommandType('rebase/change/entry'); +export const ChangeEntryCommand = new IpcCommand(scope, 'change/entry'); export interface MoveEntryParams { sha: string; to: number; relative: boolean; } -export const MoveEntryCommandType = new IpcCommandType('rebase/move/entry'); +export const MoveEntryCommand = new IpcCommand(scope, 'move/entry'); export interface UpdateSelectionParams { sha: string; } -export const UpdateSelectionCommandType = new IpcCommandType('rebase/selection/update'); +export const UpdateSelectionCommand = new IpcCommand(scope, 'selection/update'); // NOTIFICATIONS export interface DidChangeParams { state: State; } -export const DidChangeNotificationType = new IpcNotificationType('rebase/didChange'); +export const DidChangeNotification = new IpcNotification(scope, 'didChange'); diff --git a/src/webviews/rebase/rebaseEditor.ts b/src/webviews/rebase/rebaseEditor.ts index c583e30591b71..bc3908ebeed46 100644 --- a/src/webviews/rebase/rebaseEditor.ts +++ b/src/webviews/rebase/rebaseEditor.ts @@ -1,3 +1,4 @@ +import { getNonce } from '@env/crypto'; import type { CancellationToken, CustomTextEditorProvider, @@ -6,26 +7,25 @@ import type { WebviewPanelOnDidChangeViewStateEvent, } from 'vscode'; import { ConfigurationTarget, Disposable, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'; -import { getNonce } from '@env/crypto'; -import { ShowCommitsInViewCommand } from '../../commands'; -import { configuration } from '../../configuration'; -import { ContextKeys, CoreCommands } from '../../constants'; +import { InspectCommand } from '../../commands/inspect'; import type { Container } from '../../container'; -import { setContext } from '../../context'; import { emojify } from '../../emojis'; import type { GitCommit } from '../../git/models/commit'; -import { GitReference } from '../../git/models/reference'; +import { createReference } from '../../git/models/reference'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository'; -import { Logger } from '../../logger'; import { showRebaseSwitchToTextWarningMessage } from '../../messages'; -import { executeCoreCommand } from '../../system/command'; +import { getScopedCounter } from '../../system/counter'; import { debug, log } from '../../system/decorators/log'; import type { Deferrable } from '../../system/function'; import { debounce } from '../../system/function'; import { join, map } from '../../system/iterable'; +import { Logger } from '../../system/logger'; import { normalizePath } from '../../system/path'; +import { executeCoreCommand } from '../../system/vscode/command'; +import { configuration } from '../../system/vscode/configuration'; import type { IpcMessage, WebviewFocusChangedParams } from '../protocol'; -import { onIpc, WebviewFocusChangedCommandType } from '../protocol'; +import { WebviewFocusChangedCommand } from '../protocol'; +import { replaceWebviewHtmlTokens, resetContextKeys, setContextKeys } from '../webviewController'; import type { Author, ChangeEntryParams, @@ -37,41 +37,23 @@ import type { UpdateSelectionParams, } from './protocol'; import { - AbortCommandType, - ChangeEntryCommandType, - DidChangeNotificationType, - DisableCommandType, - MoveEntryCommandType, - ReorderCommandType, - SearchCommandType, - StartCommandType, - SwitchCommandType, - UpdateSelectionCommandType, + AbortCommand, + ChangeEntryCommand, + DidChangeNotification, + DisableCommand, + MoveEntryCommand, + ReorderCommand, + SearchCommand, + StartCommand, + SwitchCommand, + UpdateSelectionCommand, } from './protocol'; -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) - -let ipcSequence = 0; -function nextIpcId() { - if (ipcSequence === maxSmallIntegerV8) { - ipcSequence = 1; - } else { - ipcSequence++; - } +const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) +const utf8TextDecoder = new TextDecoder('utf8'); - return `host:${ipcSequence}`; -} - -let webviewId = 0; -function nextWebviewId() { - if (webviewId === maxSmallIntegerV8) { - webviewId = 1; - } else { - webviewId++; - } - - return webviewId; -} +const ipcSequencer = getScopedCounter(); +const webviewIdGenerator = getScopedCounter(); const rebaseRegex = /^\s?#\s?Rebase\s([0-9a-f]+)(?:..([0-9a-f]+))?\sonto\s([0-9a-f]+)\s.*$/im; const rebaseCommandsRegex = /^\s?(p|pick|r|reword|e|edit|s|squash|f|fixup|d|drop)\s([0-9a-f]+?)\s(.*)$/gm; @@ -132,13 +114,11 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl } private get contextKeyPrefix() { - return `${ContextKeys.WebviewPrefix}rebaseEditor` as const; + return 'gitlens:webview:rebase' as const; } get enabled(): boolean { - const associations = configuration.inspectAny< - { [key: string]: string } | { viewType: string; filenamePattern: string }[] - >('workbench.editorAssociations')?.globalValue; + const associations = configuration.inspectCore('workbench.editorAssociations')?.globalValue; if (associations == null || associations.length === 0) return true; if (Array.isArray(associations)) { @@ -161,9 +141,7 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl async setEnabled(enabled: boolean): Promise { this._disableAfterNextUse = false; - const inspection = configuration.inspectAny< - { [key: string]: string } | { viewType: string; filenamePattern: string }[] - >('workbench.editorAssociations'); + const inspection = configuration.inspectCore('workbench.editorAssociations'); let associations = inspection?.globalValue; if (Array.isArray(associations)) { @@ -186,7 +164,7 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl await configuration.updateAny('workbench.editorAssociations', associations, ConfigurationTarget.Global); } - @debug({ args: { 0: d => d.uri.toString(true) } }) + @debug({ args: { 1: false, 2: false } }) async resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel, _token: CancellationToken) { void this.container.usage.track(`rebaseEditor:shown`); @@ -197,7 +175,7 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl const context: RebaseEditorContext = { dispose: () => void Disposable.from(...subscriptions).dispose(), - id: nextWebviewId(), + id: webviewIdGenerator.next(), subscriptions: subscriptions, document: document, panel: panel, @@ -208,7 +186,7 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl subscriptions.push( panel.onDidDispose(() => { - this.resetContextKeys(); + resetContextKeys(this.contextKeyPrefix); Disposable.from(...subscriptions).dispose(); }), @@ -245,34 +223,11 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl } } - private resetContextKeys(): void { - void setContext(`${this.contextKeyPrefix}:inputFocus`, false); - void setContext(`${this.contextKeyPrefix}:focus`, false); - void setContext(`${this.contextKeyPrefix}:active`, false); - } - - private setContextKeys(active: boolean | undefined, focus?: boolean, inputFocus?: boolean): void { - if (active != null) { - void setContext(`${this.contextKeyPrefix}:active`, active); - - if (!active) { - focus = false; - inputFocus = false; - } - } - if (focus != null) { - void setContext(`${this.contextKeyPrefix}:focus`, focus); - } - if (inputFocus != null) { - void setContext(`${this.contextKeyPrefix}:inputFocus`, inputFocus); - } - } - @debug({ args: { 0: e => `focused=${e.focused}, inputFocused=${e.inputFocused}` }, }) - protected onViewFocusChanged(e: WebviewFocusChangedParams): void { - this.setContextKeys(e.focused, e.focused, e.inputFocused); + protected onViewFocusChanged(_e: WebviewFocusChangedParams): void { + setContextKeys(this.contextKeyPrefix); } @debug({ @@ -282,11 +237,10 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl }, }) protected onViewStateChanged(context: RebaseEditorContext, e: WebviewPanelOnDidChangeViewStateEvent): void { - const { active, visible } = e.webviewPanel; - if (visible) { - this.setContextKeys(active); + if (e.webviewPanel.visible) { + setContextKeys(this.contextKeyPrefix); } else { - this.resetContextKeys(); + resetContextKeys(this.contextKeyPrefix); } if (!context.pendingChange) return; @@ -299,7 +253,7 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl const branch = await this.container.git.getBranch(context.repoPath); context.branchName = branch?.name ?? null; } - const state = await this.parseRebaseTodo(context); + const state = await parseRebaseTodo(this.container, context, this.ascending); return state; } @@ -317,56 +271,51 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl } private onMessageReceived(context: RebaseEditorContext, e: IpcMessage) { - switch (e.method) { - // case ReadyCommandType.method: - // onIpcCommand(ReadyCommandType, e, params => { - // this.parseDocumentAndSendChange(panel, document); - // }); - + switch (true) { + // case ReadyCommandType.is(e): + // this.parseDocumentAndSendChange(panel, document); // break; - case WebviewFocusChangedCommandType.method: - onIpc(WebviewFocusChangedCommandType, e, params => { - this.onViewFocusChanged(params); - }); - + case WebviewFocusChangedCommand.is(e): + this.onViewFocusChanged(e.params); break; - case AbortCommandType.method: - onIpc(AbortCommandType, e, () => this.abort(context)); + case AbortCommand.is(e): + void this.abort(context); break; - case DisableCommandType.method: - onIpc(DisableCommandType, e, () => this.disable(context)); + case DisableCommand.is(e): + void this.disable(context); break; - case SearchCommandType.method: - onIpc(SearchCommandType, e, () => executeCoreCommand(CoreCommands.CustomEditorShowFindWidget)); + case SearchCommand.is(e): + void executeCoreCommand('editor.action.webvieweditor.showFind'); break; - case StartCommandType.method: - onIpc(StartCommandType, e, () => this.rebase(context)); + case StartCommand.is(e): + void this.rebase(context); break; - case SwitchCommandType.method: - onIpc(SwitchCommandType, e, () => this.switchToText(context)); + case SwitchCommand.is(e): + this.switchToText(context); break; - case ReorderCommandType.method: - onIpc(ReorderCommandType, e, params => this.swapOrdering(params, context)); + case ReorderCommand.is(e): + this.swapOrdering(e.params, context); break; - case ChangeEntryCommandType.method: - onIpc(ChangeEntryCommandType, e, params => this.onEntryChanged(context, params)); + case ChangeEntryCommand.is(e): + void this.onEntryChanged(context, e.params); break; - case MoveEntryCommandType.method: - onIpc(MoveEntryCommandType, e, params => this.onEntryMoved(context, params)); + case MoveEntryCommand.is(e): + void this.onEntryMoved(context, e.params); break; - case UpdateSelectionCommandType.method: - onIpc(UpdateSelectionCommandType, e, params => this.onSelectionChanged(context, params)); + case UpdateSelectionCommand.is(e): + this.onSelectionChanged(context, e.params); + break; } } @@ -514,8 +463,8 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl this.container.events.fire( 'commit:selected', { - commit: GitReference.create(sha, context.repoPath, { refType: 'revision' }), - pin: false, + commit: createReference(sha, context.repoPath, { refType: 'revision' }), + interaction: 'passive', preserveFocus: true, preserveVisibility: context.firstSelection ? showDetailsView === false @@ -556,8 +505,9 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl const state = await this.parseState(context); void this.postMessage(context, { - id: nextIpcId(), - method: DidChangeNotificationType.method, + id: `host:${ipcSequencer.next()}`, + scope: DidChangeNotification.scope, + method: DidChangeNotification.method, params: { state: state }, }); } @@ -604,7 +554,7 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl void showRebaseSwitchToTextWarningMessage(); // Open the text version of the document - void executeCoreCommand(CoreCommands.Open, context.document.uri, { + void executeCoreCommand('vscode.open', context.document.uri, { override: false, preview: false, }); @@ -613,140 +563,131 @@ export class RebaseEditorProvider implements CustomTextEditorProvider, Disposabl private async getHtml(context: RebaseEditorContext): Promise { const webRootUri = Uri.joinPath(this.container.context.extensionUri, 'dist', 'webviews'); const uri = Uri.joinPath(webRootUri, 'rebase.html'); - const content = new TextDecoder('utf8').decode(await workspace.fs.readFile(uri)); - - const bootstrap = await this.parseState(context); - const cspSource = context.panel.webview.cspSource; - const cspNonce = getNonce(); - - const root = context.panel.webview.asWebviewUri(this.container.context.extensionUri).toString(); - const webRoot = context.panel.webview.asWebviewUri(webRootUri).toString(); - - const html = content.replace( - /#{(head|body|endOfBody|placement|cspSource|cspNonce|root|webroot)}/g, - (_substring: string, token: string) => { - switch (token) { - case 'endOfBody': - return ``; - case 'placement': - return 'editor'; - case 'cspSource': - return cspSource; - case 'cspNonce': - return cspNonce; - case 'root': - return root; - case 'webroot': - return webRoot; - default: - return ''; - } - }, - ); + const [bytes, bootstrap] = await Promise.all([workspace.fs.readFile(uri), this.parseState(context)]); + + const html = replaceWebviewHtmlTokens( + utf8TextDecoder.decode(bytes), + 'gitlens.rebase', + undefined, + context.panel.webview.cspSource, + getNonce(), + context.panel.webview.asWebviewUri(this.container.context.extensionUri).toString(), + context.panel.webview.asWebviewUri(webRootUri).toString(), + 'editor', + bootstrap, + ); return html; } +} - @debug({ args: false }) - private async parseRebaseTodo(context: RebaseEditorContext): Promise> { - const contents = context.document.getText(); - const entries = parseRebaseTodoEntries(contents); - let [, , , onto] = rebaseRegex.exec(contents) ?? ['', '', '']; - - if (context.authors == null || context.commits == null) { - await this.loadRichCommitData(context, onto, entries); +async function loadRichCommitData( + container: Container, + context: RebaseEditorContext, + onto: string, + entries: RebaseEntry[], +) { + context.commits = []; + context.authors = new Map(); + + const log = await container.git.richSearchCommits( + context.repoPath, + { + query: `${onto ? `#:${onto} ` : ''}${join( + map(entries, e => `#:${e.sha}`), + ' ', + )}`, + }, + { limit: 0 }, + ); + + if (log != null) { + for (const c of log.commits.values()) { + context.commits.push(c); + + if (!context.authors.has(c.author.name)) { + context.authors.set(c.author.name, { + author: c.author.name, + avatarUrl: (await c.getAvatarUri()).toString(true), + email: c.author.email, + }); + } + if (!context.authors.has(c.committer.name)) { + const avatarUri = await c.committer.getAvatarUri(c); + context.authors.set(c.committer.name, { + author: c.committer.name, + avatarUrl: avatarUri.toString(true), + email: c.committer.email, + }); + } } + } +} - const defaultDateFormat = configuration.get('defaultDateFormat'); - const command = ShowCommitsInViewCommand.getMarkdownCommandArgs(`\${commit}`, context.repoPath); +async function parseRebaseTodo( + container: Container, + context: RebaseEditorContext, + ascending: boolean, +): Promise> { + const contents = context.document.getText(); + const entries = parseRebaseTodoEntries(contents); + let [, , , onto] = rebaseRegex.exec(contents) ?? ['', '', '']; + + if (context.authors == null || context.commits == null) { + await loadRichCommitData(container, context, onto, entries); + } - const ontoCommit = onto ? context.commits?.find(c => c.sha.startsWith(onto)) : undefined; + const defaultDateFormat = configuration.get('defaultDateFormat'); + const command = InspectCommand.getMarkdownCommandArgs(`\${commit}`, context.repoPath); - let commit; - for (const entry of entries) { - commit = context.commits?.find(c => c.sha.startsWith(entry.sha)); - if (commit == null) continue; + const ontoCommit = onto ? context.commits?.find(c => c.sha.startsWith(onto)) : undefined; - // If the onto commit is contained in the list of commits, remove it and clear the 'onto' value — See #1201 - if (commit.sha === ontoCommit?.sha) { - onto = ''; - } + let commit; + for (const entry of entries) { + commit = context.commits?.find(c => c.sha.startsWith(entry.sha)); + if (commit == null) continue; - entry.commit = { - sha: commit.sha, - author: commit.author.name, - committer: commit.committer.name, - date: commit.formatDate(defaultDateFormat), - dateFromNow: commit.formatDateFromNow(), - message: emojify(commit.message ?? commit.summary), - }; + // If the onto commit is contained in the list of commits, remove it and clear the 'onto' value — See #1201 + if (commit.sha === ontoCommit?.sha) { + onto = ''; } - return { - branch: context.branchName ?? '', - onto: onto - ? { - sha: onto, - commit: - ontoCommit != null - ? { - sha: ontoCommit.sha, - author: ontoCommit.author.name, - committer: ontoCommit.committer.name, - date: ontoCommit.formatDate(defaultDateFormat), - dateFromNow: ontoCommit.formatDateFromNow(), - message: emojify(ontoCommit.message || 'root'), - } - : undefined, - } - : undefined, - entries: entries, - authors: context.authors != null ? Object.fromEntries(context.authors) : {}, - commands: { commit: command }, - ascending: this.ascending, + entry.commit = { + sha: commit.sha, + author: commit.author.name, + committer: commit.committer.name, + date: commit.formatDate(defaultDateFormat), + dateFromNow: commit.formattedDate, + message: emojify(commit.message ?? commit.summary), }; } - @debug({ args: false }) - private async loadRichCommitData(context: RebaseEditorContext, onto: string, entries: RebaseEntry[]) { - context.commits = []; - context.authors = new Map(); - - const log = await this.container.git.richSearchCommits( - context.repoPath, - { - query: `${onto ? `#:${onto} ` : ''}${join( - map(entries, e => `#:${e.sha}`), - ' ', - )}`, - }, - { limit: 0 }, - ); - - if (log != null) { - for (const c of log.commits.values()) { - context.commits.push(c); - - if (!context.authors.has(c.author.name)) { - context.authors.set(c.author.name, { - author: c.author.name, - avatarUrl: (await c.getAvatarUri()).toString(true), - email: c.author.email, - }); - } - if (!context.authors.has(c.committer.name)) { - const avatarUri = await c.committer.getAvatarUri(c); - context.authors.set(c.committer.name, { - author: c.committer.name, - avatarUrl: avatarUri.toString(true), - email: c.committer.email, - }); - } - } - } - } + return { + webviewId: 'gitlens.rebase', + webviewInstanceId: undefined, + timestamp: Date.now(), + branch: context.branchName ?? '', + onto: onto + ? { + sha: onto, + commit: + ontoCommit != null + ? { + sha: ontoCommit.sha, + author: ontoCommit.author.name, + committer: ontoCommit.committer.name, + date: ontoCommit.formatDate(defaultDateFormat), + dateFromNow: ontoCommit.formatDateFromNow(), + message: emojify(ontoCommit.message || 'root'), + } + : undefined, + } + : undefined, + entries: entries, + authors: context.authors != null ? Object.fromEntries(context.authors) : {}, + commands: { commit: command }, + ascending: ascending, + }; } function parseRebaseTodoEntries(contents: string): RebaseEntry[]; @@ -771,9 +712,9 @@ function parseRebaseTodoEntries(contentsOrDocument: string | TextDocument): Reba index: match.index, action: rebaseActionsMap.get(action) ?? 'pick', // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - sha: ` ${sha}`.substr(1), + sha: ` ${sha}`.substring(1), // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - message: message == null || message.length === 0 ? '' : ` ${message}`.substr(1), + message: message == null || message.length === 0 ? '' : ` ${message}`.substring(1), }); } while (true); diff --git a/src/webviews/settings/protocol.ts b/src/webviews/settings/protocol.ts index 7edbb4cc4f474..5229d3b8cb76f 100644 --- a/src/webviews/settings/protocol.ts +++ b/src/webviews/settings/protocol.ts @@ -1,8 +1,52 @@ import type { Config } from '../../config'; +import type { IpcScope, WebviewState } from '../protocol'; +import { IpcNotification, IpcRequest } from '../protocol'; -export interface State { +export const scope: IpcScope = 'settings'; + +export interface State extends WebviewState { + version: string; config: Config; customSettings?: Record; scope: 'user' | 'workspace'; scopes: ['user' | 'workspace', string][]; + hasAccount: boolean; + hasConnectedJira: boolean; +} + +// REQUESTS + +export interface GenerateCommitPreviewParams { + key: string; + type: 'commit' | 'commit-uncommitted'; + format: string; +} +type GenerateConfigurationPreviewParams = GenerateCommitPreviewParams; +export interface DidGenerateConfigurationPreviewParams { + preview: string; +} +export const GenerateConfigurationPreviewRequest = new IpcRequest< + GenerateConfigurationPreviewParams, + DidGenerateConfigurationPreviewParams +>(scope, 'configuration/preview'); + +// NOTIFICATIONS + +export interface DidOpenAnchorParams { + anchor: string; + scrollBehavior: 'auto' | 'smooth'; +} +export const DidOpenAnchorNotification = new IpcNotification(scope, 'didOpenAnchor'); + +export interface DidChangeAccountParams { + hasAccount: boolean; +} +export const DidChangeAccountNotification = new IpcNotification(scope, 'didChangeAccount'); + +export interface DidChangeConnectedJiraParams { + hasConnectedJira: boolean; } +export const DidChangeConnectedJiraNotification = new IpcNotification( + scope, + 'didChangeConnectedJira', +); diff --git a/src/webviews/settings/registration.ts b/src/webviews/settings/registration.ts new file mode 100644 index 0000000000000..9ffdae6e4173a --- /dev/null +++ b/src/webviews/settings/registration.ts @@ -0,0 +1,64 @@ +import { Disposable, ViewColumn } from 'vscode'; +import { Commands } from '../../constants.commands'; +import { registerCommand } from '../../system/vscode/command'; +import type { WebviewPanelsProxy, WebviewsController } from '../webviewsController'; +import type { State } from './protocol'; + +export type SettingsWebviewShowingArgs = [string]; + +export function registerSettingsWebviewPanel(controller: WebviewsController) { + return controller.registerWebviewPanel( + { id: Commands.ShowSettingsPage }, + { + id: 'gitlens.settings', + fileName: 'settings.html', + iconPath: 'images/gitlens-icon.png', + title: 'GitLens Settings', + contextKeyPrefix: `gitlens:webview:settings`, + trackingFeature: 'settingsWebview', + plusFeature: false, + column: ViewColumn.Active, + webviewHostOptions: { + retainContextWhenHidden: false, + enableFindWidget: true, + }, + }, + async (container, host) => { + const { SettingsWebviewProvider } = await import( + /* webpackChunkName: "webview-settings" */ './settingsWebview' + ); + return new SettingsWebviewProvider(container, host); + }, + ); +} + +export function registerSettingsWebviewCommands(panels: WebviewPanelsProxy) { + return Disposable.from( + ...[ + Commands.ShowSettingsPageAndJumpToFileAnnotations, + Commands.ShowSettingsPageAndJumpToBranchesView, + Commands.ShowSettingsPageAndJumpToCommitsView, + Commands.ShowSettingsPageAndJumpToContributorsView, + Commands.ShowSettingsPageAndJumpToFileHistoryView, + Commands.ShowSettingsPageAndJumpToLineHistoryView, + Commands.ShowSettingsPageAndJumpToRemotesView, + Commands.ShowSettingsPageAndJumpToRepositoriesView, + Commands.ShowSettingsPageAndJumpToSearchAndCompareView, + Commands.ShowSettingsPageAndJumpToStashesView, + Commands.ShowSettingsPageAndJumpToTagsView, + Commands.ShowSettingsPageAndJumpToWorkTreesView, + Commands.ShowSettingsPageAndJumpToViews, + Commands.ShowSettingsPageAndJumpToCommitGraph, + Commands.ShowSettingsPageAndJumpToAutolinks, + ].map(c => { + // The show and jump commands are structured to have a ! separating the base command from the anchor + let anchor: string | undefined; + const match = /.*?!(.*)/.exec(c); + if (match != null) { + [, anchor] = match; + } + + return registerCommand(c, () => void panels.show(undefined, ...(anchor ? [anchor] : []))); + }), + ); +} diff --git a/src/webviews/settings/settingsWebview.ts b/src/webviews/settings/settingsWebview.ts index 5936a3e184131..1e9838457cca6 100644 --- a/src/webviews/settings/settingsWebview.ts +++ b/src/webviews/settings/settingsWebview.ts @@ -1,94 +1,320 @@ -import { workspace } from 'vscode'; -import { configuration } from '../../configuration'; -import { Commands, ContextKeys } from '../../constants'; +import type { ConfigurationChangeEvent, ViewColumn } from 'vscode'; +import { ConfigurationTarget, Disposable, workspace } from 'vscode'; +import { extensionPrefix } from '../../constants'; import type { Container } from '../../container'; -import { registerCommand } from '../../system/command'; -import { DidOpenAnchorNotificationType } from '../protocol'; -import { WebviewWithConfigBase } from '../webviewWithConfigBase'; +import { CommitFormatter } from '../../git/formatters/commitFormatter'; +import { GitCommit, GitCommitIdentity } from '../../git/models/commit'; +import { GitFileChange, GitFileIndexStatus } from '../../git/models/file'; +import { PullRequest } from '../../git/models/pullRequest'; +import type { SubscriptionChangeEvent } from '../../plus/gk/account/subscriptionService'; +import type { ConnectionStateChangeEvent } from '../../plus/integrations/integrationService'; +import { IssueIntegrationId } from '../../plus/integrations/providers/models'; +import { map } from '../../system/iterable'; +import type { ConfigPath, CoreConfigPath } from '../../system/vscode/configuration'; +import { configuration } from '../../system/vscode/configuration'; +import type { CustomConfigPath, IpcMessage } from '../protocol'; +import { + assertsConfigKeyValue, + DidChangeConfigurationNotification, + isCustomConfigKey, + UpdateConfigurationCommand, +} from '../protocol'; +import type { WebviewHost, WebviewProvider } from '../webviewProvider'; import type { State } from './protocol'; +import { + DidChangeAccountNotification, + DidChangeConnectedJiraNotification, + DidOpenAnchorNotification, + GenerateConfigurationPreviewRequest, +} from './protocol'; +import type { SettingsWebviewShowingArgs } from './registration'; -const anchorRegex = /.*?#(.*)/; - -export class SettingsWebview extends WebviewWithConfigBase { +export class SettingsWebviewProvider implements WebviewProvider { + private readonly _disposable: Disposable; private _pendingJumpToAnchor: string | undefined; - constructor(container: Container) { - super( - container, - 'gitlens.settings', - 'settings.html', - 'images/gitlens-icon.png', - 'GitLens Settings', - `${ContextKeys.WebviewPrefix}settings`, - 'settingsWebview', - Commands.ShowSettingsPage, + constructor( + protected readonly container: Container, + protected readonly host: WebviewHost, + ) { + this._disposable = Disposable.from( + configuration.onDidChangeAny(this.onAnyConfigurationChanged, this), + container.subscription.onDidChange(this.onSubscriptionChanged, this), + container.integrations.onDidChangeConnectionState(this.onIntegrationConnectionStateChanged, this), ); + } - this.disposables.push( - ...[ - Commands.ShowSettingsPageAndJumpToBranchesView, - Commands.ShowSettingsPageAndJumpToCommitsView, - Commands.ShowSettingsPageAndJumpToContributorsView, - Commands.ShowSettingsPageAndJumpToFileHistoryView, - Commands.ShowSettingsPageAndJumpToLineHistoryView, - Commands.ShowSettingsPageAndJumpToRemotesView, - Commands.ShowSettingsPageAndJumpToRepositoriesView, - Commands.ShowSettingsPageAndJumpToSearchAndCompareView, - Commands.ShowSettingsPageAndJumpToStashesView, - Commands.ShowSettingsPageAndJumpToTagsView, - Commands.ShowSettingsPageAndJumpToWorkTreesView, - Commands.ShowSettingsPageAndJumpToViews, - Commands.ShowSettingsPageAndJumpToCommitGraph, - Commands.ShowSettingsPageAndJumpToAutolinks, - ].map(c => { - // The show and jump commands are structured to have a # separating the base command from the anchor - let anchor: string | undefined; - const match = anchorRegex.exec(c); - if (match != null) { - [, anchor] = match; - } - - return registerCommand(c, (...args: any[]) => this.onShowAnchorCommand(anchor, ...args), this); - }), - ); + dispose() { + this._disposable.dispose(); } - protected override onReady() { - if (this._pendingJumpToAnchor != null) { - const anchor = this._pendingJumpToAnchor; - this._pendingJumpToAnchor = undefined; + onSubscriptionChanged(e: SubscriptionChangeEvent) { + void this.host.notify(DidChangeAccountNotification, { hasAccount: e.current.account != null }); + } - void this.notify(DidOpenAnchorNotificationType, { anchor: anchor, scrollBehavior: 'auto' }); + onIntegrationConnectionStateChanged(e: ConnectionStateChangeEvent) { + if (e.key === 'jira') { + void this.host.notify(DidChangeConnectedJiraNotification, { hasConnectedJira: e.reason === 'connected' }); } } - private onShowAnchorCommand(anchor?: string, ...args: any[]) { - if (anchor) { - if (this.isReady && this.visible) { - queueMicrotask( - () => void this.notify(DidOpenAnchorNotificationType, { anchor: anchor, scrollBehavior: 'smooth' }), - ); - return; - } - - this._pendingJumpToAnchor = anchor; - } + async getAccountState(): Promise { + return (await this.container.subscription.getSubscription()).account != null; + } - this.onShowCommand(...args); + async getJiraConnected(): Promise { + const jira = await this.container.integrations.get(IssueIntegrationId.Jira); + if (jira == null) return false; + return jira.maybeConnected ?? jira.isConnected(); } - protected override includeBootstrap(): State { + async includeBootstrap(): Promise { const scopes: ['user' | 'workspace', string][] = [['user', 'User']]; if (workspace.workspaceFolders?.length) { scopes.push(['workspace', 'Workspace']); } return { + ...this.host.baseWebviewState, + version: this.container.version, // Make sure to get the raw config, not from the container which has the modes mixed in config: configuration.getAll(true), customSettings: this.getCustomSettings(), scope: 'user', scopes: scopes, + hasAccount: await this.getAccountState(), + hasConnectedJira: await this.getJiraConnected(), }; } + + onReloaded(): void { + void this.notifyDidChangeConfiguration(); + } + + onShowing?( + loading: boolean, + _options: { column?: ViewColumn; preserveFocus?: boolean }, + ...args: SettingsWebviewShowingArgs + ): boolean | Promise { + const anchor = args[0]; + if (anchor && typeof anchor === 'string') { + if (!loading && this.host.ready && this.host.visible) { + queueMicrotask( + () => + void this.host.notify(DidOpenAnchorNotification, { + anchor: anchor, + scrollBehavior: 'smooth', + }), + ); + return true; + } + + this._pendingJumpToAnchor = anchor; + } + + return true; + } + + onActiveChanged(active: boolean): void { + // Anytime the webview becomes active, make sure it has the most up-to-date config + if (active) { + void this.notifyDidChangeConfiguration(); + } + } + + onReady() { + if (this._pendingJumpToAnchor != null) { + const anchor = this._pendingJumpToAnchor; + this._pendingJumpToAnchor = undefined; + + void this.host.notify(DidOpenAnchorNotification, { anchor: anchor, scrollBehavior: 'auto' }); + } + } + + async onMessageReceived(e: IpcMessage) { + if (e == null) return; + + switch (true) { + case UpdateConfigurationCommand.is(e): { + const { params } = e; + const target = + params.scope === 'workspace' ? ConfigurationTarget.Workspace : ConfigurationTarget.Global; + + let key: keyof typeof params.changes; + for (key in params.changes) { + let value = params.changes[key]; + + if (isCustomConfigKey(key)) { + const customSetting = this.customSettings.get(key); + if (customSetting != null) { + if (typeof value === 'boolean') { + await customSetting.update(value); + } else { + debugger; + } + } + + continue; + } + + assertsConfigKeyValue(key, value); + + const inspect = configuration.inspect(key)!; + + if (value != null) { + if (params.scope === 'workspace') { + if (value === inspect.workspaceValue) continue; + } else { + if (value === inspect.globalValue && value !== inspect.defaultValue) continue; + + if (value === inspect.defaultValue) { + value = undefined; + } + } + } + + await configuration.update(key, value, target); + } + + for (const key of params.removes) { + await configuration.update(key as ConfigPath, undefined, target); + } + break; + } + + case GenerateConfigurationPreviewRequest.is(e): + switch (e.params.type) { + case 'commit': + case 'commit-uncommitted': { + const commit = new GitCommit( + this.container, + '~/code/eamodio/vscode-gitlens-demo', + 'fe26af408293cba5b4bfd77306e1ac9ff7ccaef8', + new GitCommitIdentity('You', 'eamodio@gmail.com', new Date('2016-11-12T20:41:00.000Z')), + new GitCommitIdentity('You', 'eamodio@gmail.com', new Date('2020-11-01T06:57:21.000Z')), + e.params.type === 'commit-uncommitted' ? 'Uncommitted changes' : 'Supercharged', + ['3ac1d3f51d7cf5f438cc69f25f6740536ad80fef'], + e.params.type === 'commit-uncommitted' ? 'Uncommitted changes' : 'Supercharged', + new GitFileChange( + '~/code/eamodio/vscode-gitlens-demo', + 'code.ts', + GitFileIndexStatus.Modified, + ), + undefined, + [], + ); + + let includePullRequest = false; + switch (e.params.key) { + case configuration.name('currentLine.format'): + includePullRequest = configuration.get('currentLine.pullRequests.enabled'); + break; + case configuration.name('statusBar.format'): + includePullRequest = configuration.get('statusBar.pullRequests.enabled'); + break; + } + + let pr: PullRequest | undefined; + if (includePullRequest) { + pr = new PullRequest( + { id: 'github', name: 'GitHub', domain: 'github.com', icon: 'github' }, + { + id: 'eamodio', + name: 'Eric Amodio', + avatarUrl: 'https://avatars1.githubusercontent.com/u/641685?s=32&v=4', + url: 'https://github.com/eamodio', + }, + '1', + undefined, + 'Supercharged', + 'https://github.com/gitkraken/vscode-gitlens/pulls/1', + { owner: 'gitkraken', repo: 'vscode-gitlens' }, + 'merged', + new Date('Sat, 12 Nov 2016 19:41:00 GMT'), + new Date('Sat, 12 Nov 2016 19:41:00 GMT'), + undefined, + new Date('Sat, 12 Nov 2016 20:41:00 GMT'), + ); + } + + let preview; + try { + preview = CommitFormatter.fromTemplate(e.params.format, commit, { + dateFormat: configuration.get('defaultDateFormat'), + pullRequest: pr, + messageTruncateAtNewLine: true, + }); + } catch { + preview = 'Invalid format'; + } + + await this.host.respond(GenerateConfigurationPreviewRequest, e, { preview: preview }); + } + } + break; + } + } + + private onAnyConfigurationChanged(e: ConfigurationChangeEvent) { + if (!configuration.changedAny(e, extensionPrefix)) { + const notify = configuration.changedAny(e, [ + ...map(this.customSettings.values(), s => s.name), + ]); + if (!notify) return; + } + + void this.notifyDidChangeConfiguration(); + } + + private _customSettings: Map | undefined; + private get customSettings() { + if (this._customSettings == null) { + this._customSettings = new Map([ + [ + 'rebaseEditor.enabled', + { + name: 'workbench.editorAssociations', + enabled: () => this.container.rebaseEditor.enabled, + update: this.container.rebaseEditor.setEnabled, + }, + ], + [ + 'currentLine.useUncommittedChangesFormat', + { + name: 'currentLine.uncommittedChangesFormat', + enabled: () => configuration.get('currentLine.uncommittedChangesFormat') != null, + update: async enabled => + configuration.updateEffective( + 'currentLine.uncommittedChangesFormat', + // eslint-disable-next-line no-template-curly-in-string + enabled ? 'âœī¸ ${ago}' : null, + ), + }, + ], + ]); + } + return this._customSettings; + } + + protected getCustomSettings(): Record { + const customSettings: Record = Object.create(null); + for (const [key, setting] of this.customSettings) { + customSettings[key] = setting.enabled(); + } + return customSettings; + } + + private notifyDidChangeConfiguration() { + // Make sure to get the raw config, not from the container which has the modes mixed in + return this.host.notify(DidChangeConfigurationNotification, { + config: configuration.getAll(true), + customSettings: this.getCustomSettings(), + }); + } +} + +interface CustomSetting { + name: ConfigPath | CoreConfigPath; + enabled: () => boolean; + update: (enabled: boolean) => Promise; } diff --git a/src/webviews/webviewBase.ts b/src/webviews/webviewBase.ts deleted file mode 100644 index e793527ff8db1..0000000000000 --- a/src/webviews/webviewBase.ts +++ /dev/null @@ -1,369 +0,0 @@ -import type { - Webview, - WebviewOptions, - WebviewPanel, - WebviewPanelOnDidChangeViewStateEvent, - WebviewPanelOptions, - WindowState, -} from 'vscode'; -import { Disposable, Uri, ViewColumn, window, workspace } from 'vscode'; -import { getNonce } from '@env/crypto'; -import type { Commands, ContextKeys } from '../constants'; -import type { Container } from '../container'; -import { setContext } from '../context'; -import { executeCommand, registerCommand } from '../system/command'; -import { debug, log, logName } from '../system/decorators/log'; -import { serialize } from '../system/decorators/serialize'; -import type { TrackedUsageFeatures } from '../usageTracker'; -import type { IpcMessage, IpcMessageParams, IpcNotificationType, WebviewFocusChangedParams } from './protocol'; -import { ExecuteCommandType, onIpc, WebviewFocusChangedCommandType, WebviewReadyCommandType } from './protocol'; - -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) - -let ipcSequence = 0; -function nextIpcId() { - if (ipcSequence === maxSmallIntegerV8) { - ipcSequence = 1; - } else { - ipcSequence++; - } - - return `host:${ipcSequence}`; -} - -export type WebviewIds = 'graph' | 'settings' | 'timeline' | 'welcome' | 'focus'; - -@logName>((c, name) => `${name}(${c.id})`) -export abstract class WebviewBase implements Disposable { - protected readonly disposables: Disposable[] = []; - protected isReady: boolean = false; - private _disposablePanel: Disposable | undefined; - protected _panel: WebviewPanel | undefined; - - constructor( - protected readonly container: Container, - public readonly id: `gitlens.${WebviewIds}`, - private readonly fileName: string, - private readonly iconPath: string, - title: string, - private readonly contextKeyPrefix: `${ContextKeys.WebviewPrefix}${WebviewIds}`, - private readonly trackingFeature: TrackedUsageFeatures, - showCommand: Commands, - ) { - this._originalTitle = this._title = title; - this.disposables.push(registerCommand(showCommand, this.onShowCommand, this)); - } - - dispose() { - this.disposables.forEach(d => void d.dispose()); - this._disposablePanel?.dispose(); - } - - protected get options(): WebviewPanelOptions & WebviewOptions { - return { - retainContextWhenHidden: true, - enableFindWidget: true, - enableCommandUris: true, - enableScripts: true, - }; - } - private _originalTitle: string | undefined; - get originalTitle(): string | undefined { - return this._originalTitle; - } - - private _title: string; - get title(): string { - return this._panel?.title ?? this._title; - } - set title(title: string) { - this._title = title; - if (this._panel == null) return; - - this._panel.title = title; - } - - get visible() { - return this._panel?.visible ?? false; - } - - @log() - hide() { - this._panel?.dispose(); - } - - @log({ args: false }) - async show(options?: { column?: ViewColumn; preserveFocus?: boolean }, ..._args: unknown[]): Promise { - void this.container.usage.track(`${this.trackingFeature}:shown`); - - let column = options?.column ?? ViewColumn.Beside; - // Only try to open beside if there is an active tab - if (column === ViewColumn.Beside && window.tabGroups.activeTabGroup.activeTab == null) { - column = ViewColumn.Active; - } - - if (this._panel == null) { - this._panel = window.createWebviewPanel( - this.id, - this._title, - { viewColumn: column, preserveFocus: options?.preserveFocus ?? false }, - this.options, - ); - - this._panel.iconPath = Uri.file(this.container.context.asAbsolutePath(this.iconPath)); - this._disposablePanel = Disposable.from( - this._panel, - this._panel.onDidDispose(this.onPanelDisposed, this), - this._panel.onDidChangeViewState(this.onViewStateChanged, this), - this._panel.webview.onDidReceiveMessage(this.onMessageReceivedCore, this), - ...(this.onInitializing?.() ?? []), - ...(this.registerCommands?.() ?? []), - window.onDidChangeWindowState(this.onWindowStateChanged, this), - ); - - this._panel.webview.html = await this.getHtml(this._panel.webview); - } else { - await this.refresh(true); - this._panel.reveal(this._panel.viewColumn ?? ViewColumn.Active, options?.preserveFocus ?? false); - } - } - - private readonly _cspNonce = getNonce(); - protected get cspNonce(): string { - return this._cspNonce; - } - - protected onInitializing?(): Disposable[] | undefined; - protected onReady?(): void; - protected onMessageReceived?(e: IpcMessage): void; - protected onActiveChanged?(active: boolean): void; - protected onFocusChanged?(focused: boolean): void; - protected onVisibilityChanged?(visible: boolean): void; - protected onWindowFocusChanged?(focused: boolean): void; - - protected registerCommands?(): Disposable[]; - - protected includeBootstrap?(): State | Promise; - protected includeHead?(): string | Promise; - protected includeBody?(): string | Promise; - protected includeEndOfBody?(): string | Promise; - - private onWindowStateChanged(e: WindowState) { - if (!this.visible) return; - - this.onWindowFocusChanged?.(e.focused); - } - - @debug() - protected async refresh(force?: boolean): Promise { - if (this._panel == null) return; - - // Mark the webview as not ready, until we know if we are changing the html - this.isReady = false; - const html = await this.getHtml(this._panel.webview); - if (force) { - // Reset the html to get the webview to reload - this._panel.webview.html = ''; - } - - // If we aren't changing the html, mark the webview as ready again - if (this._panel.webview.html === html) { - this.isReady = true; - return; - } - - this._panel.webview.html = html; - } - - private resetContextKeys(): void { - void setContext(`${this.contextKeyPrefix}:inputFocus`, false); - void setContext(`${this.contextKeyPrefix}:focus`, false); - void setContext(`${this.contextKeyPrefix}:active`, false); - } - - private setContextKeys(active: boolean | undefined, focus?: boolean, inputFocus?: boolean): void { - if (active != null) { - void setContext(`${this.contextKeyPrefix}:active`, active); - - if (!active) { - focus = false; - inputFocus = false; - } - } - if (focus != null) { - void setContext(`${this.contextKeyPrefix}:focus`, focus); - } - if (inputFocus != null) { - void setContext(`${this.contextKeyPrefix}:inputFocus`, inputFocus); - } - } - - private onPanelDisposed() { - this.resetContextKeys(); - - this.onActiveChanged?.(false); - this.onFocusChanged?.(false); - this.onVisibilityChanged?.(false); - - this.isReady = false; - this._disposablePanel?.dispose(); - this._disposablePanel = undefined; - this._panel = undefined; - } - - protected onShowCommand(...args: unknown[]): void { - void this.show(undefined, ...args); - } - - @debug['onViewFocusChanged']>({ - args: { 0: e => `focused=${e.focused}, inputFocused=${e.inputFocused}` }, - }) - protected onViewFocusChanged(e: WebviewFocusChangedParams): void { - this.setContextKeys(undefined, e.focused, e.inputFocused); - this.onFocusChanged?.(e.focused); - } - - @debug['onViewStateChanged']>({ - args: { 0: e => `active=${e.webviewPanel.active}, visible=${e.webviewPanel.visible}` }, - }) - protected onViewStateChanged(e: WebviewPanelOnDidChangeViewStateEvent): void { - const { active, visible } = e.webviewPanel; - if (visible) { - this.setContextKeys(active); - this.onActiveChanged?.(active); - if (!active) { - this.onFocusChanged?.(false); - } - } else { - this.resetContextKeys(); - - this.onActiveChanged?.(false); - this.onFocusChanged?.(false); - } - - this.onVisibilityChanged?.(visible); - } - - @debug['onMessageReceivedCore']>({ - args: { 0: e => (e != null ? `${e.id}: method=${e.method}` : '') }, - }) - protected onMessageReceivedCore(e: IpcMessage) { - if (e == null) return; - - switch (e.method) { - case WebviewReadyCommandType.method: - onIpc(WebviewReadyCommandType, e, () => { - this.isReady = true; - this.onReady?.(); - }); - - break; - - case WebviewFocusChangedCommandType.method: - onIpc(WebviewFocusChangedCommandType, e, params => { - this.onViewFocusChanged(params); - }); - - break; - - case ExecuteCommandType.method: - onIpc(ExecuteCommandType, e, params => { - if (params.args != null) { - void executeCommand(params.command as Commands, ...params.args); - } else { - void executeCommand(params.command as Commands); - } - }); - break; - - default: - this.onMessageReceived?.(e); - break; - } - } - - private async getHtml(webview: Webview): Promise { - const webRootUri = Uri.joinPath(this.container.context.extensionUri, 'dist', 'webviews'); - const uri = Uri.joinPath(webRootUri, this.fileName); - const content = new TextDecoder('utf8').decode(await workspace.fs.readFile(uri)); - - const [bootstrap, head, body, endOfBody] = await Promise.all([ - this.includeBootstrap?.(), - this.includeHead?.(), - this.includeBody?.(), - this.includeEndOfBody?.(), - ]); - - const cspSource = webview.cspSource; - - const root = webview.asWebviewUri(this.container.context.extensionUri).toString(); - const webRoot = webview.asWebviewUri(webRootUri).toString(); - - const html = content.replace( - /#{(head|body|endOfBody|placement|cspSource|cspNonce|root|webroot)}/g, - (_substring: string, token: string) => { - switch (token) { - case 'head': - return head ?? ''; - case 'body': - return body ?? ''; - case 'endOfBody': - return `${ - bootstrap != null - ? `` - : '' - }${endOfBody ?? ''}`; - case 'placement': - return 'editor'; - case 'cspSource': - return cspSource; - case 'cspNonce': - return this.cspNonce; - case 'root': - return root; - case 'webroot': - return webRoot; - default: - return ''; - } - }, - ); - - return html; - } - - protected nextIpcId(): string { - return nextIpcId(); - } - - protected notify>( - type: T, - params: IpcMessageParams, - completionId?: string, - ): Promise { - return this.postMessage({ - id: this.nextIpcId(), - method: type.method, - params: params, - completionId: completionId, - }); - } - - @serialize() - @debug['postMessage']>({ - args: { - 0: m => `{"id":${m.id},"method":${m.method}${m.completionId ? `,"completionId":${m.completionId}` : ''}}`, - }, - }) - protected postMessage(message: IpcMessage): Promise { - if (this._panel == null || !this.isReady || !this.visible) return Promise.resolve(false); - - // It looks like there is a bug where `postMessage` can sometimes just hang infinitely. Not sure why, but ensure we don't hang - return Promise.race([ - this._panel.webview.postMessage(message), - new Promise(resolve => setTimeout(resolve, 5000, false)), - ]); - } -} diff --git a/src/webviews/webviewCommandRegistrar.ts b/src/webviews/webviewCommandRegistrar.ts new file mode 100644 index 0000000000000..2ee67ddba2b3c --- /dev/null +++ b/src/webviews/webviewCommandRegistrar.ts @@ -0,0 +1,75 @@ +import type { Disposable } from 'vscode'; +import type { CommandCallback } from '../system/vscode/command'; +import { registerWebviewCommand } from '../system/vscode/command'; +import type { WebviewContext } from '../system/webview'; +import { isWebviewContext } from '../system/webview'; +import type { WebviewProvider } from './webviewProvider'; + +export type WebviewCommandCallback> = (arg?: T | undefined) => any; +export class WebviewCommandRegistrar implements Disposable { + private readonly _commandRegistrations = new Map< + string, + { handlers: Map; subscription: Disposable } + >(); + + dispose() { + this._commandRegistrations.forEach(({ subscription }) => void subscription.dispose()); + } + + registerCommand>( + provider: T, + id: string, + instanceId: string | undefined, + command: string, + callback: CommandCallback, + ) { + let registration = this._commandRegistrations.get(command); + if (registration == null) { + const handlers = new Map(); + registration = { + subscription: registerWebviewCommand( + command, + (...args: any[]) => { + const [context] = args; + if (!isWebviewContext(context)) { + debugger; + return; + } + + const key = context.webviewInstance + ? `${context.webview}:${context.webviewInstance}` + : context.webview; + + const handler = handlers.get(key); + if (handler == null) { + throw new Error(`Unable to find Command '${command}' registration for Webview '${key}'`); + } + + handler.callback.call(handler.thisArg, context); + }, + this, + ), + handlers: handlers, + }; + this._commandRegistrations.set(command, registration); + } + + const key = instanceId ? `${id}:${instanceId}` : id; + + if (registration.handlers.has(key)) { + throw new Error(`Command '${command}' has already been registered for Webview '${key}'`); + } + + registration.handlers.set(key, { callback: callback, thisArg: provider }); + + return { + dispose: () => { + registration.handlers.delete(key); + if (registration.handlers.size === 0) { + this._commandRegistrations.delete(command); + registration.subscription.dispose(); + } + }, + }; + } +} diff --git a/src/webviews/webviewController.ts b/src/webviews/webviewController.ts new file mode 100644 index 0000000000000..89485d7fd4d56 --- /dev/null +++ b/src/webviews/webviewController.ts @@ -0,0 +1,744 @@ +import { getNonce } from '@env/crypto'; +import type { ViewBadge, Webview, WebviewPanel, WebviewView, WindowState } from 'vscode'; +import { Disposable, EventEmitter, Uri, ViewColumn, window, workspace } from 'vscode'; +import type { Commands } from '../constants.commands'; +import type { CustomEditorTypes, WebviewTypes, WebviewViewTypes } from '../constants.views'; +import type { Container } from '../container'; +import { getScopedCounter } from '../system/counter'; +import { debug, logName } from '../system/decorators/log'; +import { serialize } from '../system/decorators/serialize'; +import { getLoggableName, Logger } from '../system/logger'; +import { getLogScope, getNewLogScope, setLogScopeExit } from '../system/logger.scope'; +import { isPromise, pauseOnCancelOrTimeout } from '../system/promise'; +import { maybeStopWatch } from '../system/stopwatch'; +import { executeCommand, executeCoreCommand } from '../system/vscode/command'; +import { setContext } from '../system/vscode/context'; +import type { WebviewContext } from '../system/webview'; +import type { + IpcCallMessageType, + IpcCallParamsType, + IpcCallResponseParamsType, + IpcMessage, + IpcNotification, + IpcRequest, + WebviewFocusChangedParams, + WebviewState, +} from './protocol'; +import { + DidChangeHostWindowFocusNotification, + DidChangeWebviewFocusNotification, + ExecuteCommand, + WebviewFocusChangedCommand, + WebviewReadyCommand, +} from './protocol'; +import type { WebviewCommandCallback, WebviewCommandRegistrar } from './webviewCommandRegistrar'; +import type { WebviewHost, WebviewProvider, WebviewShowingArgs } from './webviewProvider'; +import type { WebviewPanelDescriptor, WebviewShowOptions, WebviewViewDescriptor } from './webviewsController'; + +const ipcSequencer = getScopedCounter(); +const utf8TextDecoder = new TextDecoder('utf8'); +const utf8TextEncoder = new TextEncoder(); + +type GetParentType = T extends WebviewPanelDescriptor + ? WebviewPanel + : T extends WebviewViewDescriptor + ? WebviewView + : never; + +type WebviewPanelController< + State, + SerializedState = State, + ShowingArgs extends unknown[] = unknown[], +> = WebviewController; +type WebviewViewController< + State, + SerializedState = State, + ShowingArgs extends unknown[] = unknown[], +> = WebviewController; + +@logName>(c => `WebviewController(${c.id}${c.instanceId != null ? `|${c.instanceId}` : ''})`) +export class WebviewController< + State, + SerializedState = State, + ShowingArgs extends unknown[] = unknown[], + Descriptor extends WebviewPanelDescriptor | WebviewViewDescriptor = + | WebviewPanelDescriptor + | WebviewViewDescriptor, + > + implements WebviewHost, Disposable +{ + static async create( + container: Container, + commandRegistrar: WebviewCommandRegistrar, + descriptor: WebviewPanelDescriptor, + instanceId: string | undefined, + parent: WebviewPanel, + resolveProvider: ( + container: Container, + host: WebviewHost, + ) => Promise>, + ): Promise>; + static async create( + container: Container, + commandRegistrar: WebviewCommandRegistrar, + descriptor: WebviewViewDescriptor, + instanceId: string | undefined, + parent: WebviewView, + resolveProvider: ( + container: Container, + host: WebviewHost, + ) => Promise>, + ): Promise>; + static async create( + container: Container, + commandRegistrar: WebviewCommandRegistrar, + descriptor: WebviewPanelDescriptor | WebviewViewDescriptor, + instanceId: string | undefined, + parent: WebviewPanel | WebviewView, + resolveProvider: ( + container: Container, + host: WebviewHost, + ) => Promise>, + ): Promise> { + const controller = new WebviewController( + container, + commandRegistrar, + descriptor, + instanceId, + parent, + resolveProvider, + ); + await controller.initialize(); + return controller; + } + + private readonly _onDidDispose = new EventEmitter(); + get onDidDispose() { + return this._onDidDispose.event; + } + + readonly id: Descriptor['id']; + + private _ready: boolean = false; + get ready() { + return this._ready; + } + + private disposable: Disposable | undefined; + private _isInEditor: boolean; + private /*readonly*/ provider!: WebviewProvider; + private readonly webview: Webview; + + private constructor( + private readonly container: Container, + private readonly _commandRegistrar: WebviewCommandRegistrar, + private readonly descriptor: Descriptor, + public readonly instanceId: string | undefined, + public readonly parent: GetParentType, + resolveProvider: ( + container: Container, + host: WebviewHost, + ) => Promise>, + ) { + this.id = descriptor.id; + this.webview = parent.webview; + + const isInEditor = 'onDidChangeViewState' in parent; + this._isInEditor = isInEditor; + this._originalTitle = descriptor.title; + parent.title = descriptor.title; + + this._initializing = resolveProvider(container, this).then(provider => { + this.provider = provider; + if (this._disposed) { + provider.dispose(); + return; + } + + this.disposable = Disposable.from( + window.onDidChangeWindowState(this.onWindowStateChanged, this), + parent.webview.onDidReceiveMessage(this.onMessageReceivedCore, this), + isInEditor + ? parent.onDidChangeViewState(({ webviewPanel: { visible, active } }) => + this.onParentVisibilityChanged(visible, active), + ) + : parent.onDidChangeVisibility(() => this.onParentVisibilityChanged(this.visible, this.active)), + parent.onDidDispose(this.onParentDisposed, this), + ...(this.provider.registerCommands?.() ?? []), + this.provider, + ); + }); + } + + private _disposed: boolean = false; + dispose() { + this._disposed = true; + resetContextKeys(this.descriptor.contextKeyPrefix); + + this.provider?.onFocusChanged?.(false); + this.provider?.onVisibilityChanged?.(false); + + this._ready = false; + + this._onDidDispose.fire(); + this.disposable?.dispose(); + } + + registerWebviewCommand>(command: string, callback: WebviewCommandCallback) { + return this._commandRegistrar.registerCommand(this.provider, this.id, this.instanceId, command, callback); + } + + private _initializing: Promise | undefined; + private async initialize() { + if (this._initializing == null) return; + + await this._initializing; + this._initializing = undefined; + } + + isHost(type: 'editor'): this is WebviewPanelController; + isHost(type: 'view'): this is WebviewViewController; + isHost( + type: 'editor' | 'view', + ): this is + | WebviewPanelController + | WebviewViewController { + return type === 'editor' ? this._isInEditor : !this._isInEditor; + } + + get active() { + if ('active' in this.parent) { + return this._disposed ? false : this.parent.active; + } + return this._disposed ? false : undefined; + } + + get badge(): ViewBadge | undefined { + return 'badge' in this.parent ? this.parent.badge : undefined; + } + set badge(value: ViewBadge | undefined) { + if ('badge' in this.parent) { + this.parent.badge = value; + } else { + throw new Error("The 'badge' property not supported on Webview parent"); + } + } + + private _description: string | undefined; + get description(): string | undefined { + if ('description' in this.parent) { + return this.parent.description; + } + return this._description; + } + set description(value: string | undefined) { + if ('description' in this.parent) { + this.parent.description = value; + } + this._description = value; + } + + private _originalTitle: string; + get originalTitle(): string { + return this._originalTitle; + } + + get title(): string { + return this.parent.title ?? this._originalTitle; + } + set title(value: string) { + this.parent.title = value; + } + + get visible() { + return this._disposed ? false : this.parent.visible; + } + + canReuseInstance( + options?: WebviewShowOptions, + ...args: WebviewShowingArgs + ): boolean | undefined { + if (!this.isHost('editor')) return undefined; + + if (options?.column != null && options.column !== this.parent.viewColumn) return false; + return this.provider.canReuseInstance?.(...args); + } + + getSplitArgs(): WebviewShowingArgs { + if (this.isHost('view')) return []; + + return this.provider.getSplitArgs?.() ?? []; + } + + @debug({ args: false }) + async show( + loading: boolean, + options?: WebviewShowOptions, + ...args: WebviewShowingArgs + ): Promise { + if (options == null) { + options = {}; + } + + const result = this.provider.onShowing?.(loading, options, ...args); + if (result != null) { + if (isPromise(result)) { + if ((await result) === false) return; + } else if (result === false) { + return; + } + } + + if (loading) { + this.webview.html = await this.getHtml(this.webview); + } + + if (this.isHost('editor')) { + if (!loading) { + this.parent.reveal( + options.column ?? this.parent.viewColumn ?? this.descriptor.column ?? ViewColumn.Beside, + options.preserveFocus ?? false, + ); + } + } else if (this.isHost('view')) { + await executeCoreCommand(`${this.id}.focus`, options); + if (loading) { + this.provider.onVisibilityChanged?.(true); + } + } + + setContextKeys(this.descriptor.contextKeyPrefix); + } + + get baseWebviewState(): WebviewState { + return { + webviewId: this.id, + webviewInstanceId: this.instanceId, + timestamp: Date.now(), + }; + } + + private readonly _cspNonce = getNonce(); + get cspNonce(): string { + return this._cspNonce; + } + + asWebviewUri(uri: Uri): Uri { + return this.webview.asWebviewUri(uri); + } + + @debug() + async refresh(force?: boolean): Promise { + if (force) { + this.clearPendingIpcNotifications(); + } + this.provider.onRefresh?.(force); + + // Mark the webview as not ready, until we know if we are changing the html + const wasReady = this._ready; + this._ready = false; + + const html = await this.getHtml(this.webview); + if (force) { + // Reset the html to get the webview to reload + this.webview.html = ''; + } + + // If we aren't changing the html, mark the webview as ready again + if (this.webview.html === html) { + if (wasReady) { + this._ready = true; + } + return; + } + + this.webview.html = html; + } + + @debug() + private onParentDisposed() { + this.dispose(); + } + + @debug['onMessageReceivedCore']>({ + args: { 0: e => (e != null ? `${e.id}, method=${e.method}` : '') }, + }) + private onMessageReceivedCore(e: IpcMessage) { + if (e == null) return; + + switch (true) { + case WebviewReadyCommand.is(e): + this._ready = true; + this.sendPendingIpcNotifications(); + this.provider.onReady?.(); + + break; + + case WebviewFocusChangedCommand.is(e): + this.onViewFocusChanged(e.params); + + break; + + case ExecuteCommand.is(e): + if (e.params.args != null) { + void executeCommand(e.params.command as Commands, ...e.params.args); + } else { + void executeCommand(e.params.command as Commands); + } + break; + + default: + this.provider.onMessageReceived?.(e); + break; + } + } + + @debug['onViewFocusChanged']>({ + args: { 0: e => `focused=${e.focused}, inputFocused=${e.inputFocused}` }, + }) + onViewFocusChanged(e: WebviewFocusChangedParams): void { + setContextKeys(this.descriptor.contextKeyPrefix); + this.handleFocusChanged(e.focused); + } + + @debug() + private onParentVisibilityChanged(visible: boolean, active?: boolean) { + if (this.descriptor.webviewHostOptions?.retainContextWhenHidden !== true) { + if (visible) { + if (this._ready) { + this.sendPendingIpcNotifications(); + } else if (this.provider.onReloaded != null) { + this.clearPendingIpcNotifications(); + this.provider.onReloaded(); + } else { + void this.refresh(); + } + } else { + this._ready = false; + } + } + + if (visible) { + void this.container.usage.track(`${this.descriptor.trackingFeature}:shown`); + + setContextKeys(this.descriptor.contextKeyPrefix); + if (active != null) { + this.provider.onActiveChanged?.(active); + if (!active) { + this.handleFocusChanged(false); + } + } + } else { + resetContextKeys(this.descriptor.contextKeyPrefix); + + if (active != null) { + this.provider.onActiveChanged?.(false); + } + this.handleFocusChanged(false); + } + + this.provider.onVisibilityChanged?.(visible); + } + + private onWindowStateChanged(e: WindowState) { + if (!this.visible) return; + + void this.notify(DidChangeHostWindowFocusNotification, { focused: e.focused }); + this.provider.onWindowFocusChanged?.(e.focused); + } + + private handleFocusChanged(focused: boolean) { + void this.notify(DidChangeWebviewFocusNotification, { focused: focused }); + this.provider.onFocusChanged?.(focused); + } + + getRootUri() { + return this.container.context.extensionUri; + } + + private _webRoot: string | undefined; + getWebRoot() { + if (this._webRoot == null) { + this._webRoot = this.asWebviewUri(this.getWebRootUri()).toString(); + } + return this._webRoot; + } + + private _webRootUri: Uri | undefined; + getWebRootUri() { + if (this._webRootUri == null) { + this._webRootUri = Uri.joinPath(this.getRootUri(), 'dist', 'webviews'); + } + return this._webRootUri; + } + + private async getHtml(webview: Webview): Promise { + const webRootUri = this.getWebRootUri(); + const uri = Uri.joinPath(webRootUri, this.descriptor.fileName); + + const [bytes, bootstrap, head, body, endOfBody] = await Promise.all([ + workspace.fs.readFile(uri), + this.provider.includeBootstrap?.(), + this.provider.includeHead?.(), + this.provider.includeBody?.(), + this.provider.includeEndOfBody?.(), + ]); + + const html = replaceWebviewHtmlTokens( + utf8TextDecoder.decode(bytes), + this.id, + this.instanceId, + webview.cspSource, + this._cspNonce, + this.asWebviewUri(this.getRootUri()).toString(), + this.getWebRoot(), + this.isHost('editor') ? 'editor' : 'view', + bootstrap, + head, + body, + endOfBody, + ); + return html; + } + + nextIpcId(): string { + return `host:${ipcSequencer.next()}`; + } + + async notify>( + notificationType: T, + params: IpcCallParamsType, + completionId?: string, + ): Promise { + let packed; + if (notificationType.pack && params != null) { + const sw = maybeStopWatch( + getNewLogScope(`${getLoggableName(this)}.notify serializing msg=${notificationType.method}`, true), + { + log: false, + logLevel: 'debug', + }, + ); + packed = utf8TextEncoder.encode(JSON.stringify(params)); + sw?.stop(); + } + + const msg: IpcMessage | Uint8Array> = { + id: this.nextIpcId(), + scope: notificationType.scope, + method: notificationType.method, + params: packed ?? params, + packed: packed != null, + completionId: completionId, + }; + + const success = await this.postMessage(msg); + if (success) { + this._pendingIpcNotifications.clear(); + } else { + this.addPendingIpcNotificationCore(notificationType, msg); + } + return success; + } + + respond>( + requestType: T, + msg: IpcCallMessageType, + params: IpcCallResponseParamsType, + ): Promise { + return this.notify(requestType.response, params, msg.completionId); + } + + @serialize() + @debug['postMessage']>({ + args: false, + enter: m => `(${m.id}|${m.method}${m.completionId ? `+${m.completionId}` : ''})`, + }) + private async postMessage(message: IpcMessage): Promise { + if (!this._ready) return Promise.resolve(false); + + const scope = getLogScope(); + let timeout: ReturnType | undefined; + + // It looks like there is a bug where `postMessage` can sometimes just hang infinitely. Not sure why, but ensure we don't hang forever + const promise = Promise.race([ + this.webview.postMessage(message).then( + s => { + clearTimeout(timeout); + return s; + }, + (ex: unknown) => { + clearTimeout(timeout); + Logger.error(ex, scope); + debugger; + return false; + }, + ), + new Promise(resolve => { + timeout = setTimeout(() => { + debugger; + setLogScopeExit(scope, undefined, 'TIMEDOUT'); + resolve(false); + }, 30000); + }), + ]); + + let success; + + if (this.isHost('view')) { + // If we are in a view, show progress if we are waiting too long + const result = await pauseOnCancelOrTimeout(promise, undefined, 100); + if (result.paused) { + success = await window.withProgress({ location: { viewId: this.id } }, () => result.value); + } else { + success = result.value; + } + } else { + success = await promise; + } + + return success; + } + + private _pendingIpcNotifications = new Map Promise)>(); + + addPendingIpcNotification( + type: IpcNotification, + mapping: Map, () => Promise>, + thisArg: any, + ) { + this.addPendingIpcNotificationCore(type, mapping.get(type)?.bind(thisArg)); + } + + private addPendingIpcNotificationCore( + type: IpcNotification, + msgOrFn: IpcMessage | (() => Promise) | undefined, + ) { + if (type.reset) { + this._pendingIpcNotifications.clear(); + } + + if (msgOrFn == null) { + debugger; + return; + } + this._pendingIpcNotifications.set(type, msgOrFn); + } + + clearPendingIpcNotifications() { + this._pendingIpcNotifications.clear(); + } + + sendPendingIpcNotifications() { + if (!this._ready || this._pendingIpcNotifications.size === 0) return; + + const ipcs = new Map(this._pendingIpcNotifications); + this._pendingIpcNotifications.clear(); + for (const msgOrFn of ipcs.values()) { + if (typeof msgOrFn === 'function') { + void msgOrFn(); + } else { + void this.postMessage(msgOrFn); + } + } + } +} + +export function replaceWebviewHtmlTokens( + html: string, + webviewId: string, + webviewInstanceId: string | undefined, + cspSource: string, + cspNonce: string, + root: string, + webRoot: string, + placement: 'editor' | 'view', + bootstrap?: SerializedState, + head?: string, + body?: string, + endOfBody?: string, +) { + return html.replace( + /#{(head|body|endOfBody|webviewId|webviewInstanceId|placement|cspSource|cspNonce|root|webroot|state)}/g, + (_substring: string, token: string) => { + switch (token) { + case 'head': + return head ?? ''; + case 'body': + return body ?? ''; + case 'state': + return bootstrap != null ? JSON.stringify(bootstrap).replace(/"/g, '"') : ''; + case 'endOfBody': + return `${ + bootstrap != null + ? `` + : '' + }${endOfBody ?? ''}`; + case 'webviewId': + return webviewId; + case 'webviewInstanceId': + return webviewInstanceId ?? ''; + case 'placement': + return placement; + case 'cspSource': + return cspSource; + case 'cspNonce': + return cspNonce; + case 'root': + return root; + case 'webroot': + return webRoot; + default: + return ''; + } + }, + ); +} + +export function resetContextKeys( + contextKeyPrefix: `gitlens:webview:${WebviewTypes | CustomEditorTypes}` | `gitlens:webviewView:${WebviewViewTypes}`, +): void { + void setContext(`${contextKeyPrefix}:visible`, false); +} + +export function setContextKeys( + contextKeyPrefix: `gitlens:webview:${WebviewTypes | CustomEditorTypes}` | `gitlens:webviewView:${WebviewViewTypes}`, +): void { + void setContext(`${contextKeyPrefix}:visible`, true); +} + +export function updatePendingContext( + current: Context, + pending: Partial | undefined, + update: Partial, + force: boolean = false, +): [changed: boolean, pending: Partial | undefined] { + let changed = false; + for (const [key, value] of Object.entries(update)) { + const currentValue = (current as unknown as Record)[key]; + if ( + !force && + (currentValue instanceof Uri || value instanceof Uri) && + (currentValue as any)?.toString() === value?.toString() + ) { + continue; + } + + if (!force && currentValue === value) { + if ((value !== undefined || key in current) && (pending == null || !(key in pending))) { + continue; + } + } + + if (pending == null) { + pending = {}; + } + + (pending as Record)[key] = value; + changed = true; + } + + return [changed, pending]; +} diff --git a/src/webviews/webviewProvider.ts b/src/webviews/webviewProvider.ts new file mode 100644 index 0000000000000..5b7681554cd27 --- /dev/null +++ b/src/webviews/webviewProvider.ts @@ -0,0 +1,93 @@ +import type { Disposable, Uri, ViewBadge } from 'vscode'; +import type { WebviewContext } from '../system/webview'; +import type { + IpcCallMessageType, + IpcCallParamsType, + IpcCallResponseParamsType, + IpcMessage, + IpcNotification, + IpcRequest, + WebviewState, +} from './protocol'; +import type { WebviewCommandCallback } from './webviewCommandRegistrar'; +import type { WebviewPanelDescriptor, WebviewShowOptions, WebviewViewDescriptor } from './webviewsController'; + +export type WebviewShowingArgs = T | [{ state: Partial }] | []; + +export interface WebviewProvider + extends Disposable { + /** + * Determines whether the webview instance can be reused + * @returns `true` if the webview should be reused, `false` if it should NOT be reused, and `undefined` if it *could* be reused but not ideal + */ + canReuseInstance?(...args: WebviewShowingArgs): boolean | undefined; + getSplitArgs?(): WebviewShowingArgs; + onShowing?( + loading: boolean, + options: WebviewShowOptions, + ...args: WebviewShowingArgs + ): boolean | Promise; + registerCommands?(): Disposable[]; + + includeBootstrap?(): SerializedState | Promise; + includeHead?(): string | Promise; + includeBody?(): string | Promise; + includeEndOfBody?(): string | Promise; + + onReady?(): void; + onRefresh?(force?: boolean): void; + onReloaded?(): void; + onMessageReceived?(e: IpcMessage): void; + onActiveChanged?(active: boolean): void; + onFocusChanged?(focused: boolean): void; + onVisibilityChanged?(visible: boolean): void; + onWindowFocusChanged?(focused: boolean): void; +} + +export interface WebviewHost< + Descriptor extends WebviewPanelDescriptor | WebviewViewDescriptor = WebviewPanelDescriptor | WebviewViewDescriptor, +> { + readonly id: Descriptor['id']; + + readonly originalTitle: string; + title: string; + description: string | undefined; + badge: ViewBadge | undefined; + + readonly active: boolean | undefined; + readonly ready: boolean; + readonly visible: boolean; + readonly baseWebviewState: WebviewState; + readonly cspNonce: string; + + getWebRoot(): string; + asWebviewUri(uri: Uri): Uri; + + addPendingIpcNotification( + type: IpcNotification, + mapping: Map, () => Promise>, + thisArg: any, + ): void; + clearPendingIpcNotifications(): void; + sendPendingIpcNotifications(): void; + + isHost(type: 'editor'): this is WebviewHost; + isHost(type: 'view'): this is WebviewHost; + + notify>( + notificationType: T, + params: IpcCallParamsType, + completionId?: string, + ): Promise; + refresh(force?: boolean): Promise; + respond>( + responseType: T, + msg: IpcCallMessageType, + params: IpcCallResponseParamsType, + ): Promise; + registerWebviewCommand>( + command: string, + callback: WebviewCommandCallback, + ): Disposable; + show(loading: boolean, options?: WebviewShowOptions, ...args: unknown[]): Promise; +} diff --git a/src/webviews/webviewViewBase.ts b/src/webviews/webviewViewBase.ts deleted file mode 100644 index 88fcc9ccd8dde..0000000000000 --- a/src/webviews/webviewViewBase.ts +++ /dev/null @@ -1,344 +0,0 @@ -import type { - CancellationToken, - Webview, - WebviewView, - WebviewViewProvider, - WebviewViewResolveContext, - WindowState, -} from 'vscode'; -import { Disposable, Uri, window, workspace } from 'vscode'; -import { getNonce } from '@env/crypto'; -import type { Commands, ContextKeys } from '../constants'; -import type { Container } from '../container'; -import { setContext } from '../context'; -import { Logger } from '../logger'; -import { getLogScope } from '../logScope'; -import { executeCommand } from '../system/command'; -import { debug, log, logName } from '../system/decorators/log'; -import { serialize } from '../system/decorators/serialize'; -import type { TrackedUsageFeatures } from '../usageTracker'; -import type { IpcMessage, IpcMessageParams, IpcNotificationType, WebviewFocusChangedParams } from './protocol'; -import { ExecuteCommandType, onIpc, WebviewFocusChangedCommandType, WebviewReadyCommandType } from './protocol'; - -const maxSmallIntegerV8 = 2 ** 30; // Max number that can be stored in V8's smis (small integers) - -let ipcSequence = 0; -function nextIpcId() { - if (ipcSequence === maxSmallIntegerV8) { - ipcSequence = 1; - } else { - ipcSequence++; - } - - return `host:${ipcSequence}`; -} - -export type WebviewViewIds = 'commitDetails' | 'home' | 'timeline'; - -@logName>((c, name) => `${name}(${c.id})`) -export abstract class WebviewViewBase implements WebviewViewProvider, Disposable { - protected readonly disposables: Disposable[] = []; - protected isReady: boolean = false; - private _disposableView: Disposable | undefined; - protected _view: WebviewView | undefined; - - constructor( - protected readonly container: Container, - public readonly id: `gitlens.views.${WebviewViewIds}`, - protected readonly fileName: string, - title: string, - private readonly contextKeyPrefix: `${ContextKeys.WebviewViewPrefix}${WebviewViewIds}`, - private readonly trackingFeature: TrackedUsageFeatures, - ) { - this._title = title; - this.disposables.push(window.registerWebviewViewProvider(id, this)); - } - - dispose() { - this.disposables.forEach(d => void d.dispose()); - this._disposableView?.dispose(); - } - - get description(): string | undefined { - return this._view?.description; - } - set description(description: string | undefined) { - if (this._view == null) return; - - this._view.description = description; - } - - private _title: string; - get title(): string { - return this._view?.title ?? this._title; - } - set title(title: string) { - this._title = title; - if (this._view == null) return; - - this._view.title = title; - } - - get visible() { - return this._view?.visible ?? false; - } - - @log() - async show(options?: { preserveFocus?: boolean }) { - const scope = getLogScope(); - - try { - void (await executeCommand(`${this.id}.focus`, options)); - } catch (ex) { - Logger.error(ex, scope); - } - } - - private readonly _cspNonce = getNonce(); - protected get cspNonce(): string { - return this._cspNonce; - } - - protected onInitializing?(): Disposable[] | undefined; - protected onReady?(): void; - protected onMessageReceived?(e: IpcMessage): void; - protected onFocusChanged?(focused: boolean): void; - protected onVisibilityChanged?(visible: boolean): void; - protected onWindowFocusChanged?(focused: boolean): void; - - protected registerCommands?(): Disposable[]; - - protected includeBootstrap?(): SerializedState | Promise; - protected includeHead?(): string | Promise; - protected includeBody?(): string | Promise; - protected includeEndOfBody?(): string | Promise; - - @debug({ args: false }) - async resolveWebviewView( - webviewView: WebviewView, - _context: WebviewViewResolveContext, - _token: CancellationToken, - ): Promise { - this._view = webviewView; - - webviewView.webview.options = { - enableCommandUris: true, - enableScripts: true, - }; - - webviewView.title = this._title; - - this._disposableView = Disposable.from( - this._view.onDidDispose(this.onViewDisposed, this), - this._view.onDidChangeVisibility(() => this.onViewVisibilityChanged(this.visible), this), - this._view.webview.onDidReceiveMessage(this.onMessageReceivedCore, this), - window.onDidChangeWindowState(this.onWindowStateChanged, this), - ...(this.onInitializing?.() ?? []), - ...(this.registerCommands?.() ?? []), - ); - - await this.refresh(); - this.onVisibilityChanged?.(true); - } - - @debug() - protected async refresh(force?: boolean): Promise { - if (this._view == null) return; - - // Mark the webview as not ready, until we know if we are changing the html - this.isReady = false; - const html = await this.getHtml(this._view.webview); - if (force) { - // Reset the html to get the webview to reload - this._view.webview.html = ''; - } - - // If we aren't changing the html, mark the webview as ready again - if (this._view.webview.html === html) { - this.isReady = true; - return; - } - - this._view.webview.html = html; - } - - private resetContextKeys(): void { - void setContext(`${this.contextKeyPrefix}:inputFocus`, false); - void setContext(`${this.contextKeyPrefix}:focus`, false); - } - - private setContextKeys(focus: boolean, inputFocus: boolean): void { - void setContext(`${this.contextKeyPrefix}:focus`, focus); - void setContext(`${this.contextKeyPrefix}:inputFocus`, inputFocus); - } - - private onViewDisposed() { - this.resetContextKeys(); - - this.onFocusChanged?.(false); - this.onVisibilityChanged?.(false); - - this.isReady = false; - this._disposableView?.dispose(); - this._disposableView = undefined; - this._view = undefined; - } - - @debug['onViewFocusChanged']>({ - args: { 0: e => `focused=${e.focused}, inputFocused=${e.inputFocused}` }, - }) - protected onViewFocusChanged(e: WebviewFocusChangedParams): void { - this.setContextKeys(e.focused, e.inputFocused); - this.onFocusChanged?.(e.focused); - } - - @debug() - private async onViewVisibilityChanged(visible: boolean) { - if (visible) { - void this.container.usage.track(`${this.trackingFeature}:shown`); - await this.refresh(); - } else { - this.resetContextKeys(); - this.onFocusChanged?.(false); - } - this.onVisibilityChanged?.(visible); - } - - private onWindowStateChanged(e: WindowState) { - if (!this.visible) return; - - this.onWindowFocusChanged?.(e.focused); - } - - @debug['onMessageReceivedCore']>({ - args: { 0: e => (e != null ? `${e.id}: method=${e.method}` : '') }, - }) - private onMessageReceivedCore(e: IpcMessage) { - if (e == null) return; - - switch (e.method) { - case WebviewReadyCommandType.method: - onIpc(WebviewReadyCommandType, e, () => { - this.isReady = true; - this.onReady?.(); - }); - - break; - - case WebviewFocusChangedCommandType.method: - onIpc(WebviewFocusChangedCommandType, e, params => { - this.onViewFocusChanged(params); - }); - - break; - - case ExecuteCommandType.method: - onIpc(ExecuteCommandType, e, params => { - if (params.args != null) { - void executeCommand(params.command as Commands, ...params.args); - } else { - void executeCommand(params.command as Commands); - } - }); - break; - - default: - this.onMessageReceived?.(e); - break; - } - } - - protected getWebRoot() { - if (this._view == null) return; - - const webRootUri = Uri.joinPath(this.container.context.extensionUri, 'dist', 'webviews'); - const webRoot = this._view.webview.asWebviewUri(webRootUri).toString(); - - return webRoot; - } - - private async getHtml(webview: Webview): Promise { - const webRootUri = Uri.joinPath(this.container.context.extensionUri, 'dist', 'webviews'); - const uri = Uri.joinPath(webRootUri, this.fileName); - const content = new TextDecoder('utf8').decode(await workspace.fs.readFile(uri)); - - const [bootstrap, head, body, endOfBody] = await Promise.all([ - this.includeBootstrap?.(), - this.includeHead?.(), - this.includeBody?.(), - this.includeEndOfBody?.(), - ]); - - const cspSource = webview.cspSource; - - const root = webview.asWebviewUri(this.container.context.extensionUri).toString(); - const webRoot = webview.asWebviewUri(webRootUri).toString(); - - const html = content.replace( - /#{(head|body|endOfBody|placement|cspSource|cspNonce|root|webroot)}/g, - (_substring: string, token: string) => { - switch (token) { - case 'head': - return head ?? ''; - case 'body': - return body ?? ''; - case 'endOfBody': - return `${ - bootstrap != null - ? `` - : '' - }${endOfBody ?? ''}`; - case 'placement': - return 'view'; - case 'cspSource': - return cspSource; - case 'cspNonce': - return this.cspNonce; - case 'root': - return root; - case 'webroot': - return webRoot; - default: - return ''; - } - }, - ); - - return html; - } - - protected nextIpcId(): string { - return nextIpcId(); - } - - protected notify>( - type: T, - params: IpcMessageParams, - completionId?: string, - ): Promise { - return this.postMessage({ - id: this.nextIpcId(), - method: type.method, - params: params, - completionId: completionId, - }); - } - - @serialize() - @debug['postMessage']>({ - args: { - 0: m => `{"id":${m.id},"method":${m.method}${m.completionId ? `,"completionId":${m.completionId}` : ''}}`, - }, - }) - protected postMessage(message: IpcMessage) { - if (this._view == null || !this.isReady) return Promise.resolve(false); - - // It looks like there is a bug where `postMessage` can sometimes just hang infinitely. Not sure why, but ensure we don't hang - return Promise.race([ - this._view.webview.postMessage(message), - new Promise(resolve => setTimeout(resolve, 5000, false)), - ]); - } -} diff --git a/src/webviews/webviewWithConfigBase.ts b/src/webviews/webviewWithConfigBase.ts deleted file mode 100644 index bc83ec301a2cf..0000000000000 --- a/src/webviews/webviewWithConfigBase.ts +++ /dev/null @@ -1,272 +0,0 @@ -import type { ConfigurationChangeEvent, WebviewPanelOnDidChangeViewStateEvent } from 'vscode'; -import { ConfigurationTarget } from 'vscode'; -import type { Path, PathValue } from '../configuration'; -import { configuration } from '../configuration'; -import type { Commands, ContextKeys } from '../constants'; -import type { Container } from '../container'; -import { CommitFormatter } from '../git/formatters/commitFormatter'; -import { GitCommit, GitCommitIdentity } from '../git/models/commit'; -import { GitFileChange, GitFileIndexStatus } from '../git/models/file'; -import { PullRequest, PullRequestState } from '../git/models/pullRequest'; -import { Logger } from '../logger'; -import type { TrackedUsageFeatures } from '../usageTracker'; -import type { IpcMessage } from './protocol'; -import { - DidChangeConfigurationNotificationType, - DidGenerateConfigurationPreviewNotificationType, - GenerateConfigurationPreviewCommandType, - onIpc, - UpdateConfigurationCommandType, -} from './protocol'; -import type { WebviewIds } from './webviewBase'; -import { WebviewBase } from './webviewBase'; - -export abstract class WebviewWithConfigBase extends WebviewBase { - constructor( - container: Container, - id: `gitlens.${WebviewIds}`, - fileName: string, - iconPath: string, - title: string, - contextKeyPrefix: `${ContextKeys.WebviewPrefix}${WebviewIds}`, - trackingFeature: TrackedUsageFeatures, - showCommand: Commands, - ) { - super(container, id, fileName, iconPath, title, contextKeyPrefix, trackingFeature, showCommand); - this.disposables.push( - configuration.onDidChange(this.onConfigurationChanged, this), - configuration.onDidChangeAny(this.onAnyConfigurationChanged, this), - ); - } - - private onAnyConfigurationChanged(e: ConfigurationChangeEvent) { - let notify = false; - for (const setting of this.customSettings.values()) { - if (e.affectsConfiguration(setting.name)) { - notify = true; - break; - } - } - - if (!notify) return; - - void this.notifyDidChangeConfiguration(); - } - - protected onConfigurationChanged(_e: ConfigurationChangeEvent) { - void this.notifyDidChangeConfiguration(); - } - - protected override onViewStateChanged(e: WebviewPanelOnDidChangeViewStateEvent): void { - super.onViewStateChanged(e); - - // Anytime the webview becomes active, make sure it has the most up-to-date config - if (e.webviewPanel.active) { - void this.notifyDidChangeConfiguration(); - } - } - - protected override onMessageReceivedCore(e: IpcMessage): void { - if (e == null) return; - - switch (e.method) { - case UpdateConfigurationCommandType.method: - Logger.debug(`Webview(${this.id}).onMessageReceived: method=${e.method}`); - - onIpc(UpdateConfigurationCommandType, e, async params => { - const target = - params.scope === 'workspace' ? ConfigurationTarget.Workspace : ConfigurationTarget.Global; - - let key: keyof typeof params.changes; - for (key in params.changes) { - let value = params.changes[key]; - - if (isCustomConfigKey(key)) { - const customSetting = this.customSettings.get(key); - if (customSetting != null) { - if (typeof value === 'boolean') { - await customSetting.update(value); - } else { - debugger; - } - } - - continue; - } - - const inspect = configuration.inspect(key)!; - - if (value != null) { - if (params.scope === 'workspace') { - if (value === inspect.workspaceValue) continue; - } else { - if (value === inspect.globalValue && value !== inspect.defaultValue) continue; - - if (value === inspect.defaultValue) { - value = undefined; - } - } - } - - await configuration.update(key as any, value, target); - } - - for (const key of params.removes) { - await configuration.update(key as any, undefined, target); - } - }); - break; - - case GenerateConfigurationPreviewCommandType.method: - Logger.debug(`Webview(${this.id}).onMessageReceived: method=${e.method}`); - - onIpc(GenerateConfigurationPreviewCommandType, e, async params => { - switch (params.type) { - case 'commit': - case 'commit-uncommitted': { - const commit = new GitCommit( - this.container, - '~/code/eamodio/vscode-gitlens-demo', - 'fe26af408293cba5b4bfd77306e1ac9ff7ccaef8', - new GitCommitIdentity('You', 'eamodio@gmail.com', new Date('2016-11-12T20:41:00.000Z')), - new GitCommitIdentity('You', 'eamodio@gmail.com', new Date('2020-11-01T06:57:21.000Z')), - params.type === 'commit-uncommitted' ? 'Uncommitted changes' : 'Supercharged', - ['3ac1d3f51d7cf5f438cc69f25f6740536ad80fef'], - params.type === 'commit-uncommitted' ? 'Uncommitted changes' : 'Supercharged', - new GitFileChange( - '~/code/eamodio/vscode-gitlens-demo', - 'code.ts', - GitFileIndexStatus.Modified, - ), - undefined, - [], - ); - - let includePullRequest = false; - switch (params.key) { - case configuration.name('currentLine.format'): - includePullRequest = configuration.get('currentLine.pullRequests.enabled'); - break; - case configuration.name('statusBar.format'): - includePullRequest = configuration.get('statusBar.pullRequests.enabled'); - break; - } - - let pr: PullRequest | undefined; - if (includePullRequest) { - pr = new PullRequest( - { id: 'github', name: 'GitHub', domain: 'github.com', icon: 'github' }, - { - name: 'Eric Amodio', - avatarUrl: 'https://avatars1.githubusercontent.com/u/641685?s=32&v=4', - url: 'https://github.com/eamodio', - }, - '1', - 'Supercharged', - 'https://github.com/gitkraken/vscode-gitlens/pulls/1', - PullRequestState.Merged, - new Date('Sat, 12 Nov 2016 19:41:00 GMT'), - undefined, - new Date('Sat, 12 Nov 2016 20:41:00 GMT'), - ); - } - - let preview; - try { - preview = CommitFormatter.fromTemplate(params.format, commit, { - dateFormat: configuration.get('defaultDateFormat'), - pullRequestOrRemote: pr, - messageTruncateAtNewLine: true, - }); - } catch { - preview = 'Invalid format'; - } - - await this.notify( - DidGenerateConfigurationPreviewNotificationType, - { preview: preview }, - e.completionId, - ); - } - } - }); - break; - - default: - super.onMessageReceivedCore(e); - } - } - - private _customSettings: Map | undefined; - private get customSettings() { - if (this._customSettings == null) { - this._customSettings = new Map([ - [ - 'rebaseEditor.enabled', - { - name: 'workbench.editorAssociations', - enabled: () => this.container.rebaseEditor.enabled, - update: this.container.rebaseEditor.setEnabled, - }, - ], - [ - 'currentLine.useUncommittedChangesFormat', - { - name: 'currentLine.useUncommittedChangesFormat', - enabled: () => configuration.get('currentLine.uncommittedChangesFormat') != null, - update: async enabled => - configuration.updateEffective( - 'currentLine.uncommittedChangesFormat', - // eslint-disable-next-line no-template-curly-in-string - enabled ? 'âœī¸ ${ago}' : null, - ), - }, - ], - ]); - } - return this._customSettings; - } - - protected getCustomSettings(): Record { - const customSettings: Record = Object.create(null); - for (const [key, setting] of this.customSettings) { - customSettings[key] = setting.enabled(); - } - return customSettings; - } - - private notifyDidChangeConfiguration() { - // Make sure to get the raw config, not from the container which has the modes mixed in - return this.notify(DidChangeConfigurationNotificationType, { - config: configuration.getAll(true), - customSettings: this.getCustomSettings(), - }); - } -} - -interface CustomSetting { - name: string; - enabled: () => boolean; - update: (enabled: boolean) => Promise; -} - -interface CustomConfig { - rebaseEditor: { - enabled: boolean; - }; - currentLine: { - useUncommittedChangesFormat: boolean; - }; -} - -export type CustomConfigPath = Path; -export type CustomConfigPathValue

    = PathValue; - -const customConfigKeys: readonly CustomConfigPath[] = [ - 'rebaseEditor.enabled', - 'currentLine.useUncommittedChangesFormat', -]; - -export function isCustomConfigKey(key: string): key is CustomConfigPath { - return customConfigKeys.includes(key as CustomConfigPath); -} diff --git a/src/webviews/webviewsController.ts b/src/webviews/webviewsController.ts new file mode 100644 index 0000000000000..6c98519a271a6 --- /dev/null +++ b/src/webviews/webviewsController.ts @@ -0,0 +1,513 @@ +import { uuid } from '@env/crypto'; +import type { + CancellationToken, + WebviewOptions, + WebviewPanel, + WebviewPanelOptions, + WebviewView, + WebviewViewResolveContext, +} from 'vscode'; +import { Disposable, Uri, ViewColumn, window } from 'vscode'; +import type { Commands } from '../constants.commands'; +import type { WebviewIds, WebviewTypes, WebviewViewIds, WebviewViewTypes } from '../constants.views'; +import type { Container } from '../container'; +import { ensurePlusFeaturesEnabled } from '../plus/gk/utils'; +import { debug } from '../system/decorators/log'; +import { find, first, map } from '../system/iterable'; +import { Logger } from '../system/logger'; +import { startLogScope } from '../system/logger.scope'; +import { executeCoreCommand, registerCommand } from '../system/vscode/command'; +import type { TrackedUsageFeatures } from '../telemetry/usageTracker'; +import { WebviewCommandRegistrar } from './webviewCommandRegistrar'; +import { WebviewController } from './webviewController'; +import type { WebviewHost, WebviewProvider, WebviewShowingArgs } from './webviewProvider'; + +export interface WebviewPanelDescriptor { + id: WebviewIds; + readonly fileName: string; + readonly iconPath: string; + readonly title: string; + readonly contextKeyPrefix: `gitlens:webview:${WebviewTypes}`; + readonly trackingFeature: TrackedUsageFeatures; + readonly plusFeature: boolean; + readonly column?: ViewColumn; + readonly webviewOptions?: WebviewOptions; + readonly webviewHostOptions?: WebviewPanelOptions; + + readonly allowMultipleInstances?: boolean; +} + +interface WebviewPanelRegistration { + readonly descriptor: WebviewPanelDescriptor; + controllers?: + | Map> + | undefined; +} + +export interface WebviewPanelProxy + extends Disposable { + readonly id: WebviewIds; + readonly instanceId: string | undefined; + readonly ready: boolean; + readonly active: boolean; + readonly visible: boolean; + canReuseInstance( + options?: WebviewPanelShowOptions, + ...args: WebviewShowingArgs + ): boolean | undefined; + close(): void; + refresh(force?: boolean): Promise; + show(options?: WebviewPanelShowOptions, ...args: WebviewShowingArgs): Promise; +} + +export interface WebviewPanelsProxy + extends Disposable { + readonly id: WebviewIds; + readonly instances: Iterable>; + getActiveInstance(): WebviewPanelProxy | undefined; + getBestInstance( + options?: WebviewPanelShowOptions, + ...args: WebviewShowingArgs + ): WebviewPanelProxy | undefined; + show(options?: WebviewPanelsShowOptions, ...args: WebviewShowingArgs): Promise; + splitActiveInstance(options?: WebviewPanelsShowOptions): Promise; +} + +export interface WebviewViewDescriptor { + id: WebviewViewIds; + readonly fileName: string; + readonly title: string; + readonly contextKeyPrefix: `gitlens:webviewView:${WebviewViewTypes}`; + readonly trackingFeature: TrackedUsageFeatures; + readonly plusFeature: boolean; + readonly webviewOptions?: WebviewOptions; + readonly webviewHostOptions?: { + readonly retainContextWhenHidden?: boolean; + }; +} + +interface WebviewViewRegistration { + readonly descriptor: WebviewViewDescriptor; + controller?: WebviewController | undefined; + pendingShowArgs?: + | [WebviewViewShowOptions | undefined, WebviewShowingArgs] + | undefined; +} + +export interface WebviewViewProxy extends Disposable { + readonly id: WebviewViewIds; + readonly ready: boolean; + readonly visible: boolean; + refresh(force?: boolean): Promise; + show(options?: WebviewViewShowOptions, ...args: WebviewShowingArgs): Promise; +} + +export class WebviewsController implements Disposable { + private readonly disposables: Disposable[] = []; + private readonly _commandRegistrar: WebviewCommandRegistrar; + private readonly _panels = new Map>(); + private readonly _views = new Map>(); + + constructor(private readonly container: Container) { + this.disposables.push((this._commandRegistrar = new WebviewCommandRegistrar())); + } + + dispose() { + this.disposables.forEach(d => void d.dispose()); + } + + @debug({ + args: { + 0: d => d.id, + 1: false, + 2: false, + }, + singleLine: true, + }) + registerWebviewView( + descriptor: WebviewViewDescriptor, + resolveProvider: ( + container: Container, + host: WebviewHost, + ) => Promise>, + onBeforeShow?: (...args: WebviewShowingArgs) => void | Promise, + ): WebviewViewProxy { + using scope = startLogScope(`WebviewView(${descriptor.id})`, false); + + const registration: WebviewViewRegistration = { descriptor: descriptor }; + this._views.set(descriptor.id, registration); + + const disposables: Disposable[] = []; + disposables.push( + window.registerWebviewViewProvider( + descriptor.id, + { + resolveWebviewView: async ( + webviewView: WebviewView, + context: WebviewViewResolveContext, + token: CancellationToken, + ) => { + if (registration.descriptor.plusFeature) { + if (!(await ensurePlusFeaturesEnabled())) return; + if (token.isCancellationRequested) return; + } + + Logger.debug(scope, 'Resolving view'); + + webviewView.webview.options = { + enableCommandUris: true, + enableScripts: true, + localResourceRoots: [Uri.file(this.container.context.extensionPath)], + ...descriptor.webviewOptions, + }; + + webviewView.title = descriptor.title; + + const controller = await WebviewController.create( + this.container, + this._commandRegistrar, + descriptor, + undefined, + webviewView, + resolveProvider, + ); + + registration.controller?.dispose(); + registration.controller = controller; + + disposables.push( + controller.onDidDispose(() => { + Logger.debug(scope, 'Disposing view'); + + registration.pendingShowArgs = undefined; + registration.controller = undefined; + }), + controller, + ); + + let [options, args] = registration.pendingShowArgs ?? []; + registration.pendingShowArgs = undefined; + if (args == null && isSerializedState(context)) { + args = [{ state: context.state }]; + } + + Logger.debug(scope, 'Showing view'); + await controller.show(true, options, ...(args ?? [])); + }, + }, + descriptor.webviewHostOptions != null ? { webviewOptions: descriptor.webviewHostOptions } : undefined, + ), + ); + + const disposable = Disposable.from(...disposables); + this.disposables.push(disposable); + return { + id: descriptor.id, + get ready() { + return registration.controller?.ready ?? false; + }, + get visible() { + return registration.controller?.visible ?? false; + }, + dispose: function () { + disposable.dispose(); + }, + refresh: function (force?: boolean) { + return registration.controller != null ? registration.controller.refresh(force) : Promise.resolve(); + }, + show: async function ( + options?: WebviewViewShowOptions, + ...args: WebviewShowingArgs + ) { + Logger.debug(scope, 'Showing view'); + + if (registration.controller != null) { + return registration.controller.show(false, options, ...args); + } + + registration.pendingShowArgs = [options, args]; + + if (onBeforeShow != null) { + await onBeforeShow?.(...args); + } + + return void executeCoreCommand(`${descriptor.id}.focus`, options); + }, + } satisfies WebviewViewProxy; + } + + @debug({ + args: { + 0: c => c.id, + 1: d => d.id, + 2: false, + 3: false, + }, + singleLine: true, + }) + registerWebviewPanel( + command: { + id: Commands; + options?: WebviewPanelsShowOptions; + }, + descriptor: WebviewPanelDescriptor, + resolveProvider: ( + container: Container, + host: WebviewHost, + ) => Promise>, + ): WebviewPanelsProxy { + using scope = startLogScope(`WebviewPanel(${descriptor.id})`, false); + + const registration: WebviewPanelRegistration = { descriptor: descriptor }; + this._panels.set(descriptor.id, registration); + + const disposables: Disposable[] = []; + const { container, _commandRegistrar: commandRegistrar } = this; + + let serializedPanel: WebviewPanel | undefined; + + async function show( + options?: WebviewPanelsShowOptions, + ...args: WebviewShowingArgs + ): Promise { + const { descriptor } = registration; + if (descriptor.plusFeature) { + if (!(await ensurePlusFeaturesEnabled())) return; + } + + void container.usage.track(`${descriptor.trackingFeature}:shown`); + + let column = options?.column ?? descriptor.column ?? ViewColumn.Beside; + // Only try to open beside if there is an active tab + if (column === ViewColumn.Beside && window.tabGroups.activeTabGroup.activeTab == null) { + column = ViewColumn.Active; + } + + let controller = getBestController(registration, options, ...args); + if (controller == null) { + let panel: WebviewPanel; + if (serializedPanel != null) { + Logger.debug(scope, 'Restoring panel'); + + panel = serializedPanel; + serializedPanel = undefined; + } else { + Logger.debug(scope, 'Creating panel'); + + panel = window.createWebviewPanel( + descriptor.id, + descriptor.title, + { viewColumn: column, preserveFocus: options?.preserveFocus ?? false }, + { + enableCommandUris: true, + enableScripts: true, + localResourceRoots: [Uri.file(container.context.extensionPath)], + ...descriptor.webviewOptions, + ...descriptor.webviewHostOptions, + }, + ); + } + + panel.iconPath = Uri.file(container.context.asAbsolutePath(descriptor.iconPath)); + + controller = await WebviewController.create( + container, + commandRegistrar, + descriptor, + descriptor.allowMultipleInstances ? uuid() : undefined, + panel, + resolveProvider, + ); + + registration.controllers ??= new Map(); + registration.controllers.set(controller.instanceId, controller); + + disposables.push( + controller.onDidDispose(() => { + Logger.debug(scope, `Disposing panel (${controller!.instanceId})`); + + registration.controllers?.delete(controller!.instanceId); + }), + controller, + ); + + Logger.debug(scope, `Showing panel (${controller.instanceId})`); + await controller.show(true, options, ...args); + } else { + Logger.debug(scope, `Showing existing panel (${controller.instanceId})`); + await controller.show(false, options, ...args); + } + } + + async function deserializeWebviewPanel(panel: WebviewPanel, state: SerializedState) { + // TODO@eamodio: We are currently storing nothing or way too much in serialized state. We should start storing maybe both "client" and "server" state + // Where as right now our webviews are only saving "client" state, e.g. the entire state sent to the webview, rather than key pieces of state + // We probably need to separate state into actual "state" and all the data that is sent to the webview, e.g. for the Graph state might be the selected repo, selected sha, etc vs the entire data set to render the Graph + serializedPanel = panel; + Logger.debug(scope, `Deserializing panel state=${state != null ? '' : 'undefined'}`); + await show( + { column: panel.viewColumn, preserveFocus: true, preserveInstance: false }, + ...(state != null ? [{ state: state }] : []), + ); + } + + const disposable = Disposable.from( + ...disposables, + window.registerWebviewPanelSerializer(descriptor.id, { + deserializeWebviewPanel: deserializeWebviewPanel, + }), + registerCommand( + command.id, + (opts: WebviewPanelShowOptions | undefined, ...args: unknown[]) => { + return show({ ...command.options, ...opts }, ...(args as ShowingArgs)); + }, + this, + ), + ); + this.disposables.push(disposable); + return { + id: descriptor.id, + get instances() { + if (!registration.controllers?.size) return []; + + return map(registration.controllers.values(), c => convertToWebviewPanelProxy(c)); + }, + getActiveInstance: function () { + if (!registration.controllers?.size) return undefined; + + const controller = find(registration.controllers.values(), c => c.active ?? false); + return controller != null ? convertToWebviewPanelProxy(controller) : undefined; + }, + getBestInstance: function ( + options?: WebviewPanelShowOptions, + ...args: WebviewShowingArgs + ) { + const controller = getBestController(registration, options, ...args); + return controller != null ? convertToWebviewPanelProxy(controller) : undefined; + }, + splitActiveInstance: function (options?: WebviewPanelsShowOptions) { + const controller = + registration.controllers != null + ? find(registration.controllers.values(), c => c.active ?? false) + : undefined; + const args = controller?.getSplitArgs() ?? []; + return show({ ...options, preserveInstance: false }, ...args); + }, + dispose: function () { + disposable.dispose(); + }, + show: show, + } satisfies WebviewPanelsProxy; + } +} + +export interface WebviewPanelShowOptions { + column?: ViewColumn; + preserveFocus?: boolean; + preserveVisibility?: boolean; +} + +interface WebviewPanelsShowOptions extends WebviewPanelShowOptions { + preserveInstance?: string | boolean; +} + +export type WebviewPanelShowCommandArgs = [WebviewPanelsShowOptions | undefined, ...args: unknown[]]; + +export interface WebviewViewShowOptions { + column?: never; + preserveFocus?: boolean; + preserveVisibility?: boolean; +} + +export type WebviewShowOptions = WebviewPanelShowOptions | WebviewViewShowOptions; + +function convertToWebviewPanelProxy( + controller: WebviewController, +): WebviewPanelProxy { + return { + id: controller.id, + instanceId: controller.instanceId, + ready: controller.ready, + active: controller.active ?? false, + visible: controller.visible, + canReuseInstance: function ( + options?: WebviewPanelShowOptions, + ...args: WebviewShowingArgs + ) { + return controller.canReuseInstance(options, ...args); + }, + close: function () { + controller.parent.dispose(); + }, + dispose: function () { + controller.dispose(); + }, + refresh: function (force?: boolean) { + return controller.refresh(force); + }, + show: function (options?: WebviewPanelShowOptions, ...args: WebviewShowingArgs) { + return controller.show(false, options, ...args); + }, + }; +} + +function getBestController( + registration: WebviewPanelRegistration, + options: WebviewPanelsShowOptions | undefined, + ...args: WebviewShowingArgs +) { + let controller; + if (registration.controllers?.size) { + if (registration.descriptor.allowMultipleInstances) { + if (options?.preserveInstance !== false) { + if (options?.preserveInstance != null && typeof options.preserveInstance === 'string') { + controller = registration.controllers.get(options.preserveInstance); + } + + if (controller == null) { + let active; + let first; + + // Sort active controllers first + const sortedControllers = [...registration.controllers.values()].sort( + (a, b) => (a.active ? -1 : 1) - (b.active ? -1 : 1), + ); + + for (const c of sortedControllers) { + first ??= c; + if (c.active) { + active = c; + } + + const canReuse = c.canReuseInstance(options, ...args); + if (canReuse === true) { + // If the webview says it should be reused, use it + controller = c; + break; + } else if (canReuse === false) { + // If the webview says it should not be reused don't and clear it from being first/active + if (first === c) { + first = undefined; + } + if (active === c) { + active = undefined; + } + } + } + + if (controller == null && options?.preserveInstance === true) { + controller = active ?? first; + } + } + } + } else { + controller = first(registration.controllers)?.[1]; + } + } + + return controller; +} + +export function isSerializedState(o: unknown): o is { state: Partial } { + return o != null && typeof o === 'object' && 'state' in o && o.state != null && typeof o.state === 'object'; +} diff --git a/src/webviews/welcome/protocol.ts b/src/webviews/welcome/protocol.ts index 2a81f1cb80dd7..06cd94c1dedd9 100644 --- a/src/webviews/welcome/protocol.ts +++ b/src/webviews/welcome/protocol.ts @@ -1,6 +1,40 @@ import type { Config } from '../../config'; +import type { IpcScope, WebviewState } from '../protocol'; +import { IpcCommand, IpcNotification } from '../protocol'; -export interface State { - config: Config; - customSettings?: Record; +export const scope: IpcScope = 'welcome'; + +export interface State extends WebviewState { + version: string; + config: { + codeLens: Config['codeLens']['enabled']; + currentLine: Config['currentLine']['enabled']; + }; + repoFeaturesBlocked?: boolean; + isTrialOrPaid: boolean; + canShowPromo: boolean; + orgSettings: { + ai: boolean; + drafts: boolean; + }; +} + +// COMMANDS + +export interface UpdateConfigurationParams { + type: 'codeLens' | 'currentLine'; + value: boolean; +} +export const UpdateConfigurationCommand = new IpcCommand(scope, 'configuration/update'); + +// NOTIFICATIONS + +export interface DidChangeParams { + state: State; +} +export const DidChangeNotification = new IpcNotification(scope, 'didChange', true); + +export interface DidChangeOrgSettingsParams { + orgSettings: State['orgSettings']; } +export const DidChangeOrgSettings = new IpcNotification(scope, 'org/settings/didChange'); diff --git a/src/webviews/welcome/registration.ts b/src/webviews/welcome/registration.ts new file mode 100644 index 0000000000000..c968d968e70ab --- /dev/null +++ b/src/webviews/welcome/registration.ts @@ -0,0 +1,30 @@ +import { ViewColumn } from 'vscode'; +import { Commands } from '../../constants.commands'; +import type { WebviewsController } from '../webviewsController'; +import type { State } from './protocol'; + +export function registerWelcomeWebviewPanel(controller: WebviewsController) { + return controller.registerWebviewPanel( + { id: Commands.ShowWelcomePage }, + { + id: 'gitlens.welcome', + fileName: 'welcome.html', + iconPath: 'images/gitlens-icon.png', + title: 'Welcome to GitLens', + contextKeyPrefix: `gitlens:webview:welcome`, + trackingFeature: 'welcomeWebview', + plusFeature: false, + column: ViewColumn.Active, + webviewHostOptions: { + retainContextWhenHidden: false, + enableFindWidget: true, + }, + }, + async (container, host) => { + const { WelcomeWebviewProvider } = await import( + /* webpackChunkName: "webview-welcome" */ './welcomeWebview' + ); + return new WelcomeWebviewProvider(container, host); + }, + ); +} diff --git a/src/webviews/welcome/welcomeWebview.ts b/src/webviews/welcome/welcomeWebview.ts index 5b268f12f9c93..ec6896f3f9b34 100644 --- a/src/webviews/welcome/welcomeWebview.ts +++ b/src/webviews/welcome/welcomeWebview.ts @@ -1,27 +1,134 @@ -import { configuration } from '../../configuration'; -import { Commands, ContextKeys } from '../../constants'; +import type { ConfigurationChangeEvent } from 'vscode'; +import { Disposable, workspace } from 'vscode'; +import type { ContextKeys } from '../../constants.context'; import type { Container } from '../../container'; -import { WebviewWithConfigBase } from '../webviewWithConfigBase'; -import type { State } from './protocol'; - -export class WelcomeWebview extends WebviewWithConfigBase { - constructor(container: Container) { - super( - container, - 'gitlens.welcome', - 'welcome.html', - 'images/gitlens-icon.png', - 'Welcome to GitLens', - `${ContextKeys.WebviewPrefix}welcome`, - 'welcomeWebview', - Commands.ShowWelcomePage, +import type { Subscription } from '../../plus/gk/account/subscription'; +import { isSubscriptionPaid, SubscriptionState } from '../../plus/gk/account/subscription'; +import type { SubscriptionChangeEvent } from '../../plus/gk/account/subscriptionService'; +import { configuration } from '../../system/vscode/configuration'; +import { getContext, onDidChangeContext } from '../../system/vscode/context'; +import type { IpcMessage } from '../protocol'; +import type { WebviewHost, WebviewProvider } from '../webviewProvider'; +import type { State, UpdateConfigurationParams } from './protocol'; +import { DidChangeNotification, DidChangeOrgSettings, UpdateConfigurationCommand } from './protocol'; + +const emptyDisposable = Object.freeze({ + dispose: () => { + /* noop */ + }, +}); + +export class WelcomeWebviewProvider implements WebviewProvider { + private readonly _disposable: Disposable; + + constructor( + private readonly container: Container, + private readonly host: WebviewHost, + ) { + this._disposable = Disposable.from( + configuration.onDidChange(this.onConfigurationChanged, this), + this.container.git.onDidChangeRepositories(() => this.notifyDidChange(), this), + !workspace.isTrusted + ? workspace.onDidGrantWorkspaceTrust(() => this.notifyDidChange(), this) + : emptyDisposable, + this.container.subscription.onDidChange(this.onSubscriptionChanged, this), + onDidChangeContext(this.onContextChanged, this), ); } - protected override includeBootstrap(): State { + dispose() { + this._disposable.dispose(); + } + + includeBootstrap(): Promise { + return this.getState(); + } + + onReloaded() { + void this.notifyDidChange(); + } + + private getOrgSettings(): State['orgSettings'] { return { - // Make sure to get the raw config, not from the container which has the modes mixed in - config: configuration.getAll(true), + ai: getContext('gitlens:gk:organization:ai:enabled', false), + drafts: getContext('gitlens:gk:organization:drafts:enabled', false), }; } + + private onContextChanged(key: keyof ContextKeys) { + if (['gitlens:gk:organization:ai:enabled', 'gitlens:gk:organization:drafts:enabled'].includes(key)) { + this.notifyDidChangeOrgSettings(); + } + } + + private onSubscriptionChanged(e: SubscriptionChangeEvent) { + void this.notifyDidChange(e.current); + } + + private onConfigurationChanged(e: ConfigurationChangeEvent) { + if (!configuration.changed(e, 'codeLens.enabled') && !configuration.changed(e, 'currentLine.enabled')) return; + + void this.notifyDidChange(); + } + + onMessageReceived(e: IpcMessage) { + switch (true) { + case UpdateConfigurationCommand.is(e): + this.updateConfiguration(e.params); + break; + } + } + + private async getState(subscription?: Subscription): Promise { + return { + ...this.host.baseWebviewState, + version: this.container.version, + // Make sure to get the raw config so to avoid having the mode mixed in + config: { + codeLens: configuration.get('codeLens.enabled', undefined, true, true), + currentLine: configuration.get('currentLine.enabled', undefined, true, true), + }, + repoFeaturesBlocked: + !workspace.isTrusted || + this.container.git.openRepositoryCount === 0 || + this.container.git.hasUnsafeRepositories(), + isTrialOrPaid: await this.getTrialOrPaidState(subscription), + canShowPromo: await this.getCanShowPromo(subscription), + orgSettings: this.getOrgSettings(), + }; + } + + private async getTrialOrPaidState(subscription?: Subscription): Promise { + const sub = subscription ?? (await this.container.subscription.getSubscription(true)); + + if ([SubscriptionState.FreePlusInTrial, SubscriptionState.Paid].includes(sub.state)) { + return true; + } + + return false; + } + + private async getCanShowPromo(subscription?: Subscription): Promise { + const expiresTime = new Date('2023-12-31T07:59:00.000Z').getTime(); // 2023-12-30 23:59:00 PST-0800 + if (Date.now() > expiresTime) { + return false; + } + + const sub = subscription ?? (await this.container.subscription.getSubscription(true)); + return !isSubscriptionPaid(sub); + } + + private updateConfiguration(params: UpdateConfigurationParams) { + void configuration.updateEffective(`${params.type}.enabled`, params.value); + } + + private async notifyDidChange(subscription?: Subscription) { + void this.host.notify(DidChangeNotification, { state: await this.getState(subscription) }); + } + + private notifyDidChangeOrgSettings() { + void this.host.notify(DidChangeOrgSettings, { + orgSettings: this.getOrgSettings(), + }); + } } diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile new file mode 100644 index 0000000000000..ad1bff22ad91d --- /dev/null +++ b/tests/docker/Dockerfile @@ -0,0 +1,33 @@ +FROM selenium/node-base:4.22.0 + +USER root + +RUN apt-get update && \ + apt-get install -y \ + apt-transport-https \ + ca-certificates \ + software-properties-common \ + gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget libgbm-dev \ + git && \ + rm -rf /var/lib/apt/lists/* + +RUN rm /etc/supervisor/conf.d/selenium.conf + +COPY supervisor.conf /etc/supervisor/conf.d/supervisor.conf + +# --- --- --- --- --- --- --- +# Install Node/NPM +# --- --- --- --- --- --- --- + +# enable node source repo +RUN curl -sL https://deb.nodesource.com/setup_20.x | bash - +# enable yarn repo +# RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - +# RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list + +# install node and pnpm +RUN apt-get update && \ + apt-get install -y nodejs && \ + rm -rf /var/lib/apt/lists/* + +RUN npm install -g pnpm@9 diff --git a/tests/docker/run-e2e-test-local.sh b/tests/docker/run-e2e-test-local.sh new file mode 100755 index 0000000000000..a8b51ae72ebe6 --- /dev/null +++ b/tests/docker/run-e2e-test-local.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +docker build . -t e2e-test + +cd ../../ + +npm install -g pnpm +pnpm install +mkdir -p out + +CONTAINER_NAME=e2e-test + +echo "Docker containers" +docker ps -a + +echo "Docker images" +docker image list + +docker rm -f e2e-test | true + +LOG_ID=$(docker run \ + -e SCREEN_WIDTH=1680 -e SCREEN_HEIGHT=1050 \ + -e TZ="Etc/UTC" \ + -d --name=${CONTAINER_NAME} \ + -v /dev/shm:/dev/shm --privileged \ + -v /dev/snd:/dev/snd \ + -v /etc/localtime:/etc/localtime:ro \ + -v ./:/opt/vscode-gitlens \ + e2e-test) + +echo "Run docker with id $LOG_ID" + +EXIT_CODE=$(docker wait $LOG_ID) + +echo "Exited with code $EXIT_CODE" + +docker logs e2e-test + +exit $EXIT_CODE + +# Add following options to get an access from host: +# -p 5900:25900 \ - to VNC diff --git a/tests/docker/run-unit-tests.sh b/tests/docker/run-unit-tests.sh new file mode 100755 index 0000000000000..7fda786ee9bfd --- /dev/null +++ b/tests/docker/run-unit-tests.sh @@ -0,0 +1,7 @@ +pnpm run test; +EXIT_CODE=$?; +if [[ "$EXIT_CODE" == "0" ]]; then + kill -s SIGINT `cat /var/run/supervisor/supervisord.pid`; +else + kill -s SIGKILL `cat /var/run/supervisor/supervisord.pid`; +fi diff --git a/tests/docker/supervisor.conf b/tests/docker/supervisor.conf new file mode 100644 index 0000000000000..6a8ad889a763e --- /dev/null +++ b/tests/docker/supervisor.conf @@ -0,0 +1,68 @@ +; Documentation of this file format -> http://supervisord.org/configuration.html + +; Priority 0 - xvfb & fluxbox, 5 - x11vnc, 10 - noVNC, 15 - selenium-node + +[program:xvfb] +priority=0 +command=/opt/bin/start-xvfb.sh +autostart=true +autorestart=true + +;Logs +redirect_stderr=false +stdout_logfile=/var/log/supervisor/xvfb-stdout.log +stderr_logfile=/var/log/supervisor/xvfb-stderr.log +stdout_logfile_maxbytes=50MB +stderr_logfile_maxbytes=50MB +stdout_logfile_backups=5 +stderr_logfile_backups=5 +stdout_capture_maxbytes=50MB +stderr_capture_maxbytes=50MB + +[program:vnc] +priority=5 +command=/opt/bin/start-vnc.sh +autostart=true +autorestart=true + +;Logs +redirect_stderr=false +stdout_logfile=/var/log/supervisor/vnc-stdout.log +stderr_logfile=/var/log/supervisor/vnc-stderr.log +stdout_logfile_maxbytes=50MB +stderr_logfile_maxbytes=50MB +stdout_logfile_backups=5 +stderr_logfile_backups=5 +stdout_capture_maxbytes=50MB +stderr_capture_maxbytes=50MB + +[program:novnc] +priority=10 +command=/opt/bin/start-novnc.sh +autostart=true +autorestart=true + +;Logs +redirect_stderr=false +stdout_logfile=/var/log/supervisor/novnc-stdout.log +stderr_logfile=/var/log/supervisor/novnc-stderr.log +stdout_logfile_maxbytes=50MB +stderr_logfile_maxbytes=50MB +stdout_logfile_backups=5 +stderr_logfile_backups=5 +stdout_capture_maxbytes=50MB +stderr_capture_maxbytes=50MB + +[program:selenium-node] +priority=15 +command=bash -c "cd /opt/vscode-gitlens && ./tests/docker/run-unit-tests.sh" +stopasgroup = true +autostart=true +autorestart=false +startsecs=0 +startretries=0 + +;Logs (all Hub activity redirected to stdout so it can be seen through "docker logs" +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000000000..11fc4a7b5335b --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test'; + +// eslint-disable-next-line import-x/no-default-export +export default defineConfig({ + use: { + headless: true, // Ensure headless mode is enabled + viewport: { width: 1920, height: 1080 }, + }, + reporter: 'list', // process.env.CI ? 'html' : 'list', + timeout: 60000, // 1 minute + workers: 1, + expect: { + timeout: 60000, // 1 minute + }, + globalSetup: './setup', + outputDir: '../../out/test-results', + projects: [ + { + name: 'VSCode stable', + use: { + vscodeVersion: 'stable', + }, + }, + ], +}); diff --git a/tests/e2e/setup.ts b/tests/e2e/setup.ts new file mode 100644 index 0000000000000..f9c63f14592b6 --- /dev/null +++ b/tests/e2e/setup.ts @@ -0,0 +1,7 @@ +import { downloadAndUnzipVSCode } from '@vscode/test-electron'; + +// eslint-disable-next-line import-x/no-default-export +export default async () => { + await downloadAndUnzipVSCode('insiders'); + await downloadAndUnzipVSCode('stable'); +}; diff --git a/tests/e2e/specs/baseTest.ts b/tests/e2e/specs/baseTest.ts new file mode 100644 index 0000000000000..4a572cd4501e3 --- /dev/null +++ b/tests/e2e/specs/baseTest.ts @@ -0,0 +1,73 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import type { Page } from '@playwright/test'; +import { _electron, test as base } from '@playwright/test'; +import { downloadAndUnzipVSCode } from '@vscode/test-electron/out/download'; + +export { expect } from '@playwright/test'; + +export type TestOptions = { + vscodeVersion: string; +}; + +type TestFixtures = TestOptions & { + page: Page; + createTmpDir: () => Promise; +}; + +let testProjectPath: string; +export const test = base.extend({ + vscodeVersion: ['insiders', { option: true }], + page: async ({ vscodeVersion, createTmpDir }, use) => { + const defaultCachePath = await createTmpDir(); + const vscodePath = await downloadAndUnzipVSCode(vscodeVersion); + testProjectPath = path.join(__dirname, '..', '..', '..'); + + const electronApp = await _electron.launch({ + executablePath: vscodePath, + // Got it from https://github.com/microsoft/vscode-test/blob/0ec222ef170e102244569064a12898fb203e5bb7/lib/runTest.ts#L126-L160 + args: [ + '--no-sandbox', // https://github.com/microsoft/vscode/issues/84238 + '--disable-gpu-sandbox', // https://github.com/microsoft/vscode-test/issues/221 + '--disable-updates', // https://github.com/microsoft/vscode-test/issues/120 + '--skip-welcome', + '--skip-release-notes', + '--disable-workspace-trust', + `--extensionDevelopmentPath=${path.join(__dirname, '..', '..', '..')}`, + `--extensions-dir=${path.join(defaultCachePath, 'extensions')}`, + `--user-data-dir=${path.join(defaultCachePath, 'user-data')}`, + testProjectPath, + ], + }); + + const page = await electronApp.firstWindow(); + await page.context().tracing.start({ + screenshots: true, + snapshots: true, + title: test.info().title, + }); + + await use(page); + + const tracePath = test.info().outputPath('trace.zip'); + await page.context().tracing.stop({ path: tracePath }); + test.info().attachments.push({ name: 'trace', path: tracePath, contentType: 'application/zip' }); + await electronApp.close(); + + const logPath = path.join(defaultCachePath, 'user-data'); + if (fs.existsSync(logPath)) { + const logOutputPath = test.info().outputPath('vscode-logs'); + await fs.promises.cp(logPath, logOutputPath, { recursive: true }); + } + }, + createTmpDir: async (_, use) => { + const tempDirs: string[] = []; + await use(async () => { + const tempDir = await fs.promises.realpath(await fs.promises.mkdtemp(path.join(os.tmpdir(), 'gltest-'))); + tempDirs.push(tempDir); + return tempDir; + }); + for (const tempDir of tempDirs) await fs.promises.rm(tempDir, { recursive: true }); + }, +}); diff --git a/tests/e2e/specs/command_palette.test.ts b/tests/e2e/specs/command_palette.test.ts new file mode 100644 index 0000000000000..f30ee7fbe65d2 --- /dev/null +++ b/tests/e2e/specs/command_palette.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from './baseTest'; + +test.describe('Test GitLens Command Palette commands', () => { + test('should open commit graph with the command', async ({ page }) => { + // Close any open tabs to ensure a clean state + const welcomePageTab = page.locator('div[role="tab"][aria-label="Welcome to GitLens"]'); + await welcomePageTab.waitFor({ state: 'visible', timeout: 5000 }); + void welcomePageTab.locator('div.tab-actions .action-item a.codicon-close').click(); + + // Open the command palette by clicking on the View menu and selecting Command Palette + const commandPalette = page.locator('div[id="workbench.parts.titlebar"] .command-center-quick-pick'); + await commandPalette.click(); + + // Wait for the command palette input to be visible and fill it + const commandPaletteInput = page.locator('.quick-input-box input'); + await commandPaletteInput.waitFor({ state: 'visible', timeout: 5000 }); + await commandPaletteInput.fill('> GitLens: Show Commit graph'); + await page.waitForTimeout(1000); + void page.keyboard.press('Enter'); + + // Click on the first element (GitLens: Show Commit graph) + /* + const commandPaletteFirstLine = page.locator('.quick-input-widget .monaco-list .monaco-list-row.focused'); + await commandPaletteFirstLine.waitFor({ state: 'visible', timeout: 5000 }); + await commandPaletteFirstLine.click(); + */ + // Graph should be opened + await page.locator('.panel.basepanel').waitFor({ state: 'visible' }); + await expect(page.locator('div[id="workbench.view.extension.gitlensPanel"]')).toBeVisible(); + }); +}); diff --git a/tests/e2e/specs/gitlens_install.test.ts b/tests/e2e/specs/gitlens_install.test.ts new file mode 100644 index 0000000000000..6ec5410f117d3 --- /dev/null +++ b/tests/e2e/specs/gitlens_install.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from './baseTest'; + +test.describe('Test GitLens installation', () => { + test('should display GitLens Welcome page after installation', async ({ page }) => { + const title = await page.textContent('.tab a'); + expect(title).toBe('Welcome to GitLens'); + }); + + test('should contain GitLens & GitLens Inspect icons in activity bar', async ({ page }) => { + await page.getByRole('tab', { name: 'GitLens Inspect' }).waitFor(); + const gitlensIcons = page.getByRole('tab', { name: 'GitLens' }); + void expect(gitlensIcons).toHaveCount(2); + + expect(await page.title()).toContain('[Extension Development Host]'); + }); +}); diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000000000..a9f06c56f41b5 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "es2022", + "module": "node16", + "moduleResolution": "node16", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./out", + "rootDir": ".", + "sourceMap": true + }, + "include": ["**/*.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index bd39294676bd8..1265d52880c4e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -6,9 +6,9 @@ "forceConsistentCasingInFileNames": true, "incremental": true, "isolatedModules": true, - "lib": ["es2022"], + "lib": ["es2022", "esnext.disposable"], "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "Bundler", "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noImplicitReturns": true, @@ -20,8 +20,7 @@ "sourceMap": true, "strict": true, "target": "es2022", - "useDefineForClassFields": false, + "useDefineForClassFields": true, "useUnknownInCatchVariables": false - }, - "include": ["src/**/*"] + } } diff --git a/tsconfig.browser.json b/tsconfig.browser.json index 66a5af9bb3c0a..13a30b979c108 100644 --- a/tsconfig.browser.json +++ b/tsconfig.browser.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "lib": ["dom", "dom.iterable", "es2022"], + "lib": ["dom", "dom.iterable", "es2022", "esnext.disposable"], "paths": { "@env/*": ["src/env/browser/*"], "path": ["node_modules/path-browserify"] diff --git a/tsconfig.json b/tsconfig.json index bed76c2a4a267..e60649b05ed30 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { - "extends": "./tsconfig.base.json", - "compilerOptions": { - "paths": { - "@env/*": ["src/env/node/*"] - } - }, - "include": ["src/**/*"], - "exclude": ["src/test/**/*", "src/webviews/apps/**/*", "src/env/browser/**/*"] + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.browser.json" }, + { "path": "./src/webviews/apps/tsconfig.json" }, + { "path": "./tests/e2e/tsconfig.json" }, + { "path": "./tsconfig.test.json" } + ] } diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000000000..ddce96db69e7a --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "paths": { + "@env/*": ["src/env/node/*"] + }, + "tsBuildInfoFile": "tsconfig.node.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["src/test/**/*", "src/webviews/apps/**/*", "src/env/browser/**/*"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json index 4f18a5aff23b7..fa02d44219ede 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,13 +1,14 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "module": "commonjs", - "outDir": "out", + "incremental": false, + "module": "CommonJS", + "moduleResolution": "Node", + "outDir": "out/tests", "paths": { "@env/*": ["src/env/node/*"] - }, - "tsBuildInfoFile": "tsconfig.test.tsbuildinfo" + } }, "include": ["src/**/*"], - "exclude": ["src/webviews/apps/**/*", "src/env/browser/**/*"] + "exclude": ["node_modules", "src/webviews/apps/**/*", "src/env/browser/**/*"] } diff --git a/walkthroughs/getting-started/0-tutorial.md b/walkthroughs/getting-started/0-tutorial.md deleted file mode 100644 index cd4ca64077859..0000000000000 --- a/walkthroughs/getting-started/0-tutorial.md +++ /dev/null @@ -1,7 +0,0 @@ -

    - - GitLens Getting Started video - -

    - -GitLens **supercharges** Git inside VS Code and unlocks the untapped knowledge within each repository. Whether you’re a seasoned Git user or just getting started, GitLens makes it easier, safer and faster to leverage the full power of Git. This tutorial will show you how to get the most out of GitLens in VS Code. diff --git a/walkthroughs/getting-started/1-setup.md b/walkthroughs/getting-started/1-setup.md deleted file mode 100644 index cc8d6b07e1625..0000000000000 --- a/walkthroughs/getting-started/1-setup.md +++ /dev/null @@ -1,9 +0,0 @@ -## Quick Setup - -

    - GitLens Interactive Quick Setup -

    - -GitLens is powerful, feature rich, and highly customizable to meet your needs. Do you find code lens intrusive or the current line blame annotation distracting — no problem, quickly turn them off or change how they behave using the Quick Setup. - -💡 Use the [GitLens: Welcome (Quick Setup)](command:gitlens.showWelcomePage?%22quick-setup%22) command from the [Command Palette](command:workbench.action.quickOpen?%22>GitLens%3A%20Welcome%22) to open it. diff --git a/walkthroughs/getting-started/10-interactive-rebase-editor.md b/walkthroughs/getting-started/10-interactive-rebase-editor.md deleted file mode 100644 index b598763dfd390..0000000000000 --- a/walkthroughs/getting-started/10-interactive-rebase-editor.md +++ /dev/null @@ -1,16 +0,0 @@ -## Visual Interactive Rebase - -

    - Interactive rebase editor -

    - -Simply drag & drop to reorder commits and select which ones you want to edit, squash, or drop. - -To use this directly from your terminal, e.g. when running `git rebase -i`, - -- set VS Code as your default Git editor - - `git config --global core.editor "code --wait"` -- or, to only affect rebase, set VS Code as your Git rebase editor - - `git config --global sequence.editor "code --wait"` - -> To use the Insiders edition of VS Code, replace `code` in the above with `code-insiders` diff --git a/walkthroughs/getting-started/11-terminal.md b/walkthroughs/getting-started/11-terminal.md deleted file mode 100644 index cc7f240df77c8..0000000000000 --- a/walkthroughs/getting-started/11-terminal.md +++ /dev/null @@ -1,7 +0,0 @@ -## Git Autolinks in Your Terminal - -

    - Links in Integrated Terminal -

    - -Autolinks are added for branches, tags, and commit ranges in the integrated terminal to quickly explore their commit history. While autolinks for commits will open a quick pick menu with commit details and actions. diff --git a/walkthroughs/getting-started/12-plus.md b/walkthroughs/getting-started/12-plus.md deleted file mode 100644 index 51e18212f7c96..0000000000000 --- a/walkthroughs/getting-started/12-plus.md +++ /dev/null @@ -1,26 +0,0 @@ -## GitLens+ Features - -All-new, powerful, additional features that enhance your GitLens experience. - -[GitLens+ features](https://gitkraken.com/gitlens/plus-features?utm_source=gitlens-extension&utm_medium=in-app-links&utm_campaign=gitlens-plus-links) are free for local and public repos, no account required, while upgrading to GitLens Pro gives you access on private repos. - -[Learn more about GitLens+ features](https://gitkraken.com/gitlens/plus-features?utm_source=gitlens-extension&utm_medium=in-app-links&utm_campaign=gitlens-plus-links 'Learn more') - -🛈 All other GitLens features can always be used on any repo - -GitLens+ features include the Commit Graph, Visual File History, Worktrees, and more. - -

    - Commit Graph illustration -
    New Commit Graph -

    - -

    - Visual File History illustration -
    Visual File History -

    - -

    - Worktrees illustration -
    Worktrees -

    diff --git a/walkthroughs/getting-started/2-customize.md b/walkthroughs/getting-started/2-customize.md deleted file mode 100644 index dc9d5c3909c94..0000000000000 --- a/walkthroughs/getting-started/2-customize.md +++ /dev/null @@ -1,9 +0,0 @@ -## Customize GitLens - -

    - GitLens Interactive Settings -

    - -GitLens provides a rich interactive settings editor, an easy-to-use interface, to configure many of GitLens' powerful features. - -💡 Use the [GitLens: Open Settings](command:gitlens.showSettingsPage) command from the [Command Palette](command:workbench.action.quickOpen?%22>GitLens%3A%20Open%20Settings%22) to open it. diff --git a/walkthroughs/getting-started/3-current-line-blame.md b/walkthroughs/getting-started/3-current-line-blame.md deleted file mode 100644 index d313227641a86..0000000000000 --- a/walkthroughs/getting-started/3-current-line-blame.md +++ /dev/null @@ -1,31 +0,0 @@ -## Current Line Blame []() - -

    - Current Line Blame -

    - -GitLens adds an unobtrusive Git blame annotation at the end of the current line, which shows the author, date, and message of the current line's most recent commit. - -💡 Use the [Toggle Line Blame](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20Line%20Blame%22) command from the Command Palette to turn the annotation on and off. - -âš™ī¸ Use the settings editor to customize the [current line annotations](command:gitlens.showSettingsPage?%22current-line%22 'Jump to the Current Line Blame settings'). - -## Hovers - -

    - Current Line Blame Hover -

    - -Hovering over these blame annotations will reveal more details and links to explore. The **details** hover (the top section) provides many commit details and actions, including autolinks in commit messages, while the **changes** hover (the bottom section) shows a diff of the current line with its previous version and related actions. - -âš™ī¸ Use the settings editor to customize the [hovers](command:gitlens.showSettingsPage?%22hovers%22 'Jump to the Hover settings'). - -## Status Bar Blame - -

    - Status Bar Blame -

    - -GitLens also adds Git blame information about the current line to the status bar. - -âš™ī¸ Use the settings editor to customize the [status bar](command:gitlens.showSettingsPage?%22status-bar%22 'Jump to the Status Bar settings'). diff --git a/walkthroughs/getting-started/4-git-codelens.md b/walkthroughs/getting-started/4-git-codelens.md deleted file mode 100644 index 810c6564d7f32..0000000000000 --- a/walkthroughs/getting-started/4-git-codelens.md +++ /dev/null @@ -1,13 +0,0 @@ -## Git CodeLens - -

    - Git CodeLens -

    - -GitLens adds Git authorship CodeLens to the top of the file and on code blocks. The **recent change** CodeLens shows the author and date of the most recent commit for the code block or file, while the **authors** CodeLens shows the number of authors of the code block or file and the most prominent author (if there is more than one). - -Clicking on the CodeLens performs a [customizable](command:gitlens.showSettingsPage?%22code-lens%22 'Jump to the Git CodeLens settings') action. For example, the **recent change** CodeLens will open a quick pick menu with the commit's file details and actions, where as the **authors** will toggle the whole file Git blame annotations. - -💡 Use the [Toggle Git CodeLens](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20Git%20CodeLens%22) command to turn the CodeLens on and off. - -âš™ī¸ Use the settings editor to customize the [Git CodeLens](command:gitlens.showSettingsPage?%22code-lens%22 'Jump to the Git CodeLens settings'). diff --git a/walkthroughs/getting-started/5-revision-history.md b/walkthroughs/getting-started/5-revision-history.md deleted file mode 100644 index c45818c56598f..0000000000000 --- a/walkthroughs/getting-started/5-revision-history.md +++ /dev/null @@ -1,7 +0,0 @@ -## Navigate Revision History - -

    - Revision Navigation -

    - -With just a click of a button, you can navigate backwards and forwards through any file's history. Compare changes over time and see the revision history of the whole file or every individual line of code. Customizable and unobtrusive Git blame annotations are still shown on every line, telling you the author, date, and message for the last commit! diff --git a/walkthroughs/getting-started/6-file-annotations.md b/walkthroughs/getting-started/6-file-annotations.md deleted file mode 100644 index 8545dfd6c3c3d..0000000000000 --- a/walkthroughs/getting-started/6-file-annotations.md +++ /dev/null @@ -1,43 +0,0 @@ -## File Annotations - -

    - Toggle File Annotations -

    - -GitLens adds on-demand annotations for the whole file directly to the editor's scroll bar and in the gutter area, the space beside the line number, to help you gain more insights into your code. - -### File Blame - -

    - File Blame -

    - -When activated, GitLens expands the gutter area to show the commit and author for each line of the file, similar to the current line blame. On the right edge of the gutter, an age indicator (heatmap) is shown to provide an easy, at-a-glance way to tell how recently lines were changed (see the Heatmap below for more details). An additional indicator, which highlights other lines that were also changed as part of the current line's commit, is shown both the far left edge and on the scrollbar. - -💡 On an active file, use the [Toggle File Blame](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Blame%22) command from the Command Palette to turn the annotation on and off. - -âš™ī¸ Use the settings editor to customize the [file blame](command:gitlens.showSettingsPage?%22blame%22 'Jump to the File Blame settings'). - -### File Changes - -

    - File Changes -

    - -When activated, indicators are shown on the editor to highlight any local, unpublished, changes or lines changed by the most recent commit. - -💡 On an active file, use the [Toggle File Changes](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Changes%22) command from the Command Palette to turn the annotation on and off. - -âš™ī¸ Use the settings editor to customize the [file changes](command:gitlens.showSettingsPage?%22changes%22 'Jump to the File Changes settings'). - -### Heatmap - -

    - File Heatmap -

    - -When activated, color-coded indicators are shown on the editor to highlight how recently lines were changed relative to all the other changes in the file. The colors range from hot, orange, to cold, blue, based on the age of the most recent change. Changes are considered cold after 90 days. - -💡 On an active file, use the [Toggle File Heatmap](command:workbench.action.quickOpen?%22>GitLens%3A%20Toggle%20File%20Heatmap%22) command from the Command Palette to turn the annotation on and off. - -âš™ī¸ Use the settings editor to customize the [file heatmap](command:gitlens.showSettingsPage?%22heatmap%22 'Jump to the File Heatmap settings'). diff --git a/walkthroughs/getting-started/7-git-side-bar-views.md b/walkthroughs/getting-started/7-git-side-bar-views.md deleted file mode 100644 index 5d9bdd6d63282..0000000000000 --- a/walkthroughs/getting-started/7-git-side-bar-views.md +++ /dev/null @@ -1,12 +0,0 @@ -## Side Bar Views - -

    - GitLens Side Bar Views - Source Control Side Bar Views -

    - -GitLens adds side bar views for Commits, File History, Branches, Remotes, Stashes, Tags, Contributors, Search & Compare, and more that provide rich source control details and functionality. - -By default, these views can be displayed in either the [GitLens side bar](command:gitlens.views.home.focus 'Open GitLens side bar') or on the [Source Control side bar](command:workbench.scm.focus 'Open Source Control side bar'). - -💡 Use the [GitLens: Set Views Layout](command:gitlens.setViewsLayout) command from the [Command Palette](command:workbench.action.quickOpen?%22>GitLens%3A%20Set%20Views%20Layout%22) to change the layout, or drag & drop the views individually. diff --git a/walkthroughs/getting-started/8-hosting-service-integrations.md b/walkthroughs/getting-started/8-hosting-service-integrations.md deleted file mode 100644 index 0e568479b2b8d..0000000000000 --- a/walkthroughs/getting-started/8-hosting-service-integrations.md +++ /dev/null @@ -1,9 +0,0 @@ -## Hosting Service Integrations - -

    - Hosting service integrations -

    - -GitLens provides integrations with many Git hosting services, including GitHub, GitHub Enterprise, GitLab, GitLab self-managed, Gitea, Gerrit, Google Source, Bitbucket, Bitbucket Server, Azure DevOps and custom servers. - -All Git host integrations provide issue and pull request auto-linking, while rich integrations (e.g. GitHub & GitLab) provide more detailed hover information for auto-linked issues and pull requests, pull requests associated with branches and commits, and avatars. diff --git a/walkthroughs/getting-started/9-git-command-palette.md b/walkthroughs/getting-started/9-git-command-palette.md deleted file mode 100644 index d8f91c1cc559d..0000000000000 --- a/walkthroughs/getting-started/9-git-command-palette.md +++ /dev/null @@ -1,9 +0,0 @@ -## Git Command Palette - -

    - Git command palette -

    - -The [Git Command Palette](command:gitlens.gitCommands) provides guided, step-by-step access to many common Git commands, as well as quick access to commits (history and search), stashes, and status (current branch and working tree). - -You can quickly navigate and safely execute Git commands through easy-to-use menus where each command can require an explicit confirmation step before executing. diff --git a/walkthroughs/plus/commit-graph.md b/walkthroughs/plus/commit-graph.md deleted file mode 100644 index 59a4ee21ce1d7..0000000000000 --- a/walkthroughs/plus/commit-graph.md +++ /dev/null @@ -1,9 +0,0 @@ -## Commit Graph - -

    - Commit Graph -

    - -The Commit Graph helps you easily visualize and keep track of all work in progress. Not only does it help you verify your changes, but also easily see changes made by others and when. - -Use the rich commit search to find exactly what you're looking for. It's powerful filters allow you to search by a specific commit, message, author, a changed file or files, or even a specific code change. diff --git a/walkthroughs/plus/intro.md b/walkthroughs/plus/intro.md deleted file mode 100644 index 0f6943fbf1338..0000000000000 --- a/walkthroughs/plus/intro.md +++ /dev/null @@ -1,20 +0,0 @@ -## Introducing GitLens+ Features - -All-new, powerful, additional features that enhance your current GitLens experience. - -[GitLens+ features](https://gitkraken.com/gitlens/plus-features?utm_source=gitlens-extension&utm_medium=in-app-links&utm_campaign=gitlens-plus-links) are free for local and public repos, no account required, while upgrading to GitLens Pro gives you access on private repos. - -[Learn more about GitLens+ features](https://gitkraken.com/gitlens/plus-features?utm_source=gitlens-extension&utm_medium=in-app-links&utm_campaign=gitlens-plus-links 'Learn more') - -🛈 All other GitLens features can always be used on any repo - -GitLens+ features include the Commit Graph, Visual File History, Worktrees, and more. - -

    - Commit Graph Illustration -
    New Commit Graph -

    - -## Does this affect existing features? - -No, the introduction of GitLens+ features has no impact on existing GitLens features, so you won't lose access to any of the GitLens features you know and love. In fact, we are heavily investing in enhancing and expanding the GitLens feature set. Additionally, GitLens+ features are freely available to everyone for local and public repos, while upgrading to GitLens Pro gives you access on private repos. diff --git a/walkthroughs/plus/rich-integrations.md b/walkthroughs/plus/rich-integrations.md deleted file mode 100644 index 175ba8c3fd969..0000000000000 --- a/walkthroughs/plus/rich-integrations.md +++ /dev/null @@ -1,19 +0,0 @@ -## Rich Self-Hosted Git Integrations - -GitLens Pro users can access rich integrations for self-hosted providers. - -### GitHub Enterprise - -GitHub Enterprise users can see GitHub avatars for commit authors, richer details for auto-linked issues and pull requests, as well as pull requests associated with commits and branches. - -

    - Commit Hover with GitHub Rich Integration -

    - -### GitLab Self-Managed - -GitLab Self-Managed users can see GitLab avatars for commit authors, richer details for auto-linked issues and merge requests, as well as merge requests associated with commits and branches. - -

    - Commit Hover with GitLab Rich Integration -

    diff --git a/walkthroughs/plus/try-now.md b/walkthroughs/plus/try-now.md deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/walkthroughs/plus/visual-file-history.md b/walkthroughs/plus/visual-file-history.md deleted file mode 100644 index 095d7f3d5df53..0000000000000 --- a/walkthroughs/plus/visual-file-history.md +++ /dev/null @@ -1,13 +0,0 @@ -## Visual File History - -

    - Visual File History View -

    - -The Visual File History allows you to quickly see the evolution of a file, including when changes were made, how large they were, and who made them. - -Use it to quickly find when the most impactful changes were made to a file or who best to talk to about file changes and more. - -Authors who have contributed changes to the file are on the left y-axis to create a swim-lane of their commits over time (the x-axis). Commit are plotted as color-coded (per-author) bubbles, whose size represents the relative magnitude of the changes. - -Additionally, each commit's additions and deletions are visualized as color-coded, stacked, vertical bars, whose height represents the number of affected lines (right y-axis). Added lines are shown in green, while deleted lines are red. diff --git a/walkthroughs/plus/worktrees.md b/walkthroughs/plus/worktrees.md deleted file mode 100644 index 7f5a9fc6b02af..0000000000000 --- a/walkthroughs/plus/worktrees.md +++ /dev/null @@ -1,13 +0,0 @@ -## Git Worktrees - -

    - Worktrees View -

    - -Worktrees help you multitask by minimizing the context switching between branches, allowing you to easily work on different branches of a repository simultaneously. - -Avoid interrupting your work in progress when needing to review a pull request. Simply create a new worktree and open it in a new VS Code window, all without impacting your other work. - -You can create multiple working trees, each of which can be opened in individual windows or all together in a single workspace. - -_Note: Worktrees do not yet work with VS Code on the Web._ diff --git a/walkthroughs/welcome/code-collab.png b/walkthroughs/welcome/code-collab.png new file mode 100644 index 0000000000000..d2b5c1283527c Binary files /dev/null and b/walkthroughs/welcome/code-collab.png differ diff --git a/walkthroughs/welcome/commit-graph.svg b/walkthroughs/welcome/commit-graph.svg new file mode 100644 index 0000000000000..8f4244b0f7043 --- /dev/null +++ b/walkthroughs/welcome/commit-graph.svg @@ -0,0 +1,103 @@ + + + + + main + + + + + + feature/onboard + + + + + feature/graph + + + + + bug/crash + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Work in progress + + Improves performance & reduces bundle size + Adds brand new welcome experience + Adds new Commit Graph panel layout + Optimizes startup performance + Revamps Home view experience for better utility + Optimizes Commit Graph loading performance + Adds new GitLens Inspect side bar for a better experience + Fixes crash when run on a phone + Updates package dependencies + + + + Eric Amodio + Keith Daulton + Eric Amodio + Ramin Tadayon + Keith Daulton + Eric Amodio + Keith Daulton + Ramin Tadayon + Ramin Tadayon + + + diff --git a/walkthroughs/welcome/core-features.svg b/walkthroughs/welcome/core-features.svg new file mode 100644 index 0000000000000..3a5b4f8857337 --- /dev/null +++ b/walkthroughs/welcome/core-features.svg @@ -0,0 +1,128 @@ + + + Inline Blame and Git CodeLens + + + + Eric Amodio, 3 minutes ago | 1 author (Eric Amodio) + + + 13returnsupercharged(git);|You, 6 years ago via PR #1 â€ĸ Supercharge Git + + + + + + + + You, 6 years ago via PR #1(November 12th, 2016)Supercharge Git + + + + + + 29ad3a0|||| + + + + + + - return git; + + return supercharged(git); + + + + + + Changes3ac1d3f29ad3a0| + + + + File Annotations + + + + + 12 + 13 + + + + + + + EA + + Hello GitLens6 yrs ago + + + EA + + Supercharged6 yrs ago + + + + + + functiongitlens(git:object){ + returnsupercharged(git);| + + + + + Revision Navigation + + + + + + + + + + + + + + 12functiongitlens(git:object){ + 13returnsupercharged(git); + + + + + + 12functiongitlens(git:object){ + 13returnsuperDuperCharged(git);| + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/walkthroughs/welcome/get-started.md b/walkthroughs/welcome/get-started.md new file mode 100644 index 0000000000000..ef05c22caf053 --- /dev/null +++ b/walkthroughs/welcome/get-started.md @@ -0,0 +1,13 @@ +
    + + GitLens tutorial video + +
    + +
    +

    GitLens Overview

    + + GitLens Side Bar & Panel overview + +
    GitLens Inspect as shown above has been manually dragged into the Secondary Side Bar
    +
    diff --git a/walkthroughs/welcome/integrations.md b/walkthroughs/welcome/integrations.md new file mode 100644 index 0000000000000..fd1f3843325c4 --- /dev/null +++ b/walkthroughs/welcome/integrations.md @@ -0,0 +1,3 @@ +
    + Hosting service integrations +
    diff --git a/walkthroughs/welcome/launchpad-quick.svg b/walkthroughs/welcome/launchpad-quick.svg new file mode 100644 index 0000000000000..79eeba39e4e29 --- /dev/null +++ b/walkthroughs/welcome/launchpad-quick.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/walkthroughs/welcome/launchpad.svg b/walkthroughs/welcome/launchpad.svg new file mode 100644 index 0000000000000..f18ef58492516 --- /dev/null +++ b/walkthroughs/welcome/launchpad.svg @@ -0,0 +1,99 @@ + + + + + + My Pull Requests + + + All + Opened by me + Assigned to me + Needs my review + Mentions me + + + + + + 1wk + adds stylelint + #2453 + + + + + + + + + + 1wk + Workspaces side bar view + #2650 + + + + + + + + + + 3wk + Adds experimental.OpenAIModel + #2637 + + + + + + + + + + 2mo + adds focus view + #2516 + + + + + + + +1735 + -748 + +3,556 + -136 + +79 + -12 + +3,327 + -28 + + + + My Issues + + + All + Opened by me + Assigned to me + Mentions me + + + + + + 2mo + Labs: add AI explain panel to Inspect + #2628 + + + + + + + + Launchpad + + diff --git a/walkthroughs/welcome/more-features.md b/walkthroughs/welcome/more-features.md new file mode 100644 index 0000000000000..eaf5c42162390 --- /dev/null +++ b/walkthroughs/welcome/more-features.md @@ -0,0 +1,4 @@ +
    + Interactive rebase editor +
    Interactive rebase editor
    +
    diff --git a/walkthroughs/welcome/power-up.png b/walkthroughs/welcome/power-up.png new file mode 100644 index 0000000000000..3543431ddca1d Binary files /dev/null and b/walkthroughs/welcome/power-up.png differ diff --git a/walkthroughs/welcome/pro-features.md b/walkthroughs/welcome/pro-features.md new file mode 100644 index 0000000000000..92691f7382571 --- /dev/null +++ b/walkthroughs/welcome/pro-features.md @@ -0,0 +1,3 @@ +
    + Power-up with Pro and the GitKraken DevEx Platform +
    diff --git a/walkthroughs/welcome/pro-paid.md b/walkthroughs/welcome/pro-paid.md new file mode 100644 index 0000000000000..92691f7382571 --- /dev/null +++ b/walkthroughs/welcome/pro-paid.md @@ -0,0 +1,3 @@ +
    + Power-up with Pro and the GitKraken DevEx Platform +
    diff --git a/walkthroughs/welcome/pro-reactivate.md b/walkthroughs/welcome/pro-reactivate.md new file mode 100644 index 0000000000000..ef074adfc5e70 --- /dev/null +++ b/walkthroughs/welcome/pro-reactivate.md @@ -0,0 +1,3 @@ +
    + Power-up with Pro and the GitKraken DevEx Platform +
    diff --git a/walkthroughs/welcome/pro-trial.md b/walkthroughs/welcome/pro-trial.md new file mode 100644 index 0000000000000..dfc2db19ee19e --- /dev/null +++ b/walkthroughs/welcome/pro-trial.md @@ -0,0 +1,3 @@ +
    + Power-up with Pro and the GitKraken DevEx Platform +
    diff --git a/walkthroughs/welcome/pro-upgrade.md b/walkthroughs/welcome/pro-upgrade.md new file mode 100644 index 0000000000000..ef074adfc5e70 --- /dev/null +++ b/walkthroughs/welcome/pro-upgrade.md @@ -0,0 +1,3 @@ +
    + Power-up with Pro and the GitKraken DevEx Platform +
    diff --git a/walkthroughs/welcome/rocket.webp b/walkthroughs/welcome/rocket.webp new file mode 100644 index 0000000000000..147e010085071 Binary files /dev/null and b/walkthroughs/welcome/rocket.webp differ diff --git a/walkthroughs/welcome/timeline.svg b/walkthroughs/welcome/timeline.svg new file mode 100644 index 0000000000000..2a7d65baac218 --- /dev/null +++ b/walkthroughs/welcome/timeline.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/walkthroughs/welcome/tutorial.png b/walkthroughs/welcome/tutorial.png new file mode 100644 index 0000000000000..b22d307d4bac4 Binary files /dev/null and b/walkthroughs/welcome/tutorial.png differ diff --git a/walkthroughs/welcome/visualize.svg b/walkthroughs/welcome/visualize.svg new file mode 100644 index 0000000000000..9634d1b5375c9 --- /dev/null +++ b/walkthroughs/welcome/visualize.svg @@ -0,0 +1,249 @@ + + + Commit Graph + + + + + main + + + + + + feature/onboard + + + + + feature/graph + + + + + bug/crash + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Work in progress + + Improves performance & reduces bundle size + Adds brand new welcome experience + Adds new Commit Graph panel layout + Optimizes startup performance + Revamps Home view experience for better utility + Optimizes Commit Graph loading performance + Adds new GitLens Inspect side bar for a better experience + Fixes crash when run on a phone + Updates package dependencies + + + + Eric Amodio + Keith Daulton + Eric Amodio + Ramin Tadayon + Keith Daulton + Eric Amodio + Keith Daulton + Ramin Tadayon + Ramin Tadayon + + + + Visual File History + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/walkthroughs/welcome/workspaces.svg b/walkthroughs/welcome/workspaces.svg new file mode 100644 index 0000000000000..4d488e33df9f2 --- /dev/null +++ b/walkthroughs/welcome/workspaces.svg @@ -0,0 +1,164 @@ + + + + + + My Pull Requests + + + All + Opened by me + Assigned to me + Needs my review + Mentions me + + + + + + 1wk + adds stylelint + #2453 + + + + + + + + + + 1wk + Workspaces side bar view + #2650 + + + + + + + + + + 3wk + Adds experimental.OpenAIModel + #2637 + + + + + + + + + + 2mo + adds focus view + #2516 + + + + + + + +1735 + -748 + +3,556 + -136 + +79 + -12 + +3,327 + -28 + + + + My Issues + + + All + Opened by me + Assigned to me + Mentions me + + + + + + 2mo + Labs: add AI explain panel to Inspect + #2628 + + + + + + + + + + + + GITKRAKEN WORKSPACES + + + + + + + + Client apps + + + + + + + + + + + vscode-gitlens + â€ĸ + main + â€ĸ + Last fetched 6/9/23 + + + + + + + + + + GitKraken + â€ĸ + development + â€ĸ + Last fetched 6/7/23 + + + + + + + + + + + + Backend services + + + + + + + + Open source projects + + + + + + diff --git a/webpack.config.images.js b/webpack.config.images.js deleted file mode 100644 index af18b13baa519..0000000000000 --- a/webpack.config.images.js +++ /dev/null @@ -1,99 +0,0 @@ -//@ts-check -/** @typedef {import('webpack').Configuration} WebpackConfig **/ - -const path = require('path'); -const CopyPlugin = require('copy-webpack-plugin'); -const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin'); - -module.exports = - /** - * @param {{ useOptimization?: boolean; useSharpForImageOptimization?: boolean } | undefined } env - * @param {{ mode: 'production' | 'development' | 'none' | undefined }} argv - * @returns { WebpackConfig } - */ - function (env, argv) { - const mode = argv.mode || 'none'; - const basePath = path.join(__dirname, 'src', 'webviews', 'apps'); - - env = { - useOptimization: true, - useSharpForImageOptimization: true, - ...env, - }; - - /** @type ImageMinimizerPlugin.Generator */ - // @ts-ignore - let imageGeneratorConfig = env.useSharpForImageOptimization - ? { - type: 'asset', - implementation: ImageMinimizerPlugin.sharpGenerate, - options: { - encodeOptions: { - webp: { - lossless: true, - }, - }, - }, - } - : { - type: 'asset', - implementation: ImageMinimizerPlugin.imageminGenerate, - options: { - plugins: [ - [ - 'imagemin-webp', - { - lossless: true, - nearLossless: 0, - quality: 100, - method: mode === 'production' ? 4 : 0, - }, - ], - ], - }, - }; - - /** @type WebpackConfig['plugins'] */ - const plugins = [ - new CopyPlugin({ - patterns: [ - { - from: path.posix.join(basePath.replace(/\\/g, '/'), 'media', '*.png'), - to: path.posix.join(__dirname.replace(/\\/g, '/'), 'dist', 'webviews'), - }, - ], - }), - ]; - - if (!env.useOptimization) { - plugins.push( - new ImageMinimizerPlugin({ - deleteOriginalAssets: true, - generator: [imageGeneratorConfig], - }), - ); - } - - /** @type WebpackConfig */ - const config = { - name: 'images', - context: basePath, - entry: {}, - mode: mode, - plugins: plugins, - }; - - if (env.useOptimization) { - config.optimization = { - minimize: true, - minimizer: [ - new ImageMinimizerPlugin({ - deleteOriginalAssets: true, - generator: [imageGeneratorConfig], - }), - ], - }; - } - - return config; - }; diff --git a/webpack.config.images.mjs b/webpack.config.images.mjs new file mode 100644 index 0000000000000..f9b3b8e6301fc --- /dev/null +++ b/webpack.config.images.mjs @@ -0,0 +1,102 @@ +//@ts-check +/** @typedef {import('webpack').Configuration} WebpackConfig **/ + +import path from 'path'; +import CopyPlugin from 'copy-webpack-plugin'; +import ImageMinimizerPlugin from 'image-minimizer-webpack-plugin'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * @param {{ useOptimization?: boolean; useSharpForImageOptimization?: boolean } | undefined } env + * @param {{ mode: 'production' | 'development' | 'none' | undefined }} argv + * @returns { WebpackConfig } + */ +export default function (env, argv) { + const mode = argv.mode || 'none'; + const basePath = path.join(__dirname, 'src', 'webviews', 'apps'); + + env = { + useOptimization: true, + useSharpForImageOptimization: true, + ...env, + }; + + /** @type ImageMinimizerPlugin.Generator */ + // @ts-ignore + let imageGeneratorConfig = env.useSharpForImageOptimization + ? { + type: 'asset', + implementation: ImageMinimizerPlugin.sharpGenerate, + options: { + encodeOptions: { + webp: { + lossless: true, + }, + }, + }, + } + : { + type: 'asset', + implementation: ImageMinimizerPlugin.imageminGenerate, + options: { + plugins: [ + [ + 'imagemin-webp', + { + lossless: true, + nearLossless: 0, + quality: 100, + method: mode === 'production' ? 4 : 0, + }, + ], + ], + }, + }; + + /** @type WebpackConfig['plugins'] */ + const plugins = [ + new CopyPlugin({ + patterns: [ + { + from: path.posix.join(basePath.replace(/\\/g, '/'), 'media', '*.png'), + to: path.posix.join(__dirname.replace(/\\/g, '/'), 'dist', 'webviews'), + }, + ], + }), + ]; + + if (!env.useOptimization) { + plugins.push( + new ImageMinimizerPlugin({ + deleteOriginalAssets: true, + generator: [imageGeneratorConfig], + }), + ); + } + + /** @type WebpackConfig */ + const config = { + name: 'images', + context: basePath, + entry: {}, + mode: mode, + plugins: plugins, + }; + + if (env.useOptimization) { + config.optimization = { + minimize: true, + minimizer: [ + new ImageMinimizerPlugin({ + deleteOriginalAssets: true, + generator: [imageGeneratorConfig], + }), + ], + }; + } + + return config; +} diff --git a/webpack.config.js b/webpack.config.mjs similarity index 71% rename from webpack.config.js rename to webpack.config.mjs index 0997e8030c2e3..b21a9575a1657 100644 --- a/webpack.config.js +++ b/webpack.config.mjs @@ -1,59 +1,68 @@ //@ts-check /** @typedef {import('webpack').Configuration} WebpackConfig **/ -const { spawnSync } = require('child_process'); -const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; -const CircularDependencyPlugin = require('circular-dependency-plugin'); -const { CleanWebpackPlugin: CleanPlugin } = require('clean-webpack-plugin'); -const CopyPlugin = require('copy-webpack-plugin'); -const CspHtmlPlugin = require('csp-html-webpack-plugin'); -const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); -const esbuild = require('esbuild'); -const { EsbuildPlugin } = require('esbuild-loader'); -const { generateFonts } = require('fantasticon'); -const ForkTsCheckerPlugin = require('fork-ts-checker-webpack-plugin'); -const fs = require('fs'); -const HtmlPlugin = require('html-webpack-plugin'); -const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin'); -const MiniCssExtractPlugin = require('mini-css-extract-plugin'); -const path = require('path'); -const { validate } = require('schema-utils'); -const TerserPlugin = require('terser-webpack-plugin'); -const { DefinePlugin, optimize, WebpackError } = require('webpack'); -const WebpackRequireFromPlugin = require('webpack-require-from'); - -module.exports = - /** - * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; esbuildMinify?: boolean; useSharpForImageOptimization?: boolean } | undefined } env - * @param {{ mode: 'production' | 'development' | 'none' | undefined }} argv - * @returns { WebpackConfig[] } - */ - function (env, argv) { - const mode = argv.mode || 'none'; - - env = { - analyzeBundle: false, - analyzeDeps: false, - esbuild: true, - esbuildMinify: false, - useSharpForImageOptimization: true, - ...env, - }; - - return [ - getExtensionConfig('node', mode, env), - getExtensionConfig('webworker', mode, env), - getWebviewsConfig(mode, env), - ]; +import { spawnSync } from 'child_process'; +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +import CircularDependencyPlugin from 'circular-dependency-plugin'; +import { CleanWebpackPlugin as CleanPlugin } from 'clean-webpack-plugin'; +import CopyPlugin from 'copy-webpack-plugin'; +import CspHtmlPlugin from 'csp-html-webpack-plugin'; +import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; +import esbuild from 'esbuild'; +import { ESLintLitePlugin } from '@eamodio/eslint-lite-webpack-plugin'; +import { generateFonts } from '@twbs/fantasticon'; +import ForkTsCheckerPlugin from 'fork-ts-checker-webpack-plugin'; +import fs from 'fs'; +import HtmlPlugin from 'html-webpack-plugin'; +import ImageMinimizerPlugin from 'image-minimizer-webpack-plugin'; +import MiniCssExtractPlugin from 'mini-css-extract-plugin'; +import path from 'path'; +import { validate } from 'schema-utils'; +import TerserPlugin from 'terser-webpack-plugin'; +import webpack from 'webpack'; +import WebpackRequireFromPlugin from 'webpack-require-from'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const { DefinePlugin, optimize, WebpackError } = webpack; + +const require = createRequire(import.meta.url); + +/** + * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; skipLint?: boolean } | undefined } env + * @param {{ mode: 'production' | 'development' | 'none' | undefined }} argv + * @returns { WebpackConfig[] } + */ +export default function (env, argv) { + const mode = argv.mode || 'none'; + + env = { + analyzeBundle: false, + analyzeDeps: false, + esbuild: true, + skipLint: false, + ...env, }; + return [ + getExtensionConfig('node', mode, env), + getExtensionConfig('webworker', mode, env), + getWebviewsConfig(mode, env), + ]; +} + /** * @param { 'node' | 'webworker' } target * @param { 'production' | 'development' | 'none' } mode - * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; esbuildMinify?: boolean; useSharpForImageOptimization?: boolean }} env + * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; skipLint?: boolean }} env * @returns { WebpackConfig } */ function getExtensionConfig(target, mode, env) { + const tsConfigPath = path.join(__dirname, `tsconfig.${target === 'webworker' ? 'browser' : 'node'}.json`); + /** * @type WebpackConfig['plugins'] | any */ @@ -61,27 +70,27 @@ function getExtensionConfig(target, mode, env) { new CleanPlugin({ cleanOnceBeforeBuildPatterns: ['!dist/webviews/**'] }), new ForkTsCheckerPlugin({ async: false, - eslint: { - enabled: true, - files: 'src/**/*.ts?(x)', - options: { - cache: true, - cacheLocation: path.join(__dirname, '.eslintcache/', target === 'webworker' ? 'browser/' : ''), - cacheStrategy: 'content', - fix: mode !== 'production', - overrideConfigFile: path.join( - __dirname, - target === 'webworker' ? '.eslintrc.browser.json' : '.eslintrc.json', - ), - }, - }, formatter: 'basic', typescript: { - configFile: path.join(__dirname, target === 'webworker' ? 'tsconfig.browser.json' : 'tsconfig.json'), + configFile: tsConfigPath, }, }), ]; + if (!env.skipLint) { + plugins.push( + new ESLintLitePlugin({ + files: path.join(__dirname, 'src', '**', '*.ts'), + worker: true, + eslintOptions: { + cache: true, + cacheLocation: path.join(__dirname, '.eslintcache/', target === 'webworker' ? 'browser/' : ''), + cacheStrategy: 'content', + }, + }), + ); + } + if (target === 'webworker') { plugins.push(new optimize.LimitChunkCountPlugin({ maxChunks: 1 })); } else { @@ -94,14 +103,17 @@ function getExtensionConfig(target, mode, env) { plugins.push( new FantasticonPlugin({ configPath: '.fantasticonrc.js', - onBefore: () => - spawnSync('yarn', ['run', 'icons:svgo'], { - cwd: __dirname, - encoding: 'utf8', - shell: true, - }), + onBefore: + mode !== 'production' + ? undefined + : () => + spawnSync('pnpm', ['run', 'icons:svgo'], { + cwd: __dirname, + encoding: 'utf8', + shell: true, + }), onComplete: () => - spawnSync('yarn', ['run', 'icons:apply'], { + spawnSync('pnpm', ['run', 'icons:apply'], { cwd: __dirname, encoding: 'utf8', shell: true, @@ -152,43 +164,38 @@ function getExtensionConfig(target, mode, env) { target: target, devtool: mode === 'production' ? false : 'source-map', output: { - chunkFilename: 'feature-[name].js', + chunkFilename: '[name].js', filename: 'gitlens.js', libraryTarget: 'commonjs2', path: target === 'webworker' ? path.join(__dirname, 'dist', 'browser') : path.join(__dirname, 'dist'), }, optimization: { minimizer: [ - env.esbuildMinify - ? new EsbuildPlugin({ - drop: ['debugger'], - format: 'cjs', + new TerserPlugin({ + minify: TerserPlugin.swcMinify, + extractComments: false, + parallel: true, + terserOptions: { + compress: { + drop_debugger: true, + ecma: 2020, // Keep the class names otherwise @log won't provide a useful name - keepNames: true, - legalComments: 'none', - minify: true, - target: 'es2022', - treeShaking: true, - }) - : new TerserPlugin({ - extractComments: false, - parallel: true, - terserOptions: { - compress: { - drop_debugger: true, - ecma: 2020, - module: true, - }, - ecma: 2020, - format: { - comments: false, - ecma: 2020, - }, - // Keep the class names otherwise @log won't provide a useful name - keep_classnames: true, - module: true, - }, - }), + keep_classnames: true, + module: true, + }, + ecma: 2020, + format: { + comments: false, + ecma: 2020, + }, + // Keep the class names otherwise @log won't provide a useful name + keep_classnames: true, + mangle: { + keep_classnames: true, + }, + module: true, + }, + }), ], splitChunks: target === 'webworker' @@ -217,20 +224,14 @@ function getExtensionConfig(target, mode, env) { options: { format: 'esm', implementation: esbuild, - target: ['es2022', 'chrome102', 'node16.14.2'], - tsconfig: path.join( - __dirname, - target === 'webworker' ? 'tsconfig.browser.json' : 'tsconfig.json', - ), + target: ['es2022', 'chrome114', 'node18.15.0'], + tsconfig: tsConfigPath, }, } : { loader: 'ts-loader', options: { - configFile: path.join( - __dirname, - target === 'webworker' ? 'tsconfig.browser.json' : 'tsconfig.json', - ), + configFile: tsConfigPath, experimentalWatchApi: true, transpileOnly: true, }, @@ -241,13 +242,12 @@ function getExtensionConfig(target, mode, env) { resolve: { alias: { '@env': path.resolve(__dirname, 'src', 'env', target === 'webworker' ? 'browser' : target), + // Stupid dependency that is used by `http[s]-proxy-agent` + debug: path.resolve(__dirname, 'patches', 'debug.js'), // This dependency is very large, and isn't needed for our use-case tr46: path.resolve(__dirname, 'patches', 'tr46.js'), - // Stupid dependency that is used by `http-proxy-agent` - debug: - target === 'webworker' - ? path.resolve(__dirname, 'node_modules', 'debug', 'src', 'browser.js') - : path.resolve(__dirname, 'node_modules', 'debug', 'src', 'node.js'), + // This dependency is unnecessary for our use-case + 'whatwg-url': path.resolve(__dirname, 'patches', 'whatwg-url.js'), }, fallback: target === 'webworker' @@ -279,11 +279,12 @@ function getExtensionConfig(target, mode, env) { /** * @param { 'production' | 'development' | 'none' } mode - * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; esbuildMinify?: boolean; useSharpForImageOptimization?: boolean }} env + * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; skipLint?: boolean }} env * @returns { WebpackConfig } */ function getWebviewsConfig(mode, env) { const basePath = path.join(__dirname, 'src', 'webviews', 'apps'); + const tsConfigPath = path.join(basePath, 'tsconfig.json'); /** @type WebpackConfig['plugins'] | any */ const plugins = [ @@ -303,19 +304,9 @@ function getWebviewsConfig(mode, env) { }), new ForkTsCheckerPlugin({ async: false, - eslint: { - enabled: true, - files: path.join(basePath, '**', '*.ts?(x)'), - options: { - cache: true, - cacheLocation: path.join(__dirname, '.eslintcache', 'webviews/'), - cacheStrategy: 'content', - fix: mode !== 'production', - }, - }, formatter: 'basic', typescript: { - configFile: path.join(basePath, 'tsconfig.json'), + configFile: tsConfigPath, }, }), new WebpackRequireFromPlugin({ @@ -330,8 +321,10 @@ function getWebviewsConfig(mode, env) { getHtmlPlugin('timeline', true, mode, env), getHtmlPlugin('welcome', false, mode, env), getHtmlPlugin('focus', true, mode, env), + getHtmlPlugin('account', true, mode, env), + getHtmlPlugin('patchDetails', true, mode, env), getCspHtmlPlugin(mode, env), - new InlineChunkHtmlPlugin(HtmlPlugin, mode === 'production' ? ['\\.css$'] : []), + // new InlineChunkHtmlPlugin(HtmlPlugin, mode === 'production' ? ['\\.css$'] : []), new CopyPlugin({ patterns: [ { @@ -353,6 +346,20 @@ function getWebviewsConfig(mode, env) { }), ]; + if (!env.skipLint) { + plugins.push( + new ESLintLitePlugin({ + files: path.join(basePath, '**', '*.ts?(x)'), + worker: true, + eslintOptions: { + cache: true, + cacheLocation: path.join(__dirname, '.eslintcache', 'webviews/'), + cacheStrategy: 'content', + }, + }), + ); + } + const imageGeneratorConfig = getImageMinimizerConfig(mode, env); if (mode !== 'production') { @@ -393,12 +400,14 @@ function getWebviewsConfig(mode, env) { timeline: './plus/timeline/timeline.ts', welcome: './welcome/welcome.ts', focus: './plus/focus/focus.ts', + account: './plus/account/account.ts', + patchDetails: './plus/patchDetails/patchDetails.ts', }, mode: mode, target: 'web', devtool: mode === 'production' ? false : 'source-map', output: { - chunkFilename: 'feature-[name].js', + chunkFilename: '[name].js', filename: '[name].js', libraryTarget: 'module', path: path.join(__dirname, 'dist', 'webviews'), @@ -411,38 +420,33 @@ function getWebviewsConfig(mode, env) { minimizer: mode === 'production' ? [ - env.esbuildMinify - ? new EsbuildPlugin({ - css: true, - drop: ['debugger', 'console'], - format: 'esm', + new TerserPlugin({ + // Terser seems better than SWC for minifying the webviews (esm?) + // minify: TerserPlugin.swcMinify, + extractComments: false, + parallel: true, + terserOptions: { + compress: { + drop_debugger: true, + drop_console: true, + ecma: 2020, // Keep the class names otherwise @log won't provide a useful name - // keepNames: true, - legalComments: 'none', - minify: true, - target: 'es2022', - treeShaking: true, - }) - : new TerserPlugin({ - extractComments: false, - parallel: true, - terserOptions: { - compress: { - drop_debugger: true, - drop_console: true, - ecma: 2020, - module: true, - }, - ecma: 2020, - format: { - comments: false, - ecma: 2020, - }, - // // Keep the class names otherwise @log won't provide a useful name - // keep_classnames: true, - module: true, - }, - }), + keep_classnames: true, + module: true, + }, + ecma: 2020, + format: { + comments: false, + ecma: 2020, + }, + // Keep the class names otherwise @log won't provide a useful name + keep_classnames: true, + mangle: { + keep_classnames: true, + }, + module: true, + }, + }), new ImageMinimizerPlugin({ deleteOriginalAssets: true, generator: [imageGeneratorConfig], @@ -451,7 +455,13 @@ function getWebviewsConfig(mode, env) { minimizerOptions: { preset: [ 'cssnano-preset-advanced', - { discardUnused: false, mergeIdents: false, reduceIdents: false }, + { + autoprefixer: false, + discardUnused: false, + mergeIdents: false, + reduceIdents: false, + zindex: false, + }, ], }, }), @@ -482,14 +492,14 @@ function getWebviewsConfig(mode, env) { options: { format: 'esm', implementation: esbuild, - target: 'es2021', - tsconfig: path.join(basePath, 'tsconfig.json'), + target: ['es2021', 'chrome114'], + tsconfig: tsConfigPath, }, } : { loader: 'ts-loader', options: { - configFile: path.join(basePath, 'tsconfig.json'), + configFile: tsConfigPath, experimentalWatchApi: true, transpileOnly: true, }, @@ -561,7 +571,7 @@ function getWebviewsConfig(mode, env) { /** * @param { 'production' | 'development' | 'none' } mode - * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; useSharpForImageOptimization?: boolean } | undefined } env + * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean } | undefined } env * @returns { CspHtmlPlugin } */ function getCspHtmlPlugin(mode, env) { @@ -601,48 +611,30 @@ function getCspHtmlPlugin(mode, env) { /** * @param { 'production' | 'development' | 'none' } mode - * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; useSharpForImageOptimization?: boolean } | undefined } env + * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean } | undefined } env * @returns { ImageMinimizerPlugin.Generator } */ function getImageMinimizerConfig(mode, env) { /** @type ImageMinimizerPlugin.Generator */ // @ts-ignore - return env.useSharpForImageOptimization - ? { - type: 'asset', - implementation: ImageMinimizerPlugin.sharpGenerate, - options: { - encodeOptions: { - webp: { - lossless: true, - }, - }, - }, - } - : { - type: 'asset', - implementation: ImageMinimizerPlugin.imageminGenerate, - options: { - plugins: [ - [ - 'imagemin-webp', - { - lossless: true, - nearLossless: 0, - quality: 100, - method: mode === 'production' ? 4 : 0, - }, - ], - ], + return { + type: 'asset', + implementation: ImageMinimizerPlugin.sharpGenerate, + options: { + encodeOptions: { + webp: { + lossless: true, }, - }; + }, + }, + }; } /** * @param { string } name * @param { boolean } plus * @param { 'production' | 'development' | 'none' } mode - * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean; useSharpForImageOptimization?: boolean } | undefined } env + * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean } | undefined } env * @returns { HtmlPlugin } */ function getHtmlPlugin(name, plus, mode, env) { @@ -777,7 +769,7 @@ class FantasticonPlugin { return; } - const fontConfig = { ...(loadedConfig ?? {}), ...(config ?? {}) }; + const fontConfig = { ...loadedConfig, ...config }; // TODO@eamodio: Figure out how to add watching for the fontConfig.inputDir // Maybe something like: https://github.com/Fridus/webpack-watch-files-plugin @@ -793,14 +785,40 @@ class FantasticonPlugin { } const logger = compiler.getInfrastructureLogger(this.pluginName); - logger.log(`Generating icon font...`); - await onBefore?.(fontConfig); + logger.log(`Generating '${compiler.name}' icon font...`); + + const start = Date.now(); + + let onBeforeDuration = 0; + if (onBefore != null) { + const start = Date.now(); + await onBefore(fontConfig); + onBeforeDuration = Date.now() - start; + } + await generateFonts(fontConfig); - await onComplete?.(fontConfig); - logger.log(`Generated icon font`); + + let onCompleteDuration = 0; + if (onComplete != null) { + const start = Date.now(); + await onComplete(fontConfig); + onCompleteDuration = Date.now() - start; + } + + let suffix = ''; + if (onBeforeDuration > 0 || onCompleteDuration > 0) { + suffix = ` (${onBeforeDuration > 0 ? `onBefore: ${onBeforeDuration}ms` : ''}${ + onCompleteDuration > 0 + ? `${onBeforeDuration > 0 ? ', ' : ''}onComplete: ${onCompleteDuration}ms` + : '' + })`; + } + + logger.log(`Generated '${compiler.name}' icon font in \x1b[32m${Date.now() - start}ms\x1b[0m${suffix}`); } - compiler.hooks.beforeRun.tapPromise(this.pluginName, generate.bind(this)); - compiler.hooks.watchRun.tapPromise(this.pluginName, generate.bind(this)); + const generateFn = generate.bind(this); + compiler.hooks.beforeRun.tapPromise(this.pluginName, generateFn); + compiler.hooks.watchRun.tapPromise(this.pluginName, generateFn); } } diff --git a/webpack.config.test.js b/webpack.config.test.js deleted file mode 100644 index f381d6d6e1f04..0000000000000 --- a/webpack.config.test.js +++ /dev/null @@ -1,168 +0,0 @@ -//@ts-check -/** @typedef {import('webpack').Configuration} WebpackConfig **/ - -const { spawnSync } = require('child_process'); -var fs = require('fs'); -var glob = require('glob'); -const path = require('path'); -const { CleanWebpackPlugin: CleanPlugin } = require('clean-webpack-plugin'); -const esbuild = require('esbuild'); -const ForkTsCheckerPlugin = require('fork-ts-checker-webpack-plugin'); -const JSON5 = require('json5'); -const nodeExternals = require('webpack-node-externals'); - -module.exports = - /** - * @param {{ esbuild?: boolean } | undefined } env - * @param {{ mode: 'production' | 'development' | 'none' | undefined }} argv - * @returns { WebpackConfig[] } - */ - function (env, argv) { - const mode = argv.mode || 'development'; - - env = { - esbuild: true, - ...env, - }; - - return [getExtensionConfig('node', mode, env) /*, getExtensionConfig('webworker', mode, env)*/]; - }; - -/** - * @param { 'node' | 'webworker' } target - * @param { 'production' | 'development' | 'none' } mode - * @param {{ analyzeBundle?: boolean; analyzeDeps?: boolean; esbuild?: boolean } | undefined } env - * @returns { WebpackConfig } - */ -function getExtensionConfig(target, mode, env) { - /** - * @type WebpackConfig['plugins'] | any - */ - const plugins = [ - new CleanPlugin({ cleanOnceBeforeBuildPatterns: ['out/**'] }), - new ForkTsCheckerPlugin({ - async: false, - // eslint: { - // enabled: true, - // files: 'src/**/*.ts', - // options: { - // // cache: true, - // cacheLocation: path.join( - // __dirname, - // target === 'webworker' ? '.eslintcache.browser' : '.eslintcache', - // ), - // overrideConfigFile: path.join( - // __dirname, - // target === 'webworker' ? '.eslintrc.browser.json' : '.eslintrc.json', - // ), - // }, - // }, - formatter: 'basic', - typescript: { - configFile: path.join( - __dirname, - target === 'webworker' ? 'tsconfig.test.browser.json' : 'tsconfig.test.json', - ), - }, - }), - ]; - - return { - name: `tests:${target}`, - entry: { - runTest: './src/test/runTest.ts', - 'suite/index': './src/test/suite/index.ts', - ...glob.sync('./src/test/suite/**/*.test.ts').reduce(function (obj, e) { - obj['suite/' + path.parse(e).name] = e; - return obj; - }, {}), - }, - mode: mode, - target: target, - devtool: 'source-map', - output: { - path: - target === 'webworker' - ? path.join(__dirname, 'out', 'test', 'browser') - : path.join(__dirname, 'out', 'test'), - filename: '[name].js', - sourceMapFilename: '[name].js.map', - libraryTarget: 'commonjs2', - }, - externals: [{ vscode: 'commonjs vscode' }, nodeExternals()], - module: { - rules: [ - { - exclude: /\.d\.ts$/, - include: path.join(__dirname, 'src'), - test: /\.tsx?$/, - use: env.esbuild - ? { - loader: 'esbuild-loader', - options: { - implementation: esbuild, - loader: 'ts', - target: ['es2020', 'chrome91', 'node14.16'], - tsconfigRaw: resolveTSConfig( - path.join( - __dirname, - target === 'webworker' - ? 'tsconfig.test.browser.json' - : 'tsconfig.test.json', - ), - ), - }, - } - : { - loader: 'ts-loader', - options: { - configFile: path.join( - __dirname, - target === 'webworker' ? 'tsconfig.test.browser.json' : 'tsconfig.test.json', - ), - experimentalWatchApi: true, - transpileOnly: true, - }, - }, - }, - ], - }, - resolve: { - alias: { '@env': path.resolve(__dirname, 'src', 'env', target === 'webworker' ? 'browser' : target) }, - fallback: target === 'webworker' ? { path: require.resolve('path-browserify') } : undefined, - mainFields: target === 'webworker' ? ['browser', 'module', 'main'] : ['module', 'main'], - extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], - }, - plugins: plugins, - infrastructureLogging: { - level: 'log', // enables logging required for problem matchers - }, - stats: { - preset: 'errors-warnings', - assets: true, - colors: true, - env: true, - errorsCount: true, - warningsCount: true, - timings: true, - }, - }; -} - -/** - * @param { string } configFile - * @returns { string } - */ -function resolveTSConfig(configFile) { - const result = spawnSync('yarn', ['tsc', `-p ${configFile}`, '--showConfig'], { - cwd: __dirname, - encoding: 'utf8', - shell: true, - }); - - const data = result.stdout; - const start = data.indexOf('{'); - const end = data.lastIndexOf('}') + 1; - const json = JSON5.parse(data.substring(start, end)); - return json; -} diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index 9303500d1abbf..0000000000000 --- a/yarn.lock +++ /dev/null @@ -1,7603 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@axosoft/react-virtualized@9.22.3-gitkraken.3": - version "9.22.3-gitkraken.3" - resolved "https://registry.yarnpkg.com/@axosoft/react-virtualized/-/react-virtualized-9.22.3-gitkraken.3.tgz#a02a110919aae1d1dc78a11c97024f3dd37f5ff9" - integrity sha512-sCU8gM0Ut1I3lNBYLQCq7nmRObFsdGKkTIMZkVThZhFYtmQchl1RLnsXilicmNlwCNZdm3/uDCpOw6q7T1gtog== - dependencies: - "@babel/runtime" "^7.7.2" - clsx "^1.0.4" - dom-helpers "^5.1.3" - loose-envify "^1.4.0" - prop-types "^15.7.2" - react-lifecycles-compat "^3.0.4" - -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.8.3": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" - integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== - dependencies: - "@babel/highlight" "^7.18.6" - -"@babel/helper-validator-identifier@^7.18.6": - version "7.19.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" - integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== - -"@babel/highlight@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" - integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== - dependencies: - "@babel/helper-validator-identifier" "^7.18.6" - chalk "^2.0.0" - js-tokens "^4.0.0" - -"@babel/runtime-corejs2@^7.0.0": - version "7.20.13" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs2/-/runtime-corejs2-7.20.13.tgz#a8f31b768d5b71b48bb288d6bca0b85e518ccf92" - integrity sha512-K2yRNithMJG4epI509n4ljPjogMhmYCB887iSD7rRecxWC9dkbfJZCPGj0BQaqG3d3Qkpb1SotEmyeMmtnvxhw== - dependencies: - core-js "^2.6.12" - regenerator-runtime "^0.13.11" - -"@babel/runtime@^7.1.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7": - version "7.20.13" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.13.tgz#7055ab8a7cff2b8f6058bf6ae45ff84ad2aded4b" - integrity sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA== - dependencies: - regenerator-runtime "^0.13.11" - -"@discoveryjs/json-ext@0.5.7", "@discoveryjs/json-ext@^0.5.0": - version "0.5.7" - resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" - integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== - -"@esbuild/android-arm64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.8.tgz#b3d5b65a3b2e073a6c7ee36b1f3c30c8f000315b" - integrity sha512-oa/N5j6v1svZQs7EIRPqR8f+Bf8g6HBDjD/xHC02radE/NjKHK7oQmtmLxPs1iVwYyvE+Kolo6lbpfEQ9xnhxQ== - -"@esbuild/android-arm@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.8.tgz#c41e496af541e175369d48164d0cf01a5f656cf6" - integrity sha512-0/rb91GYKhrtbeglJXOhAv9RuYimgI8h623TplY2X+vA4EXnk3Zj1fXZreJ0J3OJJu1bwmb0W7g+2cT/d8/l/w== - -"@esbuild/android-x64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.8.tgz#080fa67c29be77f5a3ca5ee4cc78d5bf927e3a3b" - integrity sha512-bTliMLqD7pTOoPg4zZkXqCDuzIUguEWLpeqkNfC41ODBHwoUgZ2w5JBeYimv4oP6TDVocoYmEhZrCLQTrH89bg== - -"@esbuild/darwin-arm64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.8.tgz#053622bf9a82f43d5c075b7818e02618f7b4a397" - integrity sha512-ghAbV3ia2zybEefXRRm7+lx8J/rnupZT0gp9CaGy/3iolEXkJ6LYRq4IpQVI9zR97ID80KJVoUlo3LSeA/sMAg== - -"@esbuild/darwin-x64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.8.tgz#8a1aadb358d537d8efad817bb1a5bff91b84734b" - integrity sha512-n5WOpyvZ9TIdv2V1K3/iIkkJeKmUpKaCTdun9buhGRWfH//osmUjlv4Z5mmWdPWind/VGcVxTHtLfLCOohsOXw== - -"@esbuild/freebsd-arm64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.8.tgz#e6738d0081ba0721a5c6c674e84c6e7fcea61989" - integrity sha512-a/SATTaOhPIPFWvHZDoZYgxaZRVHn0/LX1fHLGfZ6C13JqFUZ3K6SMD6/HCtwOQ8HnsNaEeokdiDSFLuizqv5A== - -"@esbuild/freebsd-x64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.8.tgz#1855e562f2b730f4483f6e94086e9e2597feb4c3" - integrity sha512-xpFJb08dfXr5+rZc4E+ooZmayBW6R3q59daCpKZ/cDU96/kvDM+vkYzNeTJCGd8rtO6fHWMq5Rcv/1cY6p6/0Q== - -"@esbuild/linux-arm64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.8.tgz#481da38952721a3fdb77c17a36ceaacc4270b5c5" - integrity sha512-v3iwDQuDljLTxpsqQDl3fl/yihjPAyOguxuloON9kFHYwopeJEf1BkDXODzYyXEI19gisEsQlG1bM65YqKSIww== - -"@esbuild/linux-arm@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.8.tgz#18127072b270bb6321c6d11be20bfd30e0d6ad17" - integrity sha512-6Ij8gfuGszcEwZpi5jQIJCVIACLS8Tz2chnEBfYjlmMzVsfqBP1iGmHQPp7JSnZg5xxK9tjCc+pJ2WtAmPRFVA== - -"@esbuild/linux-ia32@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.8.tgz#ee400af7b3bc69e8ca2e593ca35156ffb9abd54f" - integrity sha512-8svILYKhE5XetuFk/B6raFYIyIqydQi+GngEXJgdPdI7OMKUbSd7uzR02wSY4kb53xBrClLkhH4Xs8P61Q2BaA== - -"@esbuild/linux-loong64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.8.tgz#8c509d8a454693d39824b83b3f66c400872fce82" - integrity sha512-B6FyMeRJeV0NpyEOYlm5qtQfxbdlgmiGdD+QsipzKfFky0K5HW5Td6dyK3L3ypu1eY4kOmo7wW0o94SBqlqBSA== - -"@esbuild/linux-mips64el@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.8.tgz#f2b0d36e63fb26bc3f95b203b6a80638292101ca" - integrity sha512-CCb67RKahNobjm/eeEqeD/oJfJlrWyw29fgiyB6vcgyq97YAf3gCOuP6qMShYSPXgnlZe/i4a8WFHBw6N8bYAA== - -"@esbuild/linux-ppc64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.8.tgz#1e628be003e036e90423716028cc884fe5ba25bd" - integrity sha512-bytLJOi55y55+mGSdgwZ5qBm0K9WOCh0rx+vavVPx+gqLLhxtSFU0XbeYy/dsAAD6xECGEv4IQeFILaSS2auXw== - -"@esbuild/linux-riscv64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.8.tgz#419a815cb4c3fb9f1b78ef5295f5b48b8bf6427a" - integrity sha512-2YpRyQJmKVBEHSBLa8kBAtbhucaclb6ex4wchfY0Tj3Kg39kpjeJ9vhRU7x4mUpq8ISLXRXH1L0dBYjAeqzZAw== - -"@esbuild/linux-s390x@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.8.tgz#291c49ae5c3d11d226352755c0835911fe1a9e5c" - integrity sha512-QgbNY/V3IFXvNf11SS6exkpVcX0LJcob+0RWCgV9OiDAmVElnxciHIisoSix9uzYzScPmS6dJFbZULdSAEkQVw== - -"@esbuild/linux-x64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.8.tgz#03199d91c76faf80bd54104f5cbf0a489bc39f6a" - integrity sha512-mM/9S0SbAFDBc4OPoyP6SEOo5324LpUxdpeIUUSrSTOfhHU9hEfqRngmKgqILqwx/0DVJBzeNW7HmLEWp9vcOA== - -"@esbuild/netbsd-x64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.8.tgz#b436d767e1b21852f9ed212e2bb57f77203b0ae2" - integrity sha512-eKUYcWaWTaYr9zbj8GertdVtlt1DTS1gNBWov+iQfWuWyuu59YN6gSEJvFzC5ESJ4kMcKR0uqWThKUn5o8We6Q== - -"@esbuild/openbsd-x64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.8.tgz#d1481d8539e21d4729cd04a0450a26c2c8789e89" - integrity sha512-Vc9J4dXOboDyMXKD0eCeW0SIeEzr8K9oTHJU+Ci1mZc5njPfhKAqkRt3B/fUNU7dP+mRyralPu8QUkiaQn7iIg== - -"@esbuild/sunos-x64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.8.tgz#2cfb8126e079b2c00fd1bf095541e9f5c47877e4" - integrity sha512-0xvOTNuPXI7ft1LYUgiaXtpCEjp90RuBBYovdd2lqAFxje4sEucurg30M1WIm03+3jxByd3mfo+VUmPtRSVuOw== - -"@esbuild/win32-arm64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.8.tgz#7c6ecfd097ca23b82119753bf7072bbaefe51e3a" - integrity sha512-G0JQwUI5WdEFEnYNKzklxtBheCPkuDdu1YrtRrjuQv30WsYbkkoixKxLLv8qhJmNI+ATEWquZe/N0d0rpr55Mg== - -"@esbuild/win32-ia32@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.8.tgz#cffec63c3cb0ef8563a04df4e09fa71056171d00" - integrity sha512-Fqy63515xl20OHGFykjJsMnoIWS+38fqfg88ClvPXyDbLtgXal2DTlhb1TfTX34qWi3u4I7Cq563QcHpqgLx8w== - -"@esbuild/win32-x64@0.17.8": - version "0.17.8" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.8.tgz#200a0965cf654ac28b971358ecdca9cc5b44c335" - integrity sha512-1iuezdyDNngPnz8rLRDO2C/ZZ/emJLb72OsZeqQ6gL6Avko/XCXZw+NuxBSNhBAP13Hie418V7VMt9et1FMvpg== - -"@eslint/eslintrc@^1.4.1": - version "1.4.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e" - integrity sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA== - dependencies: - ajv "^6.12.4" - debug "^4.3.2" - espree "^9.4.0" - globals "^13.19.0" - ignore "^5.2.0" - import-fresh "^3.2.1" - js-yaml "^4.1.0" - minimatch "^3.1.2" - strip-json-comments "^3.1.1" - -"@gar/promisify@^1.1.3": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" - integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== - -"@gitkraken/gitkraken-components@6.0.3": - version "6.0.3" - resolved "https://registry.yarnpkg.com/@gitkraken/gitkraken-components/-/gitkraken-components-6.0.3.tgz#a5a6dbc1390bba8a2a788a36920f817aefa59155" - integrity sha512-UsJ41fM0EqV/BWLXJGQGvjJCxXi6+y0ZFD1XXT7hBmQ98FfNIQ7K2Bt9zv8XJOUxgiI/YMuqgVYM28++vYF53Q== - dependencies: - "@axosoft/react-virtualized" "9.22.3-gitkraken.3" - classnames "2.3.2" - re-resizable "6.9.1" - react "16.8.4" - react-bootstrap "0.32.4" - react-dom "16.8.4" - react-dragula "1.1.17" - react-helmet "6.1.0" - -"@humanwhocodes/config-array@^0.11.8": - version "0.11.8" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9" - integrity sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g== - dependencies: - "@humanwhocodes/object-schema" "^1.2.1" - debug "^4.1.1" - minimatch "^3.0.5" - -"@humanwhocodes/module-importer@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" - integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== - -"@humanwhocodes/object-schema@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" - integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== - -"@jest/schemas@^29.4.2": - version "29.4.2" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.4.2.tgz#cf7cfe97c5649f518452b176c47ed07486270fc1" - integrity sha512-ZrGzGfh31NtdVH8tn0mgJw4khQuNHiKqdzJAFbCaERbyCP9tHlxWuL/mnMu8P7e/+k4puWjI1NOzi/sFsjce/g== - dependencies: - "@sinclair/typebox" "^0.25.16" - -"@jest/types@^29.4.2": - version "29.4.2" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.4.2.tgz#8f724a414b1246b2bfd56ca5225d9e1f39540d82" - integrity sha512-CKlngyGP0fwlgC1BRUtPZSiWLBhyS9dKwKmyGxk8Z6M82LBEGB2aLQSg+U1MyLsU+M7UjnlLllBM2BLWKVm/Uw== - dependencies: - "@jest/schemas" "^29.4.2" - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^17.0.8" - chalk "^4.0.0" - -"@jridgewell/gen-mapping@^0.3.0": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" - integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A== - dependencies: - "@jridgewell/set-array" "^1.0.1" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping" "^0.3.9" - -"@jridgewell/resolve-uri@3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" - integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== - -"@jridgewell/set-array@^1.0.1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" - integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== - -"@jridgewell/source-map@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" - integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw== - dependencies: - "@jridgewell/gen-mapping" "^0.3.0" - "@jridgewell/trace-mapping" "^0.3.9" - -"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10": - version "1.4.14" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" - integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== - -"@jridgewell/trace-mapping@^0.3.14", "@jridgewell/trace-mapping@^0.3.9": - version "0.3.17" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985" - integrity sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g== - dependencies: - "@jridgewell/resolve-uri" "3.1.0" - "@jridgewell/sourcemap-codec" "1.4.14" - -"@koa/cors@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-4.0.0.tgz#b2d300d7368d2e0ad6faa1d918eff6d0cde0859a" - integrity sha512-Y4RrbvGTlAaa04DBoPBWJqDR5gPj32OOz827ULXfgB1F7piD1MB/zwn8JR2LAnvdILhxUbXbkXGWuNVsFuVFCQ== - dependencies: - vary "^1.1.2" - -"@koa/router@^12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@koa/router/-/router-12.0.0.tgz#2ae7937093fd392761c0e5833c368379d4a35737" - integrity sha512-cnnxeKHXlt7XARJptflGURdJaO+ITpNkOHmQu7NHmCoRinPbyvFzce/EG/E8Zy81yQ1W9MoSdtklc3nyaDReUw== - dependencies: - http-errors "^2.0.0" - koa-compose "^4.1.0" - methods "^1.1.2" - path-to-regexp "^6.2.1" - -"@lit-labs/ssr-dom-shim@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.0.0.tgz#427e19a2765681fd83411cd72c55ba80a01e0523" - integrity sha512-ic93MBXfApIFTrup4a70M/+ddD8xdt2zxxj9sRwHQzhS9ag/syqkD8JPdTXsc1gUy2K8TTirhlCqyTEM/sifNw== - -"@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.6.0": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.1.tgz#0d958b6d479d0e3db5fc1132ecc4fa84be3f0b93" - integrity sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA== - dependencies: - "@lit-labs/ssr-dom-shim" "^1.0.0" - -"@microsoft/fast-element@1.11.0", "@microsoft/fast-element@^1.11.0", "@microsoft/fast-element@^1.6.2", "@microsoft/fast-element@^1.9.0": - version "1.11.0" - resolved "https://registry.yarnpkg.com/@microsoft/fast-element/-/fast-element-1.11.0.tgz#494f6c87057bcbb42406982d68a92887d6b5acb1" - integrity sha512-VKJYMkS5zgzHHb66sY7AFpYv6IfFhXrjQcAyNgi2ivD65My1XOhtjfKez5ELcLFRJfgZNAxvI8kE69apXERTkw== - -"@microsoft/fast-foundation@^2.38.0", "@microsoft/fast-foundation@^2.41.1": - version "2.47.0" - resolved "https://registry.yarnpkg.com/@microsoft/fast-foundation/-/fast-foundation-2.47.0.tgz#a4cc8c5277e21d080215f5adcaed4266a8dd8a8e" - integrity sha512-EyFuioaZQ9ngjUNRQi8R3dIPPsaNQdUOS+tP0G7b1MJRhXmQWIitBM6IeveQA6ZvXG6H21dqgrfEWlsYrUZ2sw== - dependencies: - "@microsoft/fast-element" "^1.11.0" - "@microsoft/fast-web-utilities" "^5.4.1" - tabbable "^5.2.0" - tslib "^1.13.0" - -"@microsoft/fast-foundation@^2.47.1-0": - version "2.47.1-0" - resolved "https://registry.yarnpkg.com/@microsoft/fast-foundation/-/fast-foundation-2.47.1-0.tgz#ccb26e45464916518e158737d3e9a37168cf5439" - integrity sha512-Ehl3hDClql91j/LnugpmDUzBo3pSo2KvqqAG53zghPgf70ifoWI1XJ3mKyn8FkH9/BCwvEgjbCbaezAwRs++jA== - dependencies: - "@microsoft/fast-element" "^1.11.0" - "@microsoft/fast-web-utilities" "^5.4.1" - tabbable "^5.2.0" - tslib "^1.13.0" - -"@microsoft/fast-react-wrapper@0.3.16-0": - version "0.3.16-0" - resolved "https://registry.yarnpkg.com/@microsoft/fast-react-wrapper/-/fast-react-wrapper-0.3.16-0.tgz#908119d5b9eb1afad19fcdfe7f75a652815a63f9" - integrity sha512-WpkWFzzwEaY16rQKwY0QCKe8fEA83eQJP29C80uBVNtBCHLQwzZdvr0Wiq3dFkOTVGLgjv/AtQPs6mA3abVLZQ== - dependencies: - "@microsoft/fast-element" "^1.11.0" - "@microsoft/fast-foundation" "^2.47.1-0" - -"@microsoft/fast-react-wrapper@^0.1.18": - version "0.1.48" - resolved "https://registry.yarnpkg.com/@microsoft/fast-react-wrapper/-/fast-react-wrapper-0.1.48.tgz#aa89c0dfb703c2f71619c536de2342e28b40b8c9" - integrity sha512-9NvEjru9Kn5ZKjomAMX6v+eF0DR+eDkxKDwDfi+Wb73kTbrNzcnmlwd4diN15ygH97kldgj2+lpvI4CKLQQWLg== - dependencies: - "@microsoft/fast-element" "^1.9.0" - "@microsoft/fast-foundation" "^2.41.1" - -"@microsoft/fast-web-utilities@^5.4.1": - version "5.4.1" - resolved "https://registry.yarnpkg.com/@microsoft/fast-web-utilities/-/fast-web-utilities-5.4.1.tgz#8e3082ee2ff2b5467f17e7cb1fb01b0e4906b71f" - integrity sha512-ReWYncndjV3c8D8iq9tp7NcFNc1vbVHvcBFPME2nNFKNbS1XCesYZGlIlf3ot5EmuOXPlrzUHOWzQ2vFpIkqDg== - dependencies: - exenv-es6 "^1.1.1" - -"@nodelib/fs.scandir@2.1.5": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" - integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== - dependencies: - "@nodelib/fs.stat" "2.0.5" - run-parallel "^1.1.9" - -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" - integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== - -"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": - version "1.2.8" - resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" - integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== - dependencies: - "@nodelib/fs.scandir" "2.1.5" - fastq "^1.6.0" - -"@npmcli/fs@^2.1.0": - version "2.1.2" - resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-2.1.2.tgz#a9e2541a4a2fec2e69c29b35e6060973da79b865" - integrity sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ== - dependencies: - "@gar/promisify" "^1.1.3" - semver "^7.3.5" - -"@npmcli/move-file@^2.0.0": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-2.0.1.tgz#26f6bdc379d87f75e55739bab89db525b06100e4" - integrity sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ== - dependencies: - mkdirp "^1.0.4" - rimraf "^3.0.2" - -"@octokit/auth-token@^3.0.0": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-3.0.3.tgz#ce7e48a3166731f26068d7a7a7996b5da58cbe0c" - integrity sha512-/aFM2M4HVDBT/jjDBa84sJniv1t9Gm/rLkalaz9htOm+L+8JMj1k9w0CkUdcxNyNxZPlTxKPVko+m1VlM58ZVA== - dependencies: - "@octokit/types" "^9.0.0" - -"@octokit/core@4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@octokit/core/-/core-4.2.0.tgz#8c253ba9605aca605bc46187c34fcccae6a96648" - integrity sha512-AgvDRUg3COpR82P7PBdGZF/NNqGmtMq2NiPqeSsDIeCfYFOZ9gddqWNQHnFdEUf+YwOj4aZYmJnlPp7OXmDIDg== - dependencies: - "@octokit/auth-token" "^3.0.0" - "@octokit/graphql" "^5.0.0" - "@octokit/request" "^6.0.0" - "@octokit/request-error" "^3.0.0" - "@octokit/types" "^9.0.0" - before-after-hook "^2.2.0" - universal-user-agent "^6.0.0" - -"@octokit/endpoint@^7.0.0": - version "7.0.5" - resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-7.0.5.tgz#2bb2a911c12c50f10014183f5d596ce30ac67dd1" - integrity sha512-LG4o4HMY1Xoaec87IqQ41TQ+glvIeTKqfjkCEmt5AIwDZJwQeVZFIEYXrYY6yLwK+pAScb9Gj4q+Nz2qSw1roA== - dependencies: - "@octokit/types" "^9.0.0" - is-plain-object "^5.0.0" - universal-user-agent "^6.0.0" - -"@octokit/graphql@^5.0.0": - version "5.0.5" - resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-5.0.5.tgz#a4cb3ea73f83b861893a6370ee82abb36e81afd2" - integrity sha512-Qwfvh3xdqKtIznjX9lz2D458r7dJPP8l6r4GQkIdWQouZwHQK0mVT88uwiU2bdTU2OtT1uOlKpRciUWldpG0yQ== - dependencies: - "@octokit/request" "^6.0.0" - "@octokit/types" "^9.0.0" - universal-user-agent "^6.0.0" - -"@octokit/openapi-types@^16.0.0": - version "16.0.0" - resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-16.0.0.tgz#d92838a6cd9fb4639ca875ddb3437f1045cc625e" - integrity sha512-JbFWOqTJVLHZSUUoF4FzAZKYtqdxWu9Z5m2QQnOyEa04fOFljvyh7D3GYKbfuaSWisqehImiVIMG4eyJeP5VEA== - -"@octokit/request-error@^3.0.0": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-3.0.3.tgz#ef3dd08b8e964e53e55d471acfe00baa892b9c69" - integrity sha512-crqw3V5Iy2uOU5Np+8M/YexTlT8zxCfI+qu+LxUB7SZpje4Qmx3mub5DfEKSO8Ylyk0aogi6TYdf6kxzh2BguQ== - dependencies: - "@octokit/types" "^9.0.0" - deprecation "^2.0.0" - once "^1.4.0" - -"@octokit/request@^6.0.0": - version "6.2.3" - resolved "https://registry.yarnpkg.com/@octokit/request/-/request-6.2.3.tgz#76d5d6d44da5c8d406620a4c285d280ae310bdb4" - integrity sha512-TNAodj5yNzrrZ/VxP+H5HiYaZep0H3GU0O7PaF+fhDrt8FPrnkei9Aal/txsN/1P7V3CPiThG0tIvpPDYUsyAA== - dependencies: - "@octokit/endpoint" "^7.0.0" - "@octokit/request-error" "^3.0.0" - "@octokit/types" "^9.0.0" - is-plain-object "^5.0.0" - node-fetch "^2.6.7" - universal-user-agent "^6.0.0" - -"@octokit/types@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@octokit/types/-/types-9.0.0.tgz#6050db04ddf4188ec92d60e4da1a2ce0633ff635" - integrity sha512-LUewfj94xCMH2rbD5YJ+6AQ4AVjFYTgpp6rboWM5T7N3IsIF65SBEOVcYMGAEzO/kKNiNaW4LoWtoThOhH06gw== - dependencies: - "@octokit/openapi-types" "^16.0.0" - -"@opentelemetry/api@1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.4.0.tgz#2c91791a9ba6ca0a0f4aaac5e45d58df13639ac8" - integrity sha512-IgMK9i3sFGNUqPMbjABm0G26g0QCKCUBfglhQ7rQq6WcxbKfEHRcmwsoER4hZcuYqJgkYn2OeuoJIv7Jsftp7g== - -"@opentelemetry/core@1.9.1": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.9.1.tgz#e343337e1a7bf30e9a6aef3ef659b9b76379762a" - integrity sha512-6/qon6tw2I8ZaJnHAQUUn4BqhTbTNRS0WP8/bA0ynaX+Uzp/DDbd0NS0Cq6TMlh8+mrlsyqDE7mO50nmv2Yvlg== - dependencies: - "@opentelemetry/semantic-conventions" "1.9.1" - -"@opentelemetry/exporter-trace-otlp-http@0.35.1": - version "0.35.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.35.1.tgz#9bf988f91fb145b29a051bce8ff5ef85029ca575" - integrity sha512-EJgAsrvscKsqb/GzF1zS74vM+Z/aQRhrFE7hs/1GK1M9bLixaVyMGwg2pxz1wdYdjxS1mqpHMhXU+VvMvFCw1w== - dependencies: - "@opentelemetry/core" "1.9.1" - "@opentelemetry/otlp-exporter-base" "0.35.1" - "@opentelemetry/otlp-transformer" "0.35.1" - "@opentelemetry/resources" "1.9.1" - "@opentelemetry/sdk-trace-base" "1.9.1" - -"@opentelemetry/otlp-exporter-base@0.35.1": - version "0.35.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.35.1.tgz#535166608d5d36e6c959b2857d01245ee3a916b1" - integrity sha512-Sc0buJIs8CfUeQCL/12vDDjBREgsqHdjboBa/kPQDgMf008OBJSM02Ijj6T1TH+QVHl/VHBBEVJF+FTf0EH9Vg== - dependencies: - "@opentelemetry/core" "1.9.1" - -"@opentelemetry/otlp-transformer@0.35.1": - version "0.35.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.35.1.tgz#d4333b71324b83dbb1b0b3a4cfd769b3e214c6f9" - integrity sha512-c0HXcJ49MKoWSaA49J8PXlVx48CeEFpL0odP6KBkVT+Bw6kAe8JlI3mIezyN05VCDJGtS2I5E6WEsE+DJL1t9A== - dependencies: - "@opentelemetry/core" "1.9.1" - "@opentelemetry/resources" "1.9.1" - "@opentelemetry/sdk-metrics" "1.9.1" - "@opentelemetry/sdk-trace-base" "1.9.1" - -"@opentelemetry/resources@1.9.1": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.9.1.tgz#5ad3d80ba968a3a0e56498ce4bc82a6a01f2682f" - integrity sha512-VqBGbnAfubI+l+yrtYxeLyOoL358JK57btPMJDd3TCOV3mV5TNBmzvOfmesM4NeTyXuGJByd3XvOHvFezLn3rQ== - dependencies: - "@opentelemetry/core" "1.9.1" - "@opentelemetry/semantic-conventions" "1.9.1" - -"@opentelemetry/sdk-metrics@1.9.1": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-1.9.1.tgz#babc162a81df9884c16b1e67c2dd26ab478f3080" - integrity sha512-AyhKDcA8NuV7o1+9KvzRMxNbATJ8AcrutKilJ6hWSo9R5utnzxgffV4y+Hp4mJn84iXxkv+CBb99GOJ2A5OMzA== - dependencies: - "@opentelemetry/core" "1.9.1" - "@opentelemetry/resources" "1.9.1" - lodash.merge "4.6.2" - -"@opentelemetry/sdk-trace-base@1.9.1": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.9.1.tgz#c349491b432a7e0ae7316f0b48b2d454d79d2b84" - integrity sha512-Y9gC5M1efhDLYHeeo2MWcDDMmR40z6QpqcWnPCm4Dmh+RHAMf4dnEBBntIe1dDpor686kyU6JV1D29ih1lZpsQ== - dependencies: - "@opentelemetry/core" "1.9.1" - "@opentelemetry/resources" "1.9.1" - "@opentelemetry/semantic-conventions" "1.9.1" - -"@opentelemetry/semantic-conventions@1.9.1": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.9.1.tgz#ad3367684a57879392513479e0a436cb2ac46dad" - integrity sha512-oPQdbFDmZvjXk5ZDoBGXG8B4tSB/qW5vQunJWQMFUBp7Xe8O1ByPANueJ+Jzg58esEBegyyxZ7LRmfJr7kFcFg== - -"@pkgr/utils@^2.3.1": - version "2.3.1" - resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.3.1.tgz#0a9b06ffddee364d6642b3cd562ca76f55b34a03" - integrity sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw== - dependencies: - cross-spawn "^7.0.3" - is-glob "^4.0.3" - open "^8.4.0" - picocolors "^1.0.0" - tiny-glob "^0.2.9" - tslib "^2.4.0" - -"@polka/url@^1.0.0-next.20": - version "1.0.0-next.21" - resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" - integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== - -"@sinclair/typebox@^0.25.16": - version "0.25.21" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.21.tgz#763b05a4b472c93a8db29b2c3e359d55b29ce272" - integrity sha512-gFukHN4t8K4+wVC+ECqeqwzBDeFeTzBXroBTqE6vcWrQGbEUpHO7LYdG0f4xnvYq4VOEwITSlHlp0JBAIFMS/g== - -"@tootallnate/once@1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" - integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== - -"@tootallnate/once@2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" - integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== - -"@trysound/sax@0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" - integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== - -"@types/d3-selection@*", "@types/d3-selection@^3.0.4": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.4.tgz#923d7f8985718116de56f55307d26e5f00728dc5" - integrity sha512-ZeykX7286BCyMg9sH5fIAORyCB6hcATPSRQpN47jwBA2bMbAT0s+EvtDP5r1FZYJ95R8QoEE1CKJX+n0/M5Vhg== - -"@types/d3-transition@^3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.3.tgz#d4ac37d08703fb039c87f92851a598ba77400402" - integrity sha512-/S90Od8Id1wgQNvIA8iFv9jRhCiZcGhPd2qX0bKF/PS+y0W5CrXKgIiELd2CvG1mlQrWK/qlYh3VxicqG1ZvgA== - dependencies: - "@types/d3-selection" "*" - -"@types/eslint-scope@^3.7.3": - version "3.7.4" - resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" - integrity sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA== - dependencies: - "@types/eslint" "*" - "@types/estree" "*" - -"@types/eslint@*": - version "8.21.0" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.21.0.tgz#21724cfe12b96696feafab05829695d4d7bd7c48" - integrity sha512-35EhHNOXgxnUgh4XCJsGhE7zdlDhYDN/aMG6UbkByCFFNgQ7b3U+uVoqBpicFydR8JEfgdjCF7SJ7MiJfzuiTA== - dependencies: - "@types/estree" "*" - "@types/json-schema" "*" - -"@types/estree@*": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2" - integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ== - -"@types/estree@^0.0.51": - version "0.0.51" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" - integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== - -"@types/glob@8.0.1": - version "8.0.1" - resolved "https://registry.yarnpkg.com/@types/glob/-/glob-8.0.1.tgz#6e3041640148b7764adf21ce5c7138ad454725b0" - integrity sha512-8bVUjXZvJacUFkJXHdyZ9iH1Eaj5V7I8c4NdH5sQJsdXkqT4CA5Dhb4yb4VE/3asyx4L9ayZr1NIhTsWHczmMw== - dependencies: - "@types/minimatch" "^5.1.2" - "@types/node" "*" - -"@types/glob@^7.1.1": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" - integrity sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA== - dependencies: - "@types/minimatch" "*" - "@types/node" "*" - -"@types/html-minifier-terser@^6.0.0": - version "6.1.0" - resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" - integrity sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg== - -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" - integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g== - -"@types/istanbul-lib-report@*": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" - integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== - dependencies: - "@types/istanbul-lib-coverage" "*" - -"@types/istanbul-reports@^3.0.0": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff" - integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw== - dependencies: - "@types/istanbul-lib-report" "*" - -"@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": - version "7.0.11" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" - integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== - -"@types/json5@^0.0.29": - version "0.0.29" - resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" - integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== - -"@types/lodash-es@4.17.6": - version "4.17.6" - resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.6.tgz#c2ed4c8320ffa6f11b43eb89e9eaeec65966a0a0" - integrity sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg== - dependencies: - "@types/lodash" "*" - -"@types/lodash@*": - version "4.14.191" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa" - integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ== - -"@types/minimatch@*", "@types/minimatch@^5.1.2": - version "5.1.2" - resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" - integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== - -"@types/minimist@^1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" - integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== - -"@types/mocha@10.0.1": - version "10.0.1" - resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.1.tgz#2f4f65bb08bc368ac39c96da7b2f09140b26851b" - integrity sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q== - -"@types/node@*": - version "18.13.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.13.0.tgz#0400d1e6ce87e9d3032c19eb6c58205b0d3f7850" - integrity sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg== - -"@types/node@16.11.47": - version "16.11.47" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.47.tgz#efa9e3e0f72e7aa6a138055dace7437a83d9f91c" - integrity sha512-fpP+jk2zJ4VW66+wAMFoBJlx1bxmBKx4DUFf68UHgdGCOuyUTDlLWqsaNPJh7xhNDykyJ9eIzAygilP/4WoN8g== - -"@types/normalize-package-data@^2.4.0": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" - integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== - -"@types/parse-json@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" - integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== - -"@types/prop-types@*": - version "15.7.5" - resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" - integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== - -"@types/react-dom@17.0.17": - version "17.0.17" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.17.tgz#2e3743277a793a96a99f1bf87614598289da68a1" - integrity sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg== - dependencies: - "@types/react" "^17" - -"@types/react@17.0.47": - version "17.0.47" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.47.tgz#4ee71aaf4c5a9e290e03aa4d0d313c5d666b3b78" - integrity sha512-mk0BL8zBinf2ozNr3qPnlu1oyVTYq+4V7WA76RgxUAtf0Em/Wbid38KN6n4abEkvO4xMTBWmnP1FtQzgkEiJoA== - dependencies: - "@types/prop-types" "*" - "@types/scheduler" "*" - csstype "^3.0.2" - -"@types/react@^17": - version "17.0.53" - resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.53.tgz#10d4d5999b8af3d6bc6a9369d7eb953da82442ab" - integrity sha512-1yIpQR2zdYu1Z/dc1OxC+MA6GR240u3gcnP4l6mvj/PJiVaqHsQPmWttsvHsfnhfPbU2FuGmo0wSITPygjBmsw== - dependencies: - "@types/prop-types" "*" - "@types/scheduler" "*" - csstype "^3.0.2" - -"@types/scheduler@*": - version "0.16.2" - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" - integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== - -"@types/semver@^7.3.12": - version "7.3.13" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" - integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== - -"@types/sortablejs@1.15.0": - version "1.15.0" - resolved "https://registry.yarnpkg.com/@types/sortablejs/-/sortablejs-1.15.0.tgz#695e481752e2a0a311c5e73b51d5f666fc202f93" - integrity sha512-qrhtM7M41EhH4tZQTNw2/RJkxllBx3reiJpTbgWCM2Dx0U1sZ6LwKp9lfNln9uqE26ZMKUaPEYaD4rzvOWYtZw== - -"@types/trusted-types@^2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756" - integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg== - -"@types/vscode@1.72.0": - version "1.72.0" - resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.72.0.tgz#56447ca2eaca34f0d0797c8bb9e51de132f6bb0a" - integrity sha512-WvHluhUo+lQvE3I4wUagRpnkHuysB4qSyOQUyIAS9n9PYMJjepzTUD8Jyks0YeXoPD0UGctjqp2u84/b3v6Ydw== - -"@types/yargs-parser@*": - version "21.0.0" - resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" - integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== - -"@types/yargs@^17.0.8": - version "17.0.22" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.22.tgz#7dd37697691b5f17d020f3c63e7a45971ff71e9a" - integrity sha512-pet5WJ9U8yPVRhkwuEIp5ktAeAqRZOq4UdAyWLWzxbtpyXnzbtLdKiXAjJzi/KLmPGS9wk86lUFWZFN6sISo4g== - dependencies: - "@types/yargs-parser" "*" - -"@typescript-eslint/eslint-plugin@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.52.0.tgz#5fb0d43574c2411f16ea80f5fc335b8eaa7b28a8" - integrity sha512-lHazYdvYVsBokwCdKOppvYJKaJ4S41CgKBcPvyd0xjZNbvQdhn/pnJlGtQksQ/NhInzdaeaSarlBjDXHuclEbg== - dependencies: - "@typescript-eslint/scope-manager" "5.52.0" - "@typescript-eslint/type-utils" "5.52.0" - "@typescript-eslint/utils" "5.52.0" - debug "^4.3.4" - grapheme-splitter "^1.0.4" - ignore "^5.2.0" - natural-compare-lite "^1.4.0" - regexpp "^3.2.0" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/parser@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.52.0.tgz#73c136df6c0133f1d7870de7131ccf356f5be5a4" - integrity sha512-e2KiLQOZRo4Y0D/b+3y08i3jsekoSkOYStROYmPUnGMEoA0h+k2qOH5H6tcjIc68WDvGwH+PaOrP1XRzLJ6QlA== - dependencies: - "@typescript-eslint/scope-manager" "5.52.0" - "@typescript-eslint/types" "5.52.0" - "@typescript-eslint/typescript-estree" "5.52.0" - debug "^4.3.4" - -"@typescript-eslint/scope-manager@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.52.0.tgz#a993d89a0556ea16811db48eabd7c5b72dcb83d1" - integrity sha512-AR7sxxfBKiNV0FWBSARxM8DmNxrwgnYMPwmpkC1Pl1n+eT8/I2NAUPuwDy/FmDcC6F8pBfmOcaxcxRHspgOBMw== - dependencies: - "@typescript-eslint/types" "5.52.0" - "@typescript-eslint/visitor-keys" "5.52.0" - -"@typescript-eslint/type-utils@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.52.0.tgz#9fd28cd02e6f21f5109e35496df41893f33167aa" - integrity sha512-tEKuUHfDOv852QGlpPtB3lHOoig5pyFQN/cUiZtpw99D93nEBjexRLre5sQZlkMoHry/lZr8qDAt2oAHLKA6Jw== - dependencies: - "@typescript-eslint/typescript-estree" "5.52.0" - "@typescript-eslint/utils" "5.52.0" - debug "^4.3.4" - tsutils "^3.21.0" - -"@typescript-eslint/types@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.52.0.tgz#19e9abc6afb5bd37a1a9bea877a1a836c0b3241b" - integrity sha512-oV7XU4CHYfBhk78fS7tkum+/Dpgsfi91IIDy7fjCyq2k6KB63M6gMC0YIvy+iABzmXThCRI6xpCEyVObBdWSDQ== - -"@typescript-eslint/typescript-estree@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.52.0.tgz#6408cb3c2ccc01c03c278cb201cf07e73347dfca" - integrity sha512-WeWnjanyEwt6+fVrSR0MYgEpUAuROxuAH516WPjUblIrClzYJj0kBbjdnbQXLpgAN8qbEuGywiQsXUVDiAoEuQ== - dependencies: - "@typescript-eslint/types" "5.52.0" - "@typescript-eslint/visitor-keys" "5.52.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/utils@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.52.0.tgz#b260bb5a8f6b00a0ed51db66bdba4ed5e4845a72" - integrity sha512-As3lChhrbwWQLNk2HC8Ree96hldKIqk98EYvypd3It8Q1f8d5zWyIoaZEp2va5667M4ZyE7X8UUR+azXrFl+NA== - dependencies: - "@types/json-schema" "^7.0.9" - "@types/semver" "^7.3.12" - "@typescript-eslint/scope-manager" "5.52.0" - "@typescript-eslint/types" "5.52.0" - "@typescript-eslint/typescript-estree" "5.52.0" - eslint-scope "^5.1.1" - eslint-utils "^3.0.0" - semver "^7.3.7" - -"@typescript-eslint/visitor-keys@5.52.0": - version "5.52.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.52.0.tgz#e38c971259f44f80cfe49d97dbffa38e3e75030f" - integrity sha512-qMwpw6SU5VHCPr99y274xhbm+PRViK/NATY6qzt+Et7+mThGuFSl/ompj2/hrBlRP/kq+BFdgagnOSgw9TB0eA== - dependencies: - "@typescript-eslint/types" "5.52.0" - eslint-visitor-keys "^3.3.0" - -"@vscode/codicons@0.0.32": - version "0.0.32" - resolved "https://registry.yarnpkg.com/@vscode/codicons/-/codicons-0.0.32.tgz#9e27de90d509c69762b073719ba3bf46c3cd2530" - integrity sha512-3lgSTWhAzzWN/EPURoY4ZDBEA80OPmnaknNujA3qnI4Iu7AONWd9xF3iE4L+4prIe8E3TUnLQ4pxoaFTEEZNwg== - -"@vscode/test-electron@2.2.3": - version "2.2.3" - resolved "https://registry.yarnpkg.com/@vscode/test-electron/-/test-electron-2.2.3.tgz#bf6a77542970b5d34561d0671259900632e5eb94" - integrity sha512-7DmdGYQTqRNaLHKG3j56buc9DkstriY4aV0S3Zj32u0U9/T0L8vwWAC9QGCh1meu1VXDEla1ze27TkqysHGP0Q== - dependencies: - http-proxy-agent "^4.0.1" - https-proxy-agent "^5.0.0" - rimraf "^3.0.2" - unzipper "^0.10.11" - -"@vscode/test-web@0.0.34": - version "0.0.34" - resolved "https://registry.yarnpkg.com/@vscode/test-web/-/test-web-0.0.34.tgz#1e9aafacc06dd8206624cdb4c8336c82b1e001d7" - integrity sha512-KYoRogXJHBZrhajgWx5AAAbV+FufxlTQ+ywKDUu8C4/n3kI5HR+Ye/NCRHOW1DBMcMtEzznLr7RSgV7In4OCfg== - dependencies: - "@koa/cors" "^4.0.0" - "@koa/router" "^12.0.0" - decompress "^4.2.1" - decompress-targz "^4.1.1" - get-stream "6.0.1" - http-proxy-agent "^5.0.0" - https-proxy-agent "^5.0.1" - koa "^2.14.1" - koa-morgan "^1.0.1" - koa-mount "^4.0.0" - koa-static "^5.0.0" - minimist "^1.2.7" - playwright "^1.29.2" - vscode-uri "^3.0.7" - -"@vscode/vsce@2.17.0": - version "2.17.0" - resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-2.17.0.tgz#64093e1e083673081ea8bd7e69745ff2f632003e" - integrity sha512-W4HN5MtTVj/mroQU1d82bUEeWM3dUykMFnMYZPtZ6jrMiHN1PUoN3RGcS896N0r2rIq8KpWDtufcQHgK8VfgpA== - dependencies: - azure-devops-node-api "^11.0.1" - chalk "^2.4.2" - cheerio "^1.0.0-rc.9" - commander "^6.1.0" - glob "^7.0.6" - hosted-git-info "^4.0.2" - leven "^3.1.0" - markdown-it "^12.3.2" - mime "^1.3.4" - minimatch "^3.0.3" - parse-semver "^1.1.1" - read "^1.0.7" - semver "^5.1.0" - tmp "^0.2.1" - typed-rest-client "^1.8.4" - url-join "^4.0.1" - xml2js "^0.4.23" - yauzl "^2.3.1" - yazl "^2.2.2" - optionalDependencies: - keytar "^7.7.0" - -"@vscode/webview-ui-toolkit@1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@vscode/webview-ui-toolkit/-/webview-ui-toolkit-1.2.1.tgz#7c2eef9d1a192f9ee16ec3c6a0964400c2eea963" - integrity sha512-ZpVqLxoFWWk8mmAN7jr1v9yjD6NGBIoflAedNSusmaViqwHZ2znKBwAwcumLOlNlqmST6QMkiTVys7O8rzfd0w== - dependencies: - "@microsoft/fast-element" "^1.6.2" - "@microsoft/fast-foundation" "^2.38.0" - "@microsoft/fast-react-wrapper" "^0.1.18" - -"@webassemblyjs/ast@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" - integrity sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw== - dependencies: - "@webassemblyjs/helper-numbers" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - -"@webassemblyjs/floating-point-hex-parser@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz#f6c61a705f0fd7a6aecaa4e8198f23d9dc179e4f" - integrity sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ== - -"@webassemblyjs/helper-api-error@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz#1a63192d8788e5c012800ba6a7a46c705288fd16" - integrity sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg== - -"@webassemblyjs/helper-buffer@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz#832a900eb444884cde9a7cad467f81500f5e5ab5" - integrity sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA== - -"@webassemblyjs/helper-numbers@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz#64d81da219fbbba1e3bd1bfc74f6e8c4e10a62ae" - integrity sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ== - dependencies: - "@webassemblyjs/floating-point-hex-parser" "1.11.1" - "@webassemblyjs/helper-api-error" "1.11.1" - "@xtuc/long" "4.2.2" - -"@webassemblyjs/helper-wasm-bytecode@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz#f328241e41e7b199d0b20c18e88429c4433295e1" - integrity sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q== - -"@webassemblyjs/helper-wasm-section@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz#21ee065a7b635f319e738f0dd73bfbda281c097a" - integrity sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" - -"@webassemblyjs/ieee754@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz#963929e9bbd05709e7e12243a099180812992614" - integrity sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ== - dependencies: - "@xtuc/ieee754" "^1.2.0" - -"@webassemblyjs/leb128@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.1.tgz#ce814b45574e93d76bae1fb2644ab9cdd9527aa5" - integrity sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw== - dependencies: - "@xtuc/long" "4.2.2" - -"@webassemblyjs/utf8@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.1.tgz#d1f8b764369e7c6e6bae350e854dec9a59f0a3ff" - integrity sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ== - -"@webassemblyjs/wasm-edit@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz#ad206ebf4bf95a058ce9880a8c092c5dec8193d6" - integrity sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/helper-wasm-section" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" - "@webassemblyjs/wasm-opt" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" - "@webassemblyjs/wast-printer" "1.11.1" - -"@webassemblyjs/wasm-gen@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz#86c5ea304849759b7d88c47a32f4f039ae3c8f76" - integrity sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/ieee754" "1.11.1" - "@webassemblyjs/leb128" "1.11.1" - "@webassemblyjs/utf8" "1.11.1" - -"@webassemblyjs/wasm-opt@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz#657b4c2202f4cf3b345f8a4c6461c8c2418985f2" - integrity sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" - -"@webassemblyjs/wasm-parser@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz#86ca734534f417e9bd3c67c7a1c75d8be41fb199" - integrity sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-api-error" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/ieee754" "1.11.1" - "@webassemblyjs/leb128" "1.11.1" - "@webassemblyjs/utf8" "1.11.1" - -"@webassemblyjs/wast-printer@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz#d0c73beda8eec5426f10ae8ef55cee5e7084c2f0" - integrity sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@xtuc/long" "4.2.2" - -"@webpack-cli/configtest@^2.0.1": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.0.1.tgz#a69720f6c9bad6aef54a8fa6ba9c3533e7ef4c7f" - integrity sha512-njsdJXJSiS2iNbQVS0eT8A/KPnmyH4pv1APj2K0d1wrZcBLw+yppxOy4CGqa0OxDJkzfL/XELDhD8rocnIwB5A== - -"@webpack-cli/info@^2.0.1": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-2.0.1.tgz#eed745799c910d20081e06e5177c2b2569f166c0" - integrity sha512-fE1UEWTwsAxRhrJNikE7v4EotYflkEhBL7EbajfkPlf6E37/2QshOy/D48Mw8G5XMFlQtS6YV42vtbG9zBpIQA== - -"@webpack-cli/serve@^2.0.1": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.1.tgz#34bdc31727a1889198855913db2f270ace6d7bf8" - integrity sha512-0G7tNyS+yW8TdgHwZKlDWYXFA6OJQnoLCQvYKkQP0Q2X205PSQ6RNUj0M+1OB/9gRQaUZ/ccYfaxd0nhaWKfjw== - -"@xmldom/xmldom@^0.7.2": - version "0.7.9" - resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.9.tgz#7f9278a50e737920e21b297b8a35286e9942c056" - integrity sha512-yceMpm/xd4W2a85iqZyO09gTnHvXF6pyiWjD2jcOJs7hRoZtNNOO1eJlhHj1ixA+xip2hOyGn+LgcvLCMo5zXA== - -"@xtuc/ieee754@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" - integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== - -"@xtuc/long@4.2.2": - version "4.2.2" - resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" - integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== - -abbrev@1, abbrev@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - -accepts@^1.3.5: - version "1.3.8" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" - integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - dependencies: - mime-types "~2.1.34" - negotiator "0.6.3" - -acorn-import-assertions@^1.7.6: - version "1.8.0" - resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz#ba2b5939ce62c238db6d93d81c9b111b29b855e9" - integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw== - -acorn-jsx@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" - integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== - -acorn-walk@^8.0.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" - integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== - -acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.0: - version "8.8.2" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" - integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== - -agent-base@6, agent-base@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - -agentkeepalive@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.2.1.tgz#a7975cbb9f83b367f06c90cc51ff28fe7d499717" - integrity sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA== - dependencies: - debug "^4.1.0" - depd "^1.1.2" - humanize-ms "^1.2.1" - -aggregate-error@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" - integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== - dependencies: - clean-stack "^2.0.0" - indent-string "^4.0.0" - -ajv-formats@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" - integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== - dependencies: - ajv "^8.0.0" - -ajv-keywords@^3.4.1, ajv-keywords@^3.5.2: - version "3.5.2" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" - integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== - -ajv-keywords@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz#69d4d385a4733cdbeab44964a1170a88f87f0e16" - integrity sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw== - dependencies: - fast-deep-equal "^3.1.3" - -ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ajv@^8.0.0, ajv@^8.8.0: - version "8.12.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" - integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== - dependencies: - fast-deep-equal "^3.1.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - uri-js "^4.2.2" - -ansi-colors@4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" - integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== - -ansi-regex@6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" - integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -ansi-styles@^4.0.0, ansi-styles@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -anti-trojan-source@^1.3.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/anti-trojan-source/-/anti-trojan-source-1.4.1.tgz#54ce3b726cbf5240e10c6decf236cc624efd3b33" - integrity sha512-DruSp30RgiEW36/n5+e2RtJf2W57jBS01YHvH8SL1vSFIpIeArfreTCxelHPMEhGLpk/BZUeA3uWt5AeTCHq9g== - dependencies: - globby "^12.0.2" - meow "^10.1.1" - -anymatch@~3.1.2: - version "3.1.3" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" - integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -"aproba@^1.0.3 || ^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" - integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== - -are-we-there-yet@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz#679df222b278c64f2cdba1175cdc00b0d96164bd" - integrity sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg== - dependencies: - delegates "^1.0.0" - readable-stream "^3.6.0" - -argparse@^1.0.6: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - -argparse@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" - integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== - -array-find-index@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" - integrity sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw== - -array-includes@^3.1.6: - version "3.1.6" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.6.tgz#9e9e720e194f198266ba9e18c29e6a9b0e4b225f" - integrity sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - get-intrinsic "^1.1.3" - is-string "^1.0.7" - -array-union@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" - integrity sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng== - dependencies: - array-uniq "^1.0.1" - -array-union@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" - integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== - -array-union@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-3.0.1.tgz#da52630d327f8b88cfbfb57728e2af5cd9b6b975" - integrity sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw== - -array-uniq@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" - integrity sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q== - -array.prototype.flat@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz#ffc6576a7ca3efc2f46a143b9d1dda9b4b3cf5e2" - integrity sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - es-shim-unscopables "^1.0.0" - -array.prototype.flatmap@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz#1aae7903c2100433cb8261cd4ed310aab5c4a183" - integrity sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - es-shim-unscopables "^1.0.0" - -arrify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" - integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA== - -asap@^2.0.0: - version "2.0.6" - resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" - integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== - -at-least-node@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" - integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== - -atoa@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/atoa/-/atoa-1.0.0.tgz#0cc0e91a480e738f923ebc103676471779b34a49" - integrity sha512-VVE1H6cc4ai+ZXo/CRWoJiHXrA1qfA31DPnx6D20+kSI547hQN5Greh51LQ1baMRMfxO5K5M4ImMtZbZt2DODQ== - -autoprefixer@^10.4.12: - version "10.4.13" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.13.tgz#b5136b59930209a321e9fa3dca2e7c4d223e83a8" - integrity sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg== - dependencies: - browserslist "^4.21.4" - caniuse-lite "^1.0.30001426" - fraction.js "^4.2.0" - normalize-range "^0.1.2" - picocolors "^1.0.0" - postcss-value-parser "^4.2.0" - -available-typed-arrays@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" - integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== - -azure-devops-node-api@^11.0.1: - version "11.2.0" - resolved "https://registry.yarnpkg.com/azure-devops-node-api/-/azure-devops-node-api-11.2.0.tgz#bf04edbef60313117a0507415eed4790a420ad6b" - integrity sha512-XdiGPhrpaT5J8wdERRKs5g8E0Zy1pvOYTli7z9E8nmOn3YGp4FhtjhrOyFmX/8veWCwdI69mCHKJw6l+4J/bHA== - dependencies: - tunnel "0.0.6" - typed-rest-client "^1.8.4" - -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - -basic-auth@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" - integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== - dependencies: - safe-buffer "5.1.2" - -before-after-hook@^2.2.0: - version "2.2.3" - resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c" - integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ== - -big-integer@^1.6.17: - version "1.6.51" - resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686" - integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg== - -big.js@^5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" - integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== - -billboard.js@3.7.4: - version "3.7.4" - resolved "https://registry.yarnpkg.com/billboard.js/-/billboard.js-3.7.4.tgz#94181655168a8e44025279894e9442c97d1e7965" - integrity sha512-FFmmiiJCPdwE4OKE8L0XttP94ESOBqEvSQZDx+vA/LyK2pmvP8P/oU5perFS14lBTEojunuurvHAaVN1S9i1wA== - dependencies: - "@types/d3-selection" "^3.0.4" - "@types/d3-transition" "^3.0.3" - d3-axis "^3.0.0" - d3-brush "^3.0.0" - d3-drag "^3.0.0" - d3-dsv "^3.0.1" - d3-ease "^3.0.1" - d3-hierarchy "^3.1.2" - d3-interpolate "^3.0.1" - d3-scale "^4.0.2" - d3-selection "^3.0.0" - d3-shape "^3.2.0" - d3-time-format "^4.1.0" - d3-transition "^3.0.1" - d3-zoom "^3.0.0" - -binary-extensions@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" - integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== - -binary@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79" - integrity sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg== - dependencies: - buffers "~0.1.1" - chainsaw "~0.1.0" - -bindings@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" - integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== - dependencies: - file-uri-to-path "1.0.0" - -bl@^1.0.0: - version "1.2.3" - resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7" - integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww== - dependencies: - readable-stream "^2.3.5" - safe-buffer "^5.1.1" - -bl@^4.0.3: - version "4.1.0" - resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" - integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== - dependencies: - buffer "^5.5.0" - inherits "^2.0.4" - readable-stream "^3.4.0" - -bluebird@~3.4.1: - version "3.4.7" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" - integrity sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA== - -boolbase@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== - dependencies: - balanced-match "^1.0.0" - -braces@^3.0.2, braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== - dependencies: - fill-range "^7.0.1" - -browser-stdout@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" - integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== - -browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.6, browserslist@^4.21.4: - version "4.21.5" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.5.tgz#75c5dae60063ee641f977e00edd3cfb2fb7af6a7" - integrity sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w== - dependencies: - caniuse-lite "^1.0.30001449" - electron-to-chromium "^1.4.284" - node-releases "^2.0.8" - update-browserslist-db "^1.0.10" - -buffer-alloc-unsafe@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" - integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== - -buffer-alloc@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" - integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== - dependencies: - buffer-alloc-unsafe "^1.1.0" - buffer-fill "^1.0.0" - -buffer-crc32@~0.2.3: - version "0.2.13" - resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" - integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== - -buffer-fill@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" - integrity sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ== - -buffer-from@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" - integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== - -buffer-indexof-polyfill@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c" - integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A== - -buffer@^5.2.1, buffer@^5.5.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - -buffers@~0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb" - integrity sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ== - -bufferstreams@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/bufferstreams/-/bufferstreams-3.0.0.tgz#d2cb186cffeb527668341891e523c19539bc4a14" - integrity sha512-Qg0ggJUWJq90vtg4lDsGN9CDWvzBMQxhiEkSOD/sJfYt6BLect3eV1/S6K7SCSKJ34n60rf6U5eUPmQENVE4UA== - dependencies: - readable-stream "^3.4.0" - -cacache@^16.1.0: - version "16.1.3" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-16.1.3.tgz#a02b9f34ecfaf9a78c9f4bc16fceb94d5d67a38e" - integrity sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ== - dependencies: - "@npmcli/fs" "^2.1.0" - "@npmcli/move-file" "^2.0.0" - chownr "^2.0.0" - fs-minipass "^2.1.0" - glob "^8.0.1" - infer-owner "^1.0.4" - lru-cache "^7.7.1" - minipass "^3.1.6" - minipass-collect "^1.0.2" - minipass-flush "^1.0.5" - minipass-pipeline "^1.2.4" - mkdirp "^1.0.4" - p-map "^4.0.0" - promise-inflight "^1.0.1" - rimraf "^3.0.2" - ssri "^9.0.0" - tar "^6.1.11" - unique-filename "^2.0.0" - -cache-content-type@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c" - integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA== - dependencies: - mime-types "^2.1.18" - ylru "^1.2.0" - -call-bind@^1.0.0, call-bind@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" - integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== - dependencies: - function-bind "^1.1.1" - get-intrinsic "^1.0.2" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -camel-case@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a" - integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== - dependencies: - pascal-case "^3.1.2" - tslib "^2.0.3" - -camelcase-keys@^7.0.0: - version "7.0.2" - resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-7.0.2.tgz#d048d8c69448745bb0de6fc4c1c52a30dfbe7252" - integrity sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg== - dependencies: - camelcase "^6.3.0" - map-obj "^4.1.0" - quick-lru "^5.1.1" - type-fest "^1.2.1" - -camelcase@^6.0.0, camelcase@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - -caniuse-api@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" - integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== - dependencies: - browserslist "^4.0.0" - caniuse-lite "^1.0.0" - lodash.memoize "^4.1.2" - lodash.uniq "^4.5.0" - -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449: - version "1.0.30001451" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001451.tgz#2e197c698fc1373d63e1406d6607ea4617c613f1" - integrity sha512-XY7UbUpGRatZzoRft//5xOa69/1iGJRBlrieH6QYrkKLIFn3m7OVEJ81dSrKoy2BnKsdbX5cLrOispZNYo9v2w== - -capital-case@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/capital-case/-/capital-case-1.0.4.tgz#9d130292353c9249f6b00fa5852bee38a717e669" - integrity sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - upper-case-first "^2.0.2" - -chainsaw@~0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98" - integrity sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ== - dependencies: - traverse ">=0.3.0 <0.4" - -chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -change-case@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/change-case/-/change-case-4.1.2.tgz#fedfc5f136045e2398c0410ee441f95704641e12" - integrity sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A== - dependencies: - camel-case "^4.1.2" - capital-case "^1.0.4" - constant-case "^3.0.4" - dot-case "^3.0.4" - header-case "^2.0.4" - no-case "^3.0.4" - param-case "^3.0.4" - pascal-case "^3.1.2" - path-case "^3.0.4" - sentence-case "^3.0.4" - snake-case "^3.0.4" - tslib "^2.0.3" - -cheerio-select@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" - integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== - dependencies: - boolbase "^1.0.0" - css-select "^5.1.0" - css-what "^6.1.0" - domelementtype "^2.3.0" - domhandler "^5.0.3" - domutils "^3.0.1" - -cheerio@^1.0.0-rc.5, cheerio@^1.0.0-rc.9: - version "1.0.0-rc.12" - resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683" - integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== - dependencies: - cheerio-select "^2.1.0" - dom-serializer "^2.0.0" - domhandler "^5.0.3" - domutils "^3.0.1" - htmlparser2 "^8.0.1" - parse5 "^7.0.0" - parse5-htmlparser2-tree-adapter "^7.0.0" - -chokidar@3.5.3, "chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.2, chokidar@^3.5.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - -chownr@^1.1.1: - version "1.1.4" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" - integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== - -chownr@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" - integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== - -chrome-trace-event@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" - integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== - -ci-info@^3.2.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" - integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== - -circular-dependency-plugin@5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/circular-dependency-plugin/-/circular-dependency-plugin-5.2.2.tgz#39e836079db1d3cf2f988dc48c5188a44058b600" - integrity sha512-g38K9Cm5WRwlaH6g03B9OEz/0qRizI+2I7n+Gz+L5DxXJAPAiWQvwlYNm1V1jkdpUv95bOe/ASm2vfi/G560jQ== - -classnames@2.3.2, classnames@^2.2.5: - version "2.3.2" - resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" - integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== - -clean-css@5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.2.0.tgz#44e4a04e8873ff0041df97acecf23a4a6519844e" - integrity sha512-2639sWGa43EMmG7fn8mdVuBSs6HuWaSor+ZPoFWzenBc6oN+td8YhTfghWXZ25G1NiiSvz8bOFBS7PdSbTiqEA== - dependencies: - source-map "~0.6.0" - -clean-css@^5.2.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.2.tgz#70ecc7d4d4114921f5d298349ff86a31a9975224" - integrity sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww== - dependencies: - source-map "~0.6.0" - -clean-stack@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" - integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== - -clean-webpack-plugin@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/clean-webpack-plugin/-/clean-webpack-plugin-4.0.0.tgz#72947d4403d452f38ed61a9ff0ada8122aacd729" - integrity sha512-WuWE1nyTNAyW5T7oNyys2EN0cfP2fdRxhxnIQWiAp0bMabPdHhoGxM8A6YL2GhqwgrPnnaemVE7nv5XJ2Fhh2w== - dependencies: - del "^4.1.1" - -cli-color@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-2.0.3.tgz#73769ba969080629670f3f2ef69a4bf4e7cc1879" - integrity sha512-OkoZnxyC4ERN3zLzZaY9Emb7f/MhBOIpePv0Ycok0fJYT+Ouo00UBEIwsVsr0yoow++n5YWlSUgST9GKhNHiRQ== - dependencies: - d "^1.0.1" - es5-ext "^0.10.61" - es6-iterator "^2.0.3" - memoizee "^0.4.15" - timers-ext "^0.1.7" - -cliui@^7.0.2: - version "7.0.4" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" - integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^7.0.0" - -cliui@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" - integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.1" - wrap-ansi "^7.0.0" - -clone-deep@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" - integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== - dependencies: - is-plain-object "^2.0.4" - kind-of "^6.0.2" - shallow-clone "^3.0.0" - -clsx@^1.0.4: - version "1.2.1" - resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" - integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== - -co@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" - integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - -color-name@^1.0.0, color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -color-string@^1.9.0: - version "1.9.1" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" - integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== - dependencies: - color-name "^1.0.0" - simple-swizzle "^0.2.2" - -color-support@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" - integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== - -color@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" - integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== - dependencies: - color-convert "^2.0.1" - color-string "^1.9.0" - -colord@^2.9.1: - version "2.9.3" - resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43" - integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw== - -colorette@^2.0.14: - version "2.0.19" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" - integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== - -commander@7, commander@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - -commander@^2.20.0, commander@^2.8.1: - version "2.20.3" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - -commander@^6.1.0: - version "6.2.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" - integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== - -commander@^8.3.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" - integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== - -commander@^9.0.0, commander@^9.4.1: - version "9.5.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-9.5.0.tgz#bc08d1eb5cedf7ccb797a96199d41c7bc3e60d30" - integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - -concurrently@7.6.0: - version "7.6.0" - resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-7.6.0.tgz#531a6f5f30cf616f355a4afb8f8fcb2bba65a49a" - integrity sha512-BKtRgvcJGeZ4XttiDiNcFiRlxoAeZOseqUvyYRUp/Vtd+9p1ULmeoSqGsDA+2ivdeDFpqrJvGvmI+StKfKl5hw== - dependencies: - chalk "^4.1.0" - date-fns "^2.29.1" - lodash "^4.17.21" - rxjs "^7.0.0" - shell-quote "^1.7.3" - spawn-command "^0.0.2-1" - supports-color "^8.1.0" - tree-kill "^1.2.2" - yargs "^17.3.1" - -console-control-strings@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== - -constant-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/constant-case/-/constant-case-3.0.4.tgz#3b84a9aeaf4cf31ec45e6bf5de91bdfb0589faf1" - integrity sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - upper-case "^2.0.2" - -content-disposition@~0.5.2: - version "0.5.4" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" - integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== - dependencies: - safe-buffer "5.2.1" - -content-type@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== - -contra@1.9.4: - version "1.9.4" - resolved "https://registry.yarnpkg.com/contra/-/contra-1.9.4.tgz#f53bde42d7e5b5985cae4d99a8d610526de8f28d" - integrity sha512-N9ArHAqwR/lhPq4OdIAwH4e1btn6EIZMAz4TazjnzCiVECcWUPTma+dRAM38ERImEJBh8NiCCpjoQruSZ+agYg== - dependencies: - atoa "1.0.0" - ticky "1.0.1" - -cookies@~0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90" - integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow== - dependencies: - depd "~2.0.0" - keygrip "~1.1.0" - -copy-webpack-plugin@11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz#96d4dbdb5f73d02dd72d0528d1958721ab72e04a" - integrity sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ== - dependencies: - fast-glob "^3.2.11" - glob-parent "^6.0.1" - globby "^13.1.1" - normalize-path "^3.0.0" - schema-utils "^4.0.0" - serialize-javascript "^6.0.0" - -core-js@^2.6.12: - version "2.6.12" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" - integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== - -core-util-is@~1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" - integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== - -cosmiconfig@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" - integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== - dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.1.0" - parse-json "^5.0.0" - path-type "^4.0.0" - yaml "^1.7.2" - -cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -crossvent@1.5.4: - version "1.5.4" - resolved "https://registry.yarnpkg.com/crossvent/-/crossvent-1.5.4.tgz#da2c4f8f40c94782517bf2beec1044148194ab92" - integrity sha512-b6gEmNAh3kemyfNJ0LQzA/29A+YeGwevlSkNp2x0TzLOMYc0b85qRAD06OUuLWLQpR7HdJHNZQTlD1cfwoTrzg== - dependencies: - custom-event "1.0.0" - -csp-html-webpack-plugin@5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/csp-html-webpack-plugin/-/csp-html-webpack-plugin-5.1.0.tgz#b3bfa5a50d207fe5b6bb4839dc33aa59621a35a0" - integrity sha512-6l/s6hACE+UA01PLReNKZfgLZWM98f7ewWmE79maDWIbEXiPcIWQGB3LQR/Zw+hPBj4XPZZ5zNrrO+aygqaLaQ== - dependencies: - cheerio "^1.0.0-rc.5" - lodash "^4.17.20" - -css-declaration-sorter@^6.3.1: - version "6.3.1" - resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz#be5e1d71b7a992433fb1c542c7a1b835e45682ec" - integrity sha512-fBffmak0bPAnyqc/HO8C3n2sHrp9wcqQz6ES9koRF2/mLOVAx9zIQ3Y7R29sYCteTPqMCwns4WYQoCX91Xl3+w== - -css-loader@6.7.3: - version "6.7.3" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.7.3.tgz#1e8799f3ccc5874fdd55461af51137fcc5befbcd" - integrity sha512-qhOH1KlBMnZP8FzRO6YCH9UHXQhVMcEGLyNdb7Hv2cpcmJbW0YrddO+tG1ab5nT41KpHIYGsbeHqxB9xPu1pKQ== - dependencies: - icss-utils "^5.1.0" - postcss "^8.4.19" - postcss-modules-extract-imports "^3.0.0" - postcss-modules-local-by-default "^4.0.0" - postcss-modules-scope "^3.0.0" - postcss-modules-values "^4.0.0" - postcss-value-parser "^4.2.0" - semver "^7.3.8" - -css-minimizer-webpack-plugin@4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-4.2.2.tgz#79f6199eb5adf1ff7ba57f105e3752d15211eb35" - integrity sha512-s3Of/4jKfw1Hj9CxEO1E5oXhQAxlayuHO2y/ML+C6I9sQ7FdzfEV6QgMLN3vI+qFsjJGIAFLKtQK7t8BOXAIyA== - dependencies: - cssnano "^5.1.8" - jest-worker "^29.1.2" - postcss "^8.4.17" - schema-utils "^4.0.0" - serialize-javascript "^6.0.0" - source-map "^0.6.1" - -css-select@^4.1.3: - version "4.3.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" - integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== - dependencies: - boolbase "^1.0.0" - css-what "^6.0.1" - domhandler "^4.3.1" - domutils "^2.8.0" - nth-check "^2.0.1" - -css-select@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" - integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== - dependencies: - boolbase "^1.0.0" - css-what "^6.1.0" - domhandler "^5.0.2" - domutils "^3.0.1" - nth-check "^2.0.1" - -css-tree@^1.1.2, css-tree@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" - integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== - dependencies: - mdn-data "2.0.14" - source-map "^0.6.1" - -css-tree@^2.2.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" - integrity sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw== - dependencies: - mdn-data "2.0.30" - source-map-js "^1.0.1" - -css-tree@~2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.2.1.tgz#36115d382d60afd271e377f9c5f67d02bd48c032" - integrity sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA== - dependencies: - mdn-data "2.0.28" - source-map-js "^1.0.1" - -css-what@^6.0.1, css-what@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" - integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== - -cssesc@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" - integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== - -cssnano-preset-advanced@5.3.10: - version "5.3.10" - resolved "https://registry.yarnpkg.com/cssnano-preset-advanced/-/cssnano-preset-advanced-5.3.10.tgz#25558a1fbf3a871fb6429ce71e41be7f5aca6eef" - integrity sha512-fnYJyCS9jgMU+cmHO1rPSPf9axbQyD7iUhLO5Df6O4G+fKIOMps+ZbU0PdGFejFBBZ3Pftf18fn1eG7MAPUSWQ== - dependencies: - autoprefixer "^10.4.12" - cssnano-preset-default "^5.2.14" - postcss-discard-unused "^5.1.0" - postcss-merge-idents "^5.1.1" - postcss-reduce-idents "^5.2.0" - postcss-zindex "^5.1.0" - -cssnano-preset-default@^5.2.13: - version "5.2.13" - resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.13.tgz#e7353b0c57975d1bdd97ac96e68e5c1b8c68e990" - integrity sha512-PX7sQ4Pb+UtOWuz8A1d+Rbi+WimBIxJTRyBdgGp1J75VU0r/HFQeLnMYgHiCAp6AR4rqrc7Y4R+1Rjk3KJz6DQ== - dependencies: - css-declaration-sorter "^6.3.1" - cssnano-utils "^3.1.0" - postcss-calc "^8.2.3" - postcss-colormin "^5.3.0" - postcss-convert-values "^5.1.3" - postcss-discard-comments "^5.1.2" - postcss-discard-duplicates "^5.1.0" - postcss-discard-empty "^5.1.1" - postcss-discard-overridden "^5.1.0" - postcss-merge-longhand "^5.1.7" - postcss-merge-rules "^5.1.3" - postcss-minify-font-values "^5.1.0" - postcss-minify-gradients "^5.1.1" - postcss-minify-params "^5.1.4" - postcss-minify-selectors "^5.2.1" - postcss-normalize-charset "^5.1.0" - postcss-normalize-display-values "^5.1.0" - postcss-normalize-positions "^5.1.1" - postcss-normalize-repeat-style "^5.1.1" - postcss-normalize-string "^5.1.0" - postcss-normalize-timing-functions "^5.1.0" - postcss-normalize-unicode "^5.1.1" - postcss-normalize-url "^5.1.0" - postcss-normalize-whitespace "^5.1.1" - postcss-ordered-values "^5.1.3" - postcss-reduce-initial "^5.1.1" - postcss-reduce-transforms "^5.1.0" - postcss-svgo "^5.1.0" - postcss-unique-selectors "^5.1.1" - -cssnano-preset-default@^5.2.14: - version "5.2.14" - resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz#309def4f7b7e16d71ab2438052093330d9ab45d8" - integrity sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A== - dependencies: - css-declaration-sorter "^6.3.1" - cssnano-utils "^3.1.0" - postcss-calc "^8.2.3" - postcss-colormin "^5.3.1" - postcss-convert-values "^5.1.3" - postcss-discard-comments "^5.1.2" - postcss-discard-duplicates "^5.1.0" - postcss-discard-empty "^5.1.1" - postcss-discard-overridden "^5.1.0" - postcss-merge-longhand "^5.1.7" - postcss-merge-rules "^5.1.4" - postcss-minify-font-values "^5.1.0" - postcss-minify-gradients "^5.1.1" - postcss-minify-params "^5.1.4" - postcss-minify-selectors "^5.2.1" - postcss-normalize-charset "^5.1.0" - postcss-normalize-display-values "^5.1.0" - postcss-normalize-positions "^5.1.1" - postcss-normalize-repeat-style "^5.1.1" - postcss-normalize-string "^5.1.0" - postcss-normalize-timing-functions "^5.1.0" - postcss-normalize-unicode "^5.1.1" - postcss-normalize-url "^5.1.0" - postcss-normalize-whitespace "^5.1.1" - postcss-ordered-values "^5.1.3" - postcss-reduce-initial "^5.1.2" - postcss-reduce-transforms "^5.1.0" - postcss-svgo "^5.1.0" - postcss-unique-selectors "^5.1.1" - -cssnano-utils@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/cssnano-utils/-/cssnano-utils-3.1.0.tgz#95684d08c91511edfc70d2636338ca37ef3a6861" - integrity sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA== - -cssnano@^5.1.8: - version "5.1.14" - resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-5.1.14.tgz#07b0af6da73641276fe5a6d45757702ebae2eb05" - integrity sha512-Oou7ihiTocbKqi0J1bB+TRJIQX5RMR3JghA8hcWSw9mjBLQ5Y3RWqEDoYG3sRNlAbCIXpqMoZGbq5KDR3vdzgw== - dependencies: - cssnano-preset-default "^5.2.13" - lilconfig "^2.0.3" - yaml "^1.10.2" - -csso@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" - integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== - dependencies: - css-tree "^1.1.2" - -csso@^5.0.5: - version "5.0.5" - resolved "https://registry.yarnpkg.com/csso/-/csso-5.0.5.tgz#f9b7fe6cc6ac0b7d90781bb16d5e9874303e2ca6" - integrity sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ== - dependencies: - css-tree "~2.2.0" - -csstype@^3.0.2: - version "3.1.1" - resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" - integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw== - -cubic2quad@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/cubic2quad/-/cubic2quad-1.2.1.tgz#2442260b72c02ee4b6a2fe998fcc1c4073622286" - integrity sha512-wT5Y7mO8abrV16gnssKdmIhIbA9wSkeMzhh27jAguKrV82i24wER0vL5TGhUJ9dbJNDcigoRZ0IAHFEEEI4THQ== - -custom-event@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.0.tgz#2e4628be19dc4b214b5c02630c5971e811618062" - integrity sha512-6nOXX3UitrmdvSJWoVR2dlzhbX5bEUqmqsMUyx1ypCLZkHHkcuYtdpW3p94RGvcFkTV7DkLo+Ilbwnlwi8L+jw== - -"d3-array@2 - 3", "d3-array@2.10.0 - 3": - version "3.2.2" - resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.2.tgz#f8ac4705c5b06914a7e0025bbf8d5f1513f6a86e" - integrity sha512-yEEyEAbDrF8C6Ob2myOBLjwBLck1Z89jMGFee0oPsn95GqjerpaOA4ch+vc2l0FNFFwMD5N7OCSEN5eAlsUbgQ== - dependencies: - internmap "1 - 2" - -d3-axis@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322" - integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw== - -d3-brush@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c" - integrity sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ== - dependencies: - d3-dispatch "1 - 3" - d3-drag "2 - 3" - d3-interpolate "1 - 3" - d3-selection "3" - d3-transition "3" - -"d3-color@1 - 3": - version "3.1.0" - resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" - integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== - -"d3-dispatch@1 - 3": - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" - integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== - -"d3-drag@2 - 3", d3-drag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" - integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== - dependencies: - d3-dispatch "1 - 3" - d3-selection "3" - -d3-dsv@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-3.0.1.tgz#c63af978f4d6a0d084a52a673922be2160789b73" - integrity sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q== - dependencies: - commander "7" - iconv-lite "0.6" - rw "1" - -"d3-ease@1 - 3", d3-ease@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" - integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== - -"d3-format@1 - 3": - version "3.1.0" - resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" - integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== - -d3-hierarchy@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz#b01cd42c1eed3d46db77a5966cf726f8c09160c6" - integrity sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA== - -"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" - integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== - dependencies: - d3-color "1 - 3" - -d3-path@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" - integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== - -d3-scale@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" - integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== - dependencies: - d3-array "2.10.0 - 3" - d3-format "1 - 3" - d3-interpolate "1.2.0 - 3" - d3-time "2.1.1 - 3" - d3-time-format "2 - 4" - -"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" - integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== - -d3-shape@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" - integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== - dependencies: - d3-path "^3.1.0" - -"d3-time-format@2 - 4", d3-time-format@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" - integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== - dependencies: - d3-time "1 - 3" - -"d3-time@1 - 3", "d3-time@2.1.1 - 3": - version "3.1.0" - resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" - integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== - dependencies: - d3-array "2 - 3" - -"d3-timer@1 - 3": - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" - integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== - -"d3-transition@2 - 3", d3-transition@3, d3-transition@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" - integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== - dependencies: - d3-color "1 - 3" - d3-dispatch "1 - 3" - d3-ease "1 - 3" - d3-interpolate "1 - 3" - d3-timer "1 - 3" - -d3-zoom@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" - integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== - dependencies: - d3-dispatch "1 - 3" - d3-drag "2 - 3" - d3-interpolate "1 - 3" - d3-selection "2 - 3" - d3-transition "2 - 3" - -d@1, d@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" - integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA== - dependencies: - es5-ext "^0.10.50" - type "^1.0.1" - -date-fns@^2.29.1: - version "2.29.3" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" - integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== - -debug@2.6.9, debug@^2.6.8: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@4, debug@4.3.4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -debug@^3.1.0, debug@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - -debuglog@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" - integrity sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw== - -decamelize-keys@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8" - integrity sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg== - dependencies: - decamelize "^1.1.0" - map-obj "^1.0.0" - -decamelize@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== - -decamelize@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" - integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== - -decamelize@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-5.0.1.tgz#db11a92e58c741ef339fb0a2868d8a06a9a7b1e9" - integrity sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA== - -decompress-response@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" - integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== - dependencies: - mimic-response "^3.1.0" - -decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/decompress-tar/-/decompress-tar-4.1.1.tgz#718cbd3fcb16209716e70a26b84e7ba4592e5af1" - integrity sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ== - dependencies: - file-type "^5.2.0" - is-stream "^1.1.0" - tar-stream "^1.5.2" - -decompress-tarbz2@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz#3082a5b880ea4043816349f378b56c516be1a39b" - integrity sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A== - dependencies: - decompress-tar "^4.1.0" - file-type "^6.1.0" - is-stream "^1.1.0" - seek-bzip "^1.0.5" - unbzip2-stream "^1.0.9" - -decompress-targz@^4.0.0, decompress-targz@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/decompress-targz/-/decompress-targz-4.1.1.tgz#c09bc35c4d11f3de09f2d2da53e9de23e7ce1eee" - integrity sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w== - dependencies: - decompress-tar "^4.1.1" - file-type "^5.2.0" - is-stream "^1.1.0" - -decompress-unzip@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/decompress-unzip/-/decompress-unzip-4.0.1.tgz#deaaccdfd14aeaf85578f733ae8210f9b4848f69" - integrity sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw== - dependencies: - file-type "^3.8.0" - get-stream "^2.2.0" - pify "^2.3.0" - yauzl "^2.4.2" - -decompress@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/decompress/-/decompress-4.2.1.tgz#007f55cc6a62c055afa37c07eb6a4ee1b773f118" - integrity sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ== - dependencies: - decompress-tar "^4.0.0" - decompress-tarbz2 "^4.0.0" - decompress-targz "^4.0.0" - decompress-unzip "^4.0.1" - graceful-fs "^4.1.10" - make-dir "^1.0.0" - pify "^2.3.0" - strip-dirs "^2.0.0" - -deep-equal@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" - integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw== - -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== - -deep-is@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" - integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== - -deepmerge@^4.2.2: - version "4.3.0" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.0.tgz#65491893ec47756d44719ae520e0e2609233b59b" - integrity sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og== - -define-lazy-prop@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" - integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== - -define-properties@^1.1.3, define-properties@^1.1.4: - version "1.2.0" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.0.tgz#52988570670c9eacedd8064f4a990f2405849bd5" - integrity sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA== - dependencies: - has-property-descriptors "^1.0.0" - object-keys "^1.1.1" - -del@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/del/-/del-4.1.1.tgz#9e8f117222ea44a31ff3a156c049b99052a9f0b4" - integrity sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ== - dependencies: - "@types/glob" "^7.1.1" - globby "^6.1.0" - is-path-cwd "^2.0.0" - is-path-in-cwd "^2.0.0" - p-map "^2.0.0" - pify "^4.0.1" - rimraf "^2.6.3" - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== - -depd@2.0.0, depd@^2.0.0, depd@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - -depd@^1.1.2, depd@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" - integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== - -deprecation@^2.0.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" - integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== - -destroy@^1.0.4: - version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== - -detect-libc@^2.0.0, detect-libc@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" - integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w== - -dezalgo@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" - integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== - dependencies: - asap "^2.0.0" - wrappy "1" - -diff@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" - integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== - -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== - dependencies: - path-type "^4.0.0" - -doctrine@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" - integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== - dependencies: - esutils "^2.0.2" - -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - -dom-converter@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" - integrity sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA== - dependencies: - utila "~0.4" - -dom-helpers@^3.2.0, dom-helpers@^3.2.1, dom-helpers@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" - integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== - dependencies: - "@babel/runtime" "^7.1.2" - -dom-helpers@^5.1.3: - version "5.2.1" - resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" - integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== - dependencies: - "@babel/runtime" "^7.8.7" - csstype "^3.0.2" - -dom-serializer@^1.0.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" - integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.0" - entities "^2.0.0" - -dom-serializer@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" - integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== - dependencies: - domelementtype "^2.3.0" - domhandler "^5.0.2" - entities "^4.2.0" - -domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" - integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== - -domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" - integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== - dependencies: - domelementtype "^2.2.0" - -domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" - integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== - dependencies: - domelementtype "^2.3.0" - -domutils@^2.5.2, domutils@^2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" - integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== - dependencies: - dom-serializer "^1.0.1" - domelementtype "^2.2.0" - domhandler "^4.2.0" - -domutils@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.0.1.tgz#696b3875238338cb186b6c0612bd4901c89a4f1c" - integrity sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q== - dependencies: - dom-serializer "^2.0.0" - domelementtype "^2.3.0" - domhandler "^5.0.1" - -dot-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" - integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - -dragula@3.7.2: - version "3.7.2" - resolved "https://registry.yarnpkg.com/dragula/-/dragula-3.7.2.tgz#4a35c9d3981ffac1a949c29ca7285058e87393ce" - integrity sha512-iDPdNTPZY7P/l0CQ800QiX+PNA2XF9iC3ePLWfGxeb/j8iPPedRuQdfSOfZrazgSpmaShYvYQ/jx7keWb4YNzA== - dependencies: - contra "1.9.4" - crossvent "1.5.4" - -duplexer2@~0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" - integrity sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA== - dependencies: - readable-stream "^2.0.2" - -duplexer@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" - integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== - -electron-to-chromium@^1.4.284: - version "1.4.295" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.295.tgz#911d5df67542bf7554336142eb302c5ec90bba66" - integrity sha512-lEO94zqf1bDA3aepxwnWoHUjA8sZ+2owgcSZjYQy0+uOSEclJX0VieZC+r+wLpSxUHRd6gG32znTWmr+5iGzFw== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -emojis-list@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" - integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== - -encodeurl@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== - -encoding@^0.1.13: - version "0.1.13" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" - integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== - dependencies: - iconv-lite "^0.6.2" - -end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== - dependencies: - once "^1.4.0" - -enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0: - version "5.12.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634" - integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ== - dependencies: - graceful-fs "^4.2.4" - tapable "^2.2.0" - -entities@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" - integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== - -entities@^4.2.0, entities@^4.3.0, entities@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174" - integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA== - -entities@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" - integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== - -env-paths@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" - integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== - -envinfo@^7.7.3: - version "7.8.1" - resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.8.1.tgz#06377e3e5f4d379fea7ac592d5ad8927e0c4d475" - integrity sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw== - -err-code@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" - integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== - -error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -es-abstract@^1.19.0, es-abstract@^1.20.4: - version "1.21.1" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.21.1.tgz#e6105a099967c08377830a0c9cb589d570dd86c6" - integrity sha512-QudMsPOz86xYz/1dG1OuGBKOELjCh99IIWHLzy5znUB6j8xG2yMA7bfTV86VSqKF+Y/H08vQPR+9jyXpuC6hfg== - dependencies: - available-typed-arrays "^1.0.5" - call-bind "^1.0.2" - es-set-tostringtag "^2.0.1" - es-to-primitive "^1.2.1" - function-bind "^1.1.1" - function.prototype.name "^1.1.5" - get-intrinsic "^1.1.3" - get-symbol-description "^1.0.0" - globalthis "^1.0.3" - gopd "^1.0.1" - has "^1.0.3" - has-property-descriptors "^1.0.0" - has-proto "^1.0.1" - has-symbols "^1.0.3" - internal-slot "^1.0.4" - is-array-buffer "^3.0.1" - is-callable "^1.2.7" - is-negative-zero "^2.0.2" - is-regex "^1.1.4" - is-shared-array-buffer "^1.0.2" - is-string "^1.0.7" - is-typed-array "^1.1.10" - is-weakref "^1.0.2" - object-inspect "^1.12.2" - object-keys "^1.1.1" - object.assign "^4.1.4" - regexp.prototype.flags "^1.4.3" - safe-regex-test "^1.0.0" - string.prototype.trimend "^1.0.6" - string.prototype.trimstart "^1.0.6" - typed-array-length "^1.0.4" - unbox-primitive "^1.0.2" - which-typed-array "^1.1.9" - -es-module-lexer@^0.9.0: - version "0.9.3" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19" - integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ== - -es-set-tostringtag@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz#338d502f6f674301d710b80c8592de8a15f09cd8" - integrity sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg== - dependencies: - get-intrinsic "^1.1.3" - has "^1.0.3" - has-tostringtag "^1.0.0" - -es-shim-unscopables@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" - integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w== - dependencies: - has "^1.0.3" - -es-to-primitive@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" - integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== - dependencies: - is-callable "^1.1.4" - is-date-object "^1.0.1" - is-symbol "^1.0.2" - -es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@^0.10.61, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46: - version "0.10.62" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.62.tgz#5e6adc19a6da524bf3d1e02bbc8960e5eb49a9a5" - integrity sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA== - dependencies: - es6-iterator "^2.0.3" - es6-symbol "^3.1.3" - next-tick "^1.1.0" - -es6-iterator@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" - integrity sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g== - dependencies: - d "1" - es5-ext "^0.10.35" - es6-symbol "^3.1.1" - -es6-symbol@^3.1.1, es6-symbol@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.3.tgz#bad5d3c1bcdac28269f4cb331e431c78ac705d18" - integrity sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA== - dependencies: - d "^1.0.1" - ext "^1.1.2" - -es6-weak-map@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53" - integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA== - dependencies: - d "1" - es5-ext "^0.10.46" - es6-iterator "^2.0.3" - es6-symbol "^3.1.1" - -esbuild-loader@3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/esbuild-loader/-/esbuild-loader-3.0.1.tgz#9871c0e8817c4c11b6249d1916832e75272e6c7e" - integrity sha512-aZfGybqTeuyCd4AsVvWOOfkhIuN+wfZFjMyh3gyQEU1Uvsl8L6vye9HqP93iRa0iTA+6Jclap514PJIC3cLnMA== - dependencies: - esbuild "^0.17.6" - get-tsconfig "^4.4.0" - loader-utils "^2.0.4" - webpack-sources "^1.4.3" - -esbuild-sass-plugin@2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/esbuild-sass-plugin/-/esbuild-sass-plugin-2.5.0.tgz#56351c3e5a221043cdfcf5552286a01b6e7bec0f" - integrity sha512-SKWcvZwB+3/3eLhSCscJfb9AEOgL3oYlwOaItnXpXNPVj9Hc1Iwf5Cx4muUd+H+6zKyUwviAtVdRwcUsocUYgA== - dependencies: - resolve "^1.22.1" - -esbuild@0.17.8, esbuild@^0.17.6: - version "0.17.8" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.8.tgz#f7f799abc7cdce3f0f2e3e0c01f120d4d55193b4" - integrity sha512-g24ybC3fWhZddZK6R3uD2iF/RIPnRpwJAqLov6ouX3hMbY4+tKolP0VMF3zuIYCaXun+yHwS5IPQ91N2BT191g== - optionalDependencies: - "@esbuild/android-arm" "0.17.8" - "@esbuild/android-arm64" "0.17.8" - "@esbuild/android-x64" "0.17.8" - "@esbuild/darwin-arm64" "0.17.8" - "@esbuild/darwin-x64" "0.17.8" - "@esbuild/freebsd-arm64" "0.17.8" - "@esbuild/freebsd-x64" "0.17.8" - "@esbuild/linux-arm" "0.17.8" - "@esbuild/linux-arm64" "0.17.8" - "@esbuild/linux-ia32" "0.17.8" - "@esbuild/linux-loong64" "0.17.8" - "@esbuild/linux-mips64el" "0.17.8" - "@esbuild/linux-ppc64" "0.17.8" - "@esbuild/linux-riscv64" "0.17.8" - "@esbuild/linux-s390x" "0.17.8" - "@esbuild/linux-x64" "0.17.8" - "@esbuild/netbsd-x64" "0.17.8" - "@esbuild/openbsd-x64" "0.17.8" - "@esbuild/sunos-x64" "0.17.8" - "@esbuild/win32-arm64" "0.17.8" - "@esbuild/win32-ia32" "0.17.8" - "@esbuild/win32-x64" "0.17.8" - -escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== - -escape-html@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== - -escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== - -eslint-cli@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/eslint-cli/-/eslint-cli-1.1.1.tgz#ae6979edd8ee6e78c6d413b525f4052cb2a94cfd" - integrity sha512-Gu+fYzt7M+jIb5szUHLl5Ex0vFY7zErbi78D7ZaaLunvVTxHRvbOlfzmJlIUWsV5WDM4qyu9TD7WnGgDaDgaMA== - dependencies: - chalk "^2.0.1" - debug "^2.6.8" - resolve "^1.3.3" - -eslint-config-prettier@8.6.0: - version "8.6.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.6.0.tgz#dec1d29ab728f4fa63061774e1672ac4e363d207" - integrity sha512-bAF0eLpLVqP5oEVUFKpMA+NnRFICwn9X8B5jrR9FcqnYBuPbqWEjTEspPWMj5ye6czoSLDweCzSo3Ko7gGrZaA== - -eslint-import-resolver-node@^0.3.7: - version "0.3.7" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz#83b375187d412324a1963d84fa664377a23eb4d7" - integrity sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA== - dependencies: - debug "^3.2.7" - is-core-module "^2.11.0" - resolve "^1.22.1" - -eslint-import-resolver-typescript@3.5.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.5.3.tgz#db5ed9e906651b7a59dd84870aaef0e78c663a05" - integrity sha512-njRcKYBc3isE42LaTcJNVANR3R99H9bAxBDMNDr2W7yq5gYPxbU3MkdhsQukxZ/Xg9C2vcyLlDsbKfRDg0QvCQ== - dependencies: - debug "^4.3.4" - enhanced-resolve "^5.10.0" - get-tsconfig "^4.2.0" - globby "^13.1.2" - is-core-module "^2.10.0" - is-glob "^4.0.3" - synckit "^0.8.4" - -eslint-module-utils@^2.7.4: - version "2.7.4" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz#4f3e41116aaf13a20792261e61d3a2e7e0583974" - integrity sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA== - dependencies: - debug "^3.2.7" - -eslint-plugin-anti-trojan-source@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-anti-trojan-source/-/eslint-plugin-anti-trojan-source-1.1.1.tgz#e83d064a990d26731661b1b6f90613cf5a58fd5d" - integrity sha512-gWDuG2adNNccwRM+2/Q3UHqV1DgrAUSpSi/Tdnx2Ybr0ndWMSBn7lt4AbxdPuFSEs2OAokX/vdIHbBbTLzWspw== - dependencies: - anti-trojan-source "^1.3.1" - -eslint-plugin-import@2.27.5: - version "2.27.5" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz#876a6d03f52608a3e5bb439c2550588e51dd6c65" - integrity sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow== - dependencies: - array-includes "^3.1.6" - array.prototype.flat "^1.3.1" - array.prototype.flatmap "^1.3.1" - debug "^3.2.7" - doctrine "^2.1.0" - eslint-import-resolver-node "^0.3.7" - eslint-module-utils "^2.7.4" - has "^1.0.3" - is-core-module "^2.11.0" - is-glob "^4.0.3" - minimatch "^3.1.2" - object.values "^1.1.6" - resolve "^1.22.1" - semver "^6.3.0" - tsconfig-paths "^3.14.1" - -eslint-plugin-lit@1.8.2: - version "1.8.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.8.2.tgz#e4ba34f641e7ffdde3c6004313a99dafd1945a43" - integrity sha512-4mOGcSRNEPMh7AN2F7Iy6no36nuFgyYOsnTRhFw1k8xyy1Zm6QOp788ywDvJqy+eelFbLPBhq20Qr55a887Dmw== - dependencies: - parse5 "^6.0.1" - parse5-htmlparser2-tree-adapter "^6.0.1" - requireindex "^1.2.0" - -eslint-scope@5.1.1, eslint-scope@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" - integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== - dependencies: - esrecurse "^4.3.0" - estraverse "^4.1.1" - -eslint-scope@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642" - integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw== - dependencies: - esrecurse "^4.3.0" - estraverse "^5.2.0" - -eslint-utils@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672" - integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA== - dependencies: - eslint-visitor-keys "^2.0.0" - -eslint-visitor-keys@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" - integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== - -eslint-visitor-keys@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" - integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== - -eslint@8.34.0: - version "8.34.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.34.0.tgz#fe0ab0ef478104c1f9ebc5537e303d25a8fb22d6" - integrity sha512-1Z8iFsucw+7kSqXNZVslXS8Ioa4u2KM7GPwuKtkTFAqZ/cHMcEaR+1+Br0wLlot49cNxIiZk5wp8EAbPcYZxTg== - dependencies: - "@eslint/eslintrc" "^1.4.1" - "@humanwhocodes/config-array" "^0.11.8" - "@humanwhocodes/module-importer" "^1.0.1" - "@nodelib/fs.walk" "^1.2.8" - ajv "^6.10.0" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.3.2" - doctrine "^3.0.0" - escape-string-regexp "^4.0.0" - eslint-scope "^7.1.1" - eslint-utils "^3.0.0" - eslint-visitor-keys "^3.3.0" - espree "^9.4.0" - esquery "^1.4.0" - esutils "^2.0.2" - fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" - find-up "^5.0.0" - glob-parent "^6.0.2" - globals "^13.19.0" - grapheme-splitter "^1.0.4" - ignore "^5.2.0" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - is-path-inside "^3.0.3" - js-sdsl "^4.1.4" - js-yaml "^4.1.0" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash.merge "^4.6.2" - minimatch "^3.1.2" - natural-compare "^1.4.0" - optionator "^0.9.1" - regexpp "^3.2.0" - strip-ansi "^6.0.1" - strip-json-comments "^3.1.0" - text-table "^0.2.0" - -espree@^9.4.0: - version "9.4.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.1.tgz#51d6092615567a2c2cff7833445e37c28c0065bd" - integrity sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg== - dependencies: - acorn "^8.8.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.3.0" - -esquery@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5" - integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w== - dependencies: - estraverse "^5.1.0" - -esrecurse@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" - integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== - dependencies: - estraverse "^5.2.0" - -estraverse@^4.1.1: - version "4.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" - integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== - -estraverse@^5.1.0, estraverse@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" - integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== - -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - -event-emitter@^0.3.5: - version "0.3.5" - resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" - integrity sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA== - dependencies: - d "1" - es5-ext "~0.10.14" - -events@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" - integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== - -exenv-es6@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/exenv-es6/-/exenv-es6-1.1.1.tgz#80b7a8c5af24d53331f755bac07e84abb1f6de67" - integrity sha512-vlVu3N8d6yEMpMsEm+7sUBAI81aqYYuEvfK0jNqmdb/OPXzzH7QWDDnVjMvDSY47JdHEqx/dfC/q8WkfoTmpGQ== - -expand-template@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" - integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== - -ext@^1.1.2: - version "1.7.0" - resolved "https://registry.yarnpkg.com/ext/-/ext-1.7.0.tgz#0ea4383c0103d60e70be99e9a7f11027a33c4f5f" - integrity sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw== - dependencies: - type "^2.7.2" - -fantasticon@1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/fantasticon/-/fantasticon-1.2.3.tgz#9082b8bcb38ef6fd63e4f392643b12675c2fb310" - integrity sha512-VoPXI8+wbLq4qooK2LAFRcqKtOCR20+InF/Io/9I1kLp3IT+LwqJgeFijFvp9a3HRZptfCAxNvazoVHn4kihzQ== - dependencies: - change-case "^4.1.2" - cli-color "^2.0.0" - commander "^7.2.0" - glob "^7.2.0" - handlebars "^4.7.7" - slugify "^1.6.0" - svg2ttf "^6.0.3" - svgicons2svgfont "^10.0.3" - ttf2eot "^2.0.0" - ttf2woff "^3.0.0" - ttf2woff2 "^4.0.4" - -fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - -fast-glob@^3.2.11, fast-glob@^3.2.7, fast-glob@^3.2.9: - version "3.2.12" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" - integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.4" - -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - -fast-levenshtein@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== - -fast-memoize@^2.5.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.2.tgz#79e3bb6a4ec867ea40ba0e7146816f6cdce9b57e" - integrity sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw== - -fastest-levenshtein@^1.0.12: - version "1.0.16" - resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" - integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== - -fastq@^1.6.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a" - integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw== - dependencies: - reusify "^1.0.4" - -fd-slicer@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" - integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g== - dependencies: - pend "~1.2.0" - -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== - dependencies: - flat-cache "^3.0.4" - -file-type@^3.8.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" - integrity sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA== - -file-type@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-5.2.0.tgz#2ddbea7c73ffe36368dfae49dc338c058c2b8ad6" - integrity sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ== - -file-type@^6.1.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/file-type/-/file-type-6.2.0.tgz#e50cd75d356ffed4e306dc4f5bcf52a79903a919" - integrity sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg== - -file-uri-to-path@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" - integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== - -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== - dependencies: - to-regex-range "^5.0.1" - -find-up@5.0.0, find-up@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - -find-up@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - -flat-cache@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" - integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== - dependencies: - flatted "^3.1.0" - rimraf "^3.0.2" - -flat@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" - integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== - -flatted@^3.1.0: - version "3.2.7" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" - integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== - -for-each@^0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" - integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== - dependencies: - is-callable "^1.1.3" - -fork-ts-checker-webpack-plugin@6.5.2: - version "6.5.2" - resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz#4f67183f2f9eb8ba7df7177ce3cf3e75cdafb340" - integrity sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA== - dependencies: - "@babel/code-frame" "^7.8.3" - "@types/json-schema" "^7.0.5" - chalk "^4.1.0" - chokidar "^3.4.2" - cosmiconfig "^6.0.0" - deepmerge "^4.2.2" - fs-extra "^9.0.0" - glob "^7.1.6" - memfs "^3.1.2" - minimatch "^3.0.4" - schema-utils "2.7.0" - semver "^7.3.2" - tapable "^1.0.0" - -fraction.js@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" - integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA== - -fresh@~0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - -fs-constants@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" - integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== - -fs-extra@^9.0.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" - integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== - dependencies: - at-least-node "^1.0.0" - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - -fs-minipass@^2.0.0, fs-minipass@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" - integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== - dependencies: - minipass "^3.0.0" - -fs-monkey@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.3.tgz#ae3ac92d53bb328efe0e9a1d9541f6ad8d48e2d3" - integrity sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q== - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - -fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - -fstream@^1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" - integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== - dependencies: - graceful-fs "^4.1.2" - inherits "~2.0.0" - mkdirp ">=0.5 0" - rimraf "2" - -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== - -function.prototype.name@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621" - integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - es-abstract "^1.19.0" - functions-have-names "^1.2.2" - -functions-have-names@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" - integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== - -gauge@^4.0.3: - version "4.0.4" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-4.0.4.tgz#52ff0652f2bbf607a989793d53b751bef2328dce" - integrity sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg== - dependencies: - aproba "^1.0.3 || ^2.0.0" - color-support "^1.1.3" - console-control-strings "^1.1.0" - has-unicode "^2.0.1" - signal-exit "^3.0.7" - string-width "^4.2.3" - strip-ansi "^6.0.1" - wide-align "^1.1.5" - -geometry-interfaces@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/geometry-interfaces/-/geometry-interfaces-1.1.4.tgz#9e82af6700ca639a675299f08e1f5fbc4a79d48d" - integrity sha512-qD6OdkT6NcES9l4Xx3auTpwraQruU7dARbQPVO71MKvkGYw5/z/oIiGymuFXrRaEQa5Y67EIojUpaLeGEa5hGA== - -get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f" - integrity sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q== - dependencies: - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.3" - -get-stream@6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - -get-stream@^2.2.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-2.3.1.tgz#5f38f93f346009666ee0150a054167f91bdd95de" - integrity sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA== - dependencies: - object-assign "^4.0.1" - pinkie-promise "^2.0.0" - -get-symbol-description@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" - integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.1.1" - -get-tsconfig@^4.2.0, get-tsconfig@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.4.0.tgz#64eee64596668a81b8fce18403f94f245ee0d4e5" - integrity sha512-0Gdjo/9+FzsYhXCEFueo2aY1z1tpXrxWZzP7k8ul9qt1U5o8rYJwTJYmaeHdrVosYIVYkOy2iwCJ9FdpocJhPQ== - -github-from-package@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" - integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== - -glob-parent@^5.1.2, glob-parent@~5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob-parent@^6.0.1, glob-parent@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - -glob-to-regexp@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" - integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== - -glob@7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" - integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -glob@8.1.0, glob@^8.0.1: - version "8.1.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" - integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^5.0.1" - once "^1.3.0" - -glob@^7.0.3, glob@^7.0.6, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - -globals@^13.19.0: - version "13.20.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.20.0.tgz#ea276a1e508ffd4f1612888f9d1bad1e2717bf82" - integrity sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ== - dependencies: - type-fest "^0.20.2" - -globalthis@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.3.tgz#5852882a52b80dc301b0660273e1ed082f0b6ccf" - integrity sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA== - dependencies: - define-properties "^1.1.3" - -globalyzer@0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/globalyzer/-/globalyzer-0.1.0.tgz#cb76da79555669a1519d5a8edf093afaa0bf1465" - integrity sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q== - -globby@^11.0.4, globby@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" - integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.2.9" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^3.0.0" - -globby@^12.0.2: - version "12.2.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-12.2.0.tgz#2ab8046b4fba4ff6eede835b29f678f90e3d3c22" - integrity sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA== - dependencies: - array-union "^3.0.1" - dir-glob "^3.0.1" - fast-glob "^3.2.7" - ignore "^5.1.9" - merge2 "^1.4.1" - slash "^4.0.0" - -globby@^13.1.1, globby@^13.1.2: - version "13.1.3" - resolved "https://registry.yarnpkg.com/globby/-/globby-13.1.3.tgz#f62baf5720bcb2c1330c8d4ef222ee12318563ff" - integrity sha512-8krCNHXvlCgHDpegPzleMq07yMYTO2sXKASmZmquEYWEmCx6J5UTRbp5RwMJkTJGtcQ44YpiUYUiN0b9mzy8Bw== - dependencies: - dir-glob "^3.0.1" - fast-glob "^3.2.11" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^4.0.0" - -globby@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" - integrity sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw== - dependencies: - array-union "^1.0.1" - glob "^7.0.3" - object-assign "^4.0.1" - pify "^2.0.0" - pinkie-promise "^2.0.0" - -globrex@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098" - integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== - -gopd@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" - integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== - dependencies: - get-intrinsic "^1.1.3" - -graceful-fs@^4.1.10, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: - version "4.2.10" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" - integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== - -grapheme-splitter@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e" - integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ== - -gzip-size@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" - integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q== - dependencies: - duplexer "^0.1.2" - -handlebars@^4.7.7: - version "4.7.7" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" - integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== - dependencies: - minimist "^1.2.5" - neo-async "^2.6.0" - source-map "^0.6.1" - wordwrap "^1.0.0" - optionalDependencies: - uglify-js "^3.1.4" - -hard-rejection@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883" - integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA== - -has-bigints@^1.0.1, has-bigints@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" - integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -has-property-descriptors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" - integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== - dependencies: - get-intrinsic "^1.1.1" - -has-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" - integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg== - -has-symbols@^1.0.2, has-symbols@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== - -has-tostringtag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" - integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== - dependencies: - has-symbols "^1.0.2" - -has-unicode@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== - -has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -he@1.2.0, he@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" - integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== - -header-case@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/header-case/-/header-case-2.0.4.tgz#5a42e63b55177349cf405beb8d775acabb92c063" - integrity sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q== - dependencies: - capital-case "^1.0.4" - tslib "^2.0.3" - -hosted-git-info@^4.0.1, hosted-git-info@^4.0.2: - version "4.1.0" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224" - integrity sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA== - dependencies: - lru-cache "^6.0.0" - -html-loader@4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/html-loader/-/html-loader-4.2.0.tgz#20f69f9ec69244860c250ae6ee0046c8c5c4d348" - integrity sha512-OxCHD3yt+qwqng2vvcaPApCEvbx+nXWu+v69TYHx1FO8bffHn/JjHtE3TTQZmHjwvnJe4xxzuecetDVBrQR1Zg== - dependencies: - html-minifier-terser "^7.0.0" - parse5 "^7.0.0" - -html-minifier-terser@^6.0.2: - version "6.1.0" - resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#bfc818934cc07918f6b3669f5774ecdfd48f32ab" - integrity sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw== - dependencies: - camel-case "^4.1.2" - clean-css "^5.2.2" - commander "^8.3.0" - he "^1.2.0" - param-case "^3.0.4" - relateurl "^0.2.7" - terser "^5.10.0" - -html-minifier-terser@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-7.1.0.tgz#cd62d42158be9a6bef0fcd40f94127345743d9b5" - integrity sha512-BvPO2S7Ip0Q5qt+Y8j/27Vclj6uHC6av0TMoDn7/bJPhMWHI2UtR2e/zEgJn3/qYAmxumrGp9q4UHurL6mtW9Q== - dependencies: - camel-case "^4.1.2" - clean-css "5.2.0" - commander "^9.4.1" - entities "^4.4.0" - param-case "^3.0.4" - relateurl "^0.2.7" - terser "^5.15.1" - -html-webpack-plugin@5.5.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz#c3911936f57681c1f9f4d8b68c158cd9dfe52f50" - integrity sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw== - dependencies: - "@types/html-minifier-terser" "^6.0.0" - html-minifier-terser "^6.0.2" - lodash "^4.17.21" - pretty-error "^4.0.0" - tapable "^2.0.0" - -htmlparser2@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" - integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.0.0" - domutils "^2.5.2" - entities "^2.0.0" - -htmlparser2@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.1.tgz#abaa985474fcefe269bc761a779b544d7196d010" - integrity sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA== - dependencies: - domelementtype "^2.3.0" - domhandler "^5.0.2" - domutils "^3.0.1" - entities "^4.3.0" - -http-assert@^1.3.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f" - integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w== - dependencies: - deep-equal "~1.0.1" - http-errors "~1.8.0" - -http-cache-semantics@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" - integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== - -http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0: - version "1.8.1" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" - integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== - dependencies: - depd "~1.1.2" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses ">= 1.5.0 < 2" - toidentifier "1.0.1" - -http-errors@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -http-errors@~1.6.2: - version "1.6.3" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" - integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A== - dependencies: - depd "~1.1.2" - inherits "2.0.3" - setprototypeof "1.1.0" - statuses ">= 1.4.0 < 2" - -http-proxy-agent@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" - integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== - dependencies: - "@tootallnate/once" "1" - agent-base "6" - debug "4" - -http-proxy-agent@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" - integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== - dependencies: - "@tootallnate/once" "2" - agent-base "6" - debug "4" - -https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== - dependencies: - agent-base "6" - debug "4" - -humanize-ms@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" - integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== - dependencies: - ms "^2.0.0" - -iconv-lite@0.6, iconv-lite@0.6.3, iconv-lite@^0.6.2: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - -icss-utils@^5.0.0, icss-utils@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" - integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== - -ieee754@^1.1.13: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - -ignore@^5.1.9, ignore@^5.2.0: - version "5.2.4" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" - integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== - -image-minimizer-webpack-plugin@3.8.1: - version "3.8.1" - resolved "https://registry.yarnpkg.com/image-minimizer-webpack-plugin/-/image-minimizer-webpack-plugin-3.8.1.tgz#b1bcc3c261d5bde26cbaf41c673d70beb5a41794" - integrity sha512-fxXbAYgP1chTrcsg16rO5qzy6qMnvoaS+fhfxNOuM1EV8S427WdLl2xNXom+9qPbgvaj2r6pPHspTqJHWwfVgg== - dependencies: - schema-utils "^4.0.0" - serialize-javascript "^6.0.0" - -immutable@^4.0.0: - version "4.2.4" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.2.4.tgz#83260d50889526b4b531a5e293709a77f7c55a2a" - integrity sha512-WDxL3Hheb1JkRN3sQkyujNlL/xRjAo3rJtaU5xeufUauG66JdMr32bLj4gF+vWl84DIA3Zxw7tiAjneYzRRw+w== - -import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: - version "3.3.0" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" - integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - -import-local@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" - integrity sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg== - dependencies: - pkg-dir "^4.2.0" - resolve-cwd "^3.0.0" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== - -indent-string@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" - integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== - -indent-string@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-5.0.0.tgz#4fd2980fccaf8622d14c64d694f4cf33c81951a5" - integrity sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg== - -infer-owner@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" - integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -inherits@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== - -ini@~1.3.0: - version "1.3.8" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== - -internal-slot@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.5.tgz#f2a2ee21f668f8627a4667f309dc0f4fb6674986" - integrity sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ== - dependencies: - get-intrinsic "^1.2.0" - has "^1.0.3" - side-channel "^1.0.4" - -"internmap@1 - 2": - version "2.0.3" - resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" - integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== - -interpret@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4" - integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ== - -invariant@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - -ip@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.0.tgz#4cf4ab182fee2314c75ede1276f8c80b479936da" - integrity sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ== - -is-array-buffer@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.1.tgz#deb1db4fcae48308d54ef2442706c0393997052a" - integrity sha512-ASfLknmY8Xa2XtB4wmbz13Wu202baeA18cJBCeCy0wXUHZF0IPyVEXqKEcd+t2fNSLLL1vC6k7lxZEojNbISXQ== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.1.3" - is-typed-array "^1.1.10" - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== - -is-arrayish@^0.3.1: - version "0.3.2" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" - integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== - -is-bigint@^1.0.1: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" - integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== - dependencies: - has-bigints "^1.0.1" - -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - -is-boolean-object@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719" - integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - -is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" - integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== - -is-core-module@^2.10.0, is-core-module@^2.11.0, is-core-module@^2.5.0, is-core-module@^2.9.0: - version "2.11.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144" - integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw== - dependencies: - has "^1.0.3" - -is-date-object@^1.0.1: - version "1.0.5" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" - integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== - dependencies: - has-tostringtag "^1.0.0" - -is-docker@^2.0.0, is-docker@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" - integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-generator-function@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" - integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== - dependencies: - has-tostringtag "^1.0.0" - -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -is-lambda@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" - integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ== - -is-natural-number@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/is-natural-number/-/is-natural-number-4.0.1.tgz#ab9d76e1db4ced51e35de0c72ebecf09f734cde8" - integrity sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ== - -is-negative-zero@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" - integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== - -is-number-object@^1.0.4: - version "1.0.7" - resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" - integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== - dependencies: - has-tostringtag "^1.0.0" - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-path-cwd@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb" - integrity sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ== - -is-path-in-cwd@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz#bfe2dca26c69f397265a4009963602935a053acb" - integrity sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ== - dependencies: - is-path-inside "^2.1.0" - -is-path-inside@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-2.1.0.tgz#7c9810587d659a40d27bcdb4d5616eab059494b2" - integrity sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg== - dependencies: - path-is-inside "^1.0.2" - -is-path-inside@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - -is-plain-obj@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" - integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg== - -is-plain-obj@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" - integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== - -is-plain-object@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" - integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== - dependencies: - isobject "^3.0.1" - -is-plain-object@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" - integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== - -is-promise@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" - integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== - -is-regex@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" - integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - -is-shared-array-buffer@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" - integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA== - dependencies: - call-bind "^1.0.2" - -is-stream@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== - -is-string@^1.0.5, is-string@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" - integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== - dependencies: - has-tostringtag "^1.0.0" - -is-symbol@^1.0.2, is-symbol@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" - integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== - dependencies: - has-symbols "^1.0.2" - -is-typed-array@^1.1.10, is-typed-array@^1.1.9: - version "1.1.10" - resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.10.tgz#36a5b5cb4189b575d1a3e4b08536bfb485801e3f" - integrity sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A== - dependencies: - available-typed-arrays "^1.0.5" - call-bind "^1.0.2" - for-each "^0.3.3" - gopd "^1.0.1" - has-tostringtag "^1.0.0" - -is-unicode-supported@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" - integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== - -is-weakref@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" - integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== - dependencies: - call-bind "^1.0.2" - -is-wsl@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" - integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== - dependencies: - is-docker "^2.0.0" - -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== - -isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -isobject@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== - -jest-util@^29.4.2: - version "29.4.2" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.4.2.tgz#3db8580b295df453a97de4a1b42dd2578dabd2c2" - integrity sha512-wKnm6XpJgzMUSRFB7YF48CuwdzuDIHenVuoIb1PLuJ6F+uErZsuDkU+EiExkChf6473XcawBrSfDSnXl+/YG4g== - dependencies: - "@jest/types" "^29.4.2" - "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" - -jest-worker@^27.4.5: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" - integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== - dependencies: - "@types/node" "*" - merge-stream "^2.0.0" - supports-color "^8.0.0" - -jest-worker@^29.1.2: - version "29.4.2" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.4.2.tgz#d9b2c3bafc69311d84d94e7fb45677fc8976296f" - integrity sha512-VIuZA2hZmFyRbchsUCHEehoSf2HEl0YVF8SDJqtPnKorAaBuh42V8QsLnde0XP5F6TyCynGPEGgBOn3Fc+wZGw== - dependencies: - "@types/node" "*" - jest-util "^29.4.2" - merge-stream "^2.0.0" - supports-color "^8.0.0" - -js-sdsl@^4.1.4: - version "4.3.0" - resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.3.0.tgz#aeefe32a451f7af88425b11fdb5f58c90ae1d711" - integrity sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ== - -"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@4.1.0, js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - -json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" - integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-schema-traverse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" - integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== - -json-stable-stringify-without-jsonify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" - integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== - -json5@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" - integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== - dependencies: - minimist "^1.2.0" - -json5@^2.1.2: - version "2.2.3" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" - integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== - -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== - dependencies: - universalify "^2.0.0" - optionalDependencies: - graceful-fs "^4.1.6" - -keycode@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.2.1.tgz#09c23b2be0611d26117ea2501c2c391a01f39eff" - integrity sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg== - -keygrip@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" - integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ== - dependencies: - tsscmp "1.0.6" - -keytar@^7.7.0: - version "7.9.0" - resolved "https://registry.yarnpkg.com/keytar/-/keytar-7.9.0.tgz#4c6225708f51b50cbf77c5aae81721964c2918cb" - integrity sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ== - dependencies: - node-addon-api "^4.3.0" - prebuild-install "^7.0.1" - -kind-of@^6.0.2, kind-of@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" - integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== - -klona@^2.0.4: - version "2.0.6" - resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22" - integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA== - -koa-compose@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877" - integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw== - -koa-convert@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5" - integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA== - dependencies: - co "^4.6.0" - koa-compose "^4.1.0" - -koa-morgan@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/koa-morgan/-/koa-morgan-1.0.1.tgz#08052e0ce0d839d3c43178b90a5bb3424bef1f99" - integrity sha512-JOUdCNlc21G50afBXfErUrr1RKymbgzlrO5KURY+wmDG1Uvd2jmxUJcHgylb/mYXy2SjiNZyYim/ptUBGsIi3A== - dependencies: - morgan "^1.6.1" - -koa-mount@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/koa-mount/-/koa-mount-4.0.0.tgz#e0265e58198e1a14ef889514c607254ff386329c" - integrity sha512-rm71jaA/P+6HeCpoRhmCv8KVBIi0tfGuO/dMKicbQnQW/YJntJ6MnnspkodoA4QstMVEZArsCphmd0bJEtoMjQ== - dependencies: - debug "^4.0.1" - koa-compose "^4.1.0" - -koa-send@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/koa-send/-/koa-send-5.0.1.tgz#39dceebfafb395d0d60beaffba3a70b4f543fe79" - integrity sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ== - dependencies: - debug "^4.1.1" - http-errors "^1.7.3" - resolve-path "^1.4.0" - -koa-static@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/koa-static/-/koa-static-5.0.0.tgz#5e92fc96b537ad5219f425319c95b64772776943" - integrity sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ== - dependencies: - debug "^3.1.0" - koa-send "^5.0.0" - -koa@^2.14.1: - version "2.14.1" - resolved "https://registry.yarnpkg.com/koa/-/koa-2.14.1.tgz#defb9589297d8eb1859936e777f3feecfc26925c" - integrity sha512-USJFyZgi2l0wDgqkfD27gL4YGno7TfUkcmOe6UOLFOVuN+J7FwnNu4Dydl4CUQzraM1lBAiGed0M9OVJoT0Kqw== - dependencies: - accepts "^1.3.5" - cache-content-type "^1.0.0" - content-disposition "~0.5.2" - content-type "^1.0.4" - cookies "~0.8.0" - debug "^4.3.2" - delegates "^1.0.0" - depd "^2.0.0" - destroy "^1.0.4" - encodeurl "^1.0.2" - escape-html "^1.0.3" - fresh "~0.5.2" - http-assert "^1.3.0" - http-errors "^1.6.3" - is-generator-function "^1.0.7" - koa-compose "^4.1.0" - koa-convert "^2.0.0" - on-finished "^2.3.0" - only "~0.0.2" - parseurl "^1.3.2" - statuses "^1.5.0" - type-is "^1.6.16" - vary "^1.1.2" - -leven@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" - integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== - -levn@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" - integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== - dependencies: - prelude-ls "^1.2.1" - type-check "~0.4.0" - -license-checker-rseidelsohn@4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/license-checker-rseidelsohn/-/license-checker-rseidelsohn-4.1.1.tgz#87643eca5f1715e3637242791cc4b28588bd91bd" - integrity sha512-yCksBCx6+Lh2kUVg2eyiki3a4PeO1SV7VcuvFgEUX43MPsDCo6363ePcFYhM4GV2KlSD/XGz74X+uPu2HPbGxA== - dependencies: - chalk "4.1.2" - debug "^4.3.4" - lodash.clonedeep "^4.5.0" - mkdirp "^1.0.4" - nopt "^5.0.0" - read-installed-packages "^1.0.0" - semver "^7.3.5" - spdx-correct "^3.1.1" - spdx-expression-parse "^3.0.1" - spdx-satisfies "^5.0.1" - treeify "^1.1.0" - -lilconfig@^2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" - integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg== - -lines-and-columns@^1.1.6: - version "1.2.4" - resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" - integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== - -linkify-it@^3.0.1: - version "3.0.3" - resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.3.tgz#a98baf44ce45a550efb4d49c769d07524cc2fa2e" - integrity sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ== - dependencies: - uc.micro "^1.0.1" - -listenercount@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937" - integrity sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ== - -lit-element@^3.2.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.2.tgz#d148ab6bf4c53a33f707a5168e087725499e5f2b" - integrity sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ== - dependencies: - "@lit/reactive-element" "^1.3.0" - lit-html "^2.2.0" - -lit-html@^2.2.0, lit-html@^2.6.0: - version "2.6.1" - resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.6.1.tgz#eb29f0b0c2ab54ea77379db11fc011b0c71f1cda" - integrity sha512-Z3iw+E+3KKFn9t2YKNjsXNEu/LRLI98mtH/C6lnFg7kvaqPIzPn124Yd4eT/43lyqrejpc5Wb6BHq3fdv4S8Rw== - dependencies: - "@types/trusted-types" "^2.0.2" - -lit@2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/lit/-/lit-2.6.1.tgz#5951a2098b9bde5b328c73b55c15fdc0eefd96d7" - integrity sha512-DT87LD64f8acR7uVp7kZfhLRrHkfC/N4BVzAtnw9Yg8087mbBJ//qedwdwX0kzDbxgPccWRW6mFwGbRQIxy0pw== - dependencies: - "@lit/reactive-element" "^1.6.0" - lit-element "^3.2.0" - lit-html "^2.6.0" - -loader-runner@^4.2.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" - integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== - -loader-utils@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" - integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== - dependencies: - big.js "^5.2.2" - emojis-list "^3.0.0" - json5 "^2.1.2" - -locate-path@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== - dependencies: - p-locate "^4.1.0" - -locate-path@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" - integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== - dependencies: - p-locate "^5.0.0" - -lodash-es@4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== - -lodash.clonedeep@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" - integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== - -lodash.memoize@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" - integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== - -lodash.merge@4.6.2, lodash.merge@^4.6.2: - version "4.6.2" - resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" - integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== - -lodash.uniq@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" - integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== - -lodash@^4.17.10, lodash@^4.17.20, lodash@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -log-symbols@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" - integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== - dependencies: - chalk "^4.1.0" - is-unicode-supported "^0.1.0" - -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - -lower-case@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" - integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== - dependencies: - tslib "^2.0.3" - -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -lru-cache@^7.7.1: - version "7.14.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.1.tgz#8da8d2f5f59827edb388e63e459ac23d6d408fea" - integrity sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA== - -lru-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" - integrity sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ== - dependencies: - es5-ext "~0.10.2" - -make-dir@^1.0.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" - integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== - dependencies: - pify "^3.0.0" - -make-fetch-happen@^10.0.3: - version "10.2.1" - resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz#f5e3835c5e9817b617f2770870d9492d28678164" - integrity sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w== - dependencies: - agentkeepalive "^4.2.1" - cacache "^16.1.0" - http-cache-semantics "^4.1.0" - http-proxy-agent "^5.0.0" - https-proxy-agent "^5.0.0" - is-lambda "^1.0.1" - lru-cache "^7.7.1" - minipass "^3.1.6" - minipass-collect "^1.0.2" - minipass-fetch "^2.0.3" - minipass-flush "^1.0.5" - minipass-pipeline "^1.2.4" - negotiator "^0.6.3" - promise-retry "^2.0.1" - socks-proxy-agent "^7.0.0" - ssri "^9.0.0" - -map-obj@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" - integrity sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg== - -map-obj@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a" - integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ== - -markdown-it@^12.3.2: - version "12.3.2" - resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-12.3.2.tgz#bf92ac92283fe983fe4de8ff8abfb5ad72cd0c90" - integrity sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg== - dependencies: - argparse "^2.0.1" - entities "~2.1.0" - linkify-it "^3.0.1" - mdurl "^1.0.1" - uc.micro "^1.0.5" - -mdn-data@2.0.14: - version "2.0.14" - resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" - integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== - -mdn-data@2.0.28: - version "2.0.28" - resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba" - integrity sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g== - -mdn-data@2.0.30: - version "2.0.30" - resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc" - integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== - -mdurl@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" - integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== - -memfs@^3.1.2: - version "3.4.13" - resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.4.13.tgz#248a8bd239b3c240175cd5ec548de5227fc4f345" - integrity sha512-omTM41g3Skpvx5dSYeZIbXKcXoAVc/AoMNwn9TKx++L/gaen/+4TTttmu8ZSch5vfVJ8uJvGbroTsIlslRg6lg== - dependencies: - fs-monkey "^1.0.3" - -memoizee@^0.4.15: - version "0.4.15" - resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.15.tgz#e6f3d2da863f318d02225391829a6c5956555b72" - integrity sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ== - dependencies: - d "^1.0.1" - es5-ext "^0.10.53" - es6-weak-map "^2.0.3" - event-emitter "^0.3.5" - is-promise "^2.2.2" - lru-queue "^0.1.0" - next-tick "^1.1.0" - timers-ext "^0.1.7" - -meow@^10.1.1: - version "10.1.5" - resolved "https://registry.yarnpkg.com/meow/-/meow-10.1.5.tgz#be52a1d87b5f5698602b0f32875ee5940904aa7f" - integrity sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw== - dependencies: - "@types/minimist" "^1.2.2" - camelcase-keys "^7.0.0" - decamelize "^5.0.0" - decamelize-keys "^1.1.0" - hard-rejection "^2.1.0" - minimist-options "4.1.0" - normalize-package-data "^3.0.2" - read-pkg-up "^8.0.0" - redent "^4.0.0" - trim-newlines "^4.0.2" - type-fest "^1.2.2" - yargs-parser "^20.2.9" - -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - -merge2@^1.3.0, merge2@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" - integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== - -methods@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== - -microbuffer@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/microbuffer/-/microbuffer-1.0.0.tgz#8b3832ed40c87d51f47bb234913a698a756d19d2" - integrity sha512-O/SUXauVN4x6RaEJFqSPcXNtLFL+QzJHKZlyDVYFwcDDRVca3Fa/37QXXC+4zAGGa4YhHrHxKXuuHvLDIQECtA== - -micromatch@^4.0.0, micromatch@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" - integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== - dependencies: - braces "^3.0.2" - picomatch "^2.3.1" - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime@^1.3.4: - version "1.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" - integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - -mimic-response@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" - integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== - -min-indent@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" - integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== - -mini-css-extract-plugin@2.7.2: - version "2.7.2" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz#e049d3ea7d3e4e773aad585c6cb329ce0c7b72d7" - integrity sha512-EdlUizq13o0Pd+uCp+WO/JpkLvHRVGt97RqfeGhXqAcorYo1ypJSpkV+WDT0vY/kmh/p7wRdJNJtuyK540PXDw== - dependencies: - schema-utils "^4.0.0" - -minimatch@5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" - integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== - dependencies: - brace-expansion "^2.0.1" - -minimatch@^3.0.3, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimatch@^5.0.1: - version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== - dependencies: - brace-expansion "^2.0.1" - -minimist-options@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619" - integrity sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A== - dependencies: - arrify "^1.0.1" - is-plain-obj "^1.1.0" - kind-of "^6.0.3" - -minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.7: - version "1.2.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" - integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== - -minipass-collect@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" - integrity sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA== - dependencies: - minipass "^3.0.0" - -minipass-fetch@^2.0.3: - version "2.1.2" - resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-2.1.2.tgz#95560b50c472d81a3bc76f20ede80eaed76d8add" - integrity sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA== - dependencies: - minipass "^3.1.6" - minipass-sized "^1.0.3" - minizlib "^2.1.2" - optionalDependencies: - encoding "^0.1.13" - -minipass-flush@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" - integrity sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw== - dependencies: - minipass "^3.0.0" - -minipass-pipeline@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" - integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== - dependencies: - minipass "^3.0.0" - -minipass-sized@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/minipass-sized/-/minipass-sized-1.0.3.tgz#70ee5a7c5052070afacfbc22977ea79def353b70" - integrity sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== - dependencies: - minipass "^3.0.0" - -minipass@^3.0.0, minipass@^3.1.1, minipass@^3.1.6: - version "3.3.6" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" - integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== - dependencies: - yallist "^4.0.0" - -minipass@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.0.3.tgz#00bfbaf1e16e35e804f4aa31a7c1f6b8d9f0ee72" - integrity sha512-OW2r4sQ0sI+z5ckEt5c1Tri4xTgZwYDxpE54eqWlQloQRoWtXjqt9udJ5Z4dSv7wK+nfFI7FRXyCpBSft+gpFw== - -minizlib@^2.1.1, minizlib@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" - integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== - dependencies: - minipass "^3.0.0" - yallist "^4.0.0" - -mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: - version "0.5.3" - resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" - integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== - -"mkdirp@>=0.5 0": - version "0.5.6" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" - integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== - dependencies: - minimist "^1.2.6" - -mkdirp@^1.0.3, mkdirp@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" - integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== - -mocha@10.2.0: - version "10.2.0" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.2.0.tgz#1fd4a7c32ba5ac372e03a17eef435bd00e5c68b8" - integrity sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg== - dependencies: - ansi-colors "4.1.1" - browser-stdout "1.3.1" - chokidar "3.5.3" - debug "4.3.4" - diff "5.0.0" - escape-string-regexp "4.0.0" - find-up "5.0.0" - glob "7.2.0" - he "1.2.0" - js-yaml "4.1.0" - log-symbols "4.1.0" - minimatch "5.0.1" - ms "2.1.3" - nanoid "3.3.3" - serialize-javascript "6.0.0" - strip-json-comments "3.1.1" - supports-color "8.1.1" - workerpool "6.2.1" - yargs "16.2.0" - yargs-parser "20.2.4" - yargs-unparser "2.0.0" - -morgan@^1.6.1: - version "1.10.0" - resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7" - integrity sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ== - dependencies: - basic-auth "~2.0.1" - debug "2.6.9" - depd "~2.0.0" - on-finished "~2.3.0" - on-headers "~1.0.2" - -mrmime@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27" - integrity sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw== - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -ms@2.1.3, ms@^2.0.0, ms@^2.1.1: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -mute-stream@~0.0.4: - version "0.0.8" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" - integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== - -mylas@^2.1.9: - version "2.1.13" - resolved "https://registry.yarnpkg.com/mylas/-/mylas-2.1.13.tgz#1e23b37d58fdcc76e15d8a5ed23f9ae9fc0cbdf4" - integrity sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg== - -nan@^2.14.2: - version "2.17.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" - integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== - -nanoid@3.3.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" - integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w== - -nanoid@^3.3.4: - version "3.3.4" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" - integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== - -napi-build-utils@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" - integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== - -natural-compare-lite@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" - integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g== - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== - -neatequal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/neatequal/-/neatequal-1.0.0.tgz#2ee1211bc9fa6e4c55715fd210bb05602eb1ae3b" - integrity sha512-sVt5awO4a4w24QmAthdrCPiVRW3naB8FGLdyadin01BH+6BzNPEBwGrpwCczQvPlULS6uXTItTe1PJ5P0kYm7A== - dependencies: - varstream "^0.3.2" - -negotiator@0.6.3, negotiator@^0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - -neo-async@^2.6.0, neo-async@^2.6.2: - version "2.6.2" - resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" - integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== - -next-tick@1, next-tick@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" - integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== - -no-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" - integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== - dependencies: - lower-case "^2.0.2" - tslib "^2.0.3" - -node-abi@^3.3.0: - version "3.33.0" - resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.33.0.tgz#8b23a0cec84e1c5f5411836de6a9b84bccf26e7f" - integrity sha512-7GGVawqyHF4pfd0YFybhv/eM9JwTtPqx0mAanQ146O3FlSh3pA24zf9IRQTOsfTSqXTNzPSP5iagAJ94jjuVog== - dependencies: - semver "^7.3.5" - -node-addon-api@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" - integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== - -node-addon-api@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" - integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== - -node-fetch@2.6.9, node-fetch@^2.6.7: - version "2.6.9" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" - integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== - dependencies: - whatwg-url "^5.0.0" - -node-gyp@^9.0.0: - version "9.3.1" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-9.3.1.tgz#1e19f5f290afcc9c46973d68700cbd21a96192e4" - integrity sha512-4Q16ZCqq3g8awk6UplT7AuxQ35XN4R/yf/+wSAwcBUAjg7l58RTactWaP8fIDTi0FzI7YcVLujwExakZlfWkXg== - dependencies: - env-paths "^2.2.0" - glob "^7.1.4" - graceful-fs "^4.2.6" - make-fetch-happen "^10.0.3" - nopt "^6.0.0" - npmlog "^6.0.0" - rimraf "^3.0.2" - semver "^7.3.5" - tar "^6.1.2" - which "^2.0.2" - -node-releases@^2.0.8: - version "2.0.10" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f" - integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w== - -nopt@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" - integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== - dependencies: - abbrev "1" - -nopt@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-6.0.0.tgz#245801d8ebf409c6df22ab9d95b65e1309cdb16d" - integrity sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g== - dependencies: - abbrev "^1.0.0" - -normalize-package-data@^3.0.0, normalize-package-data@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-3.0.3.tgz#dbcc3e2da59509a0983422884cd172eefdfa525e" - integrity sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA== - dependencies: - hosted-git-info "^4.0.1" - is-core-module "^2.5.0" - semver "^7.3.4" - validate-npm-package-license "^3.0.1" - -normalize-path@^3.0.0, normalize-path@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -normalize-range@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" - integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== - -normalize-url@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" - integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== - -npm-normalize-package-bin@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" - integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== - -npmlog@^6.0.0: - version "6.0.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.2.tgz#c8166017a42f2dea92d6453168dd865186a70830" - integrity sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg== - dependencies: - are-we-there-yet "^3.0.0" - console-control-strings "^1.1.0" - gauge "^4.0.3" - set-blocking "^2.0.0" - -nth-check@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" - integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== - dependencies: - boolbase "^1.0.0" - -object-assign@^4.0.1, object-assign@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== - -object-inspect@^1.12.2, object-inspect@^1.9.0: - version "1.12.3" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" - integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== - -object-keys@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - -object.assign@^4.1.4: - version "4.1.4" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" - integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - has-symbols "^1.0.3" - object-keys "^1.1.1" - -object.values@^1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.6.tgz#4abbaa71eba47d63589d402856f908243eea9b1d" - integrity sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - -on-finished@^2.3.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" - -on-finished@~2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" - integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== - dependencies: - ee-first "1.1.1" - -on-headers@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" - integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== - -once@^1.3.0, once@^1.3.1, once@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -only@~0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4" - integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ== - -open@^8.4.0: - version "8.4.1" - resolved "https://registry.yarnpkg.com/open/-/open-8.4.1.tgz#2ab3754c07f5d1f99a7a8d6a82737c95e3101cff" - integrity sha512-/4b7qZNhv6Uhd7jjnREh1NjnPxlTq+XNWPG88Ydkj5AILcA5m3ajvcg57pB24EQjKv0dK62XnDqk9c/hkIG5Kg== - dependencies: - define-lazy-prop "^2.0.0" - is-docker "^2.1.1" - is-wsl "^2.2.0" - -opener@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" - integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== - -optionator@^0.9.1: - version "0.9.1" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" - integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== - dependencies: - deep-is "^0.1.3" - fast-levenshtein "^2.0.6" - levn "^0.4.1" - prelude-ls "^1.2.1" - type-check "^0.4.0" - word-wrap "^1.2.3" - -os-browserify@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" - integrity sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A== - -p-limit@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-limit@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" - -p-locate@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== - dependencies: - p-limit "^2.2.0" - -p-locate@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" - integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== - dependencies: - p-limit "^3.0.2" - -p-map@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" - integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== - -p-map@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" - integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== - dependencies: - aggregate-error "^3.0.0" - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -pako@^1.0.0: - version "1.0.11" - resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" - integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== - -param-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" - integrity sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A== - dependencies: - dot-case "^3.0.4" - tslib "^2.0.3" - -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - -parse-json@^5.0.0, parse-json@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" - integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== - dependencies: - "@babel/code-frame" "^7.0.0" - error-ex "^1.3.1" - json-parse-even-better-errors "^2.3.0" - lines-and-columns "^1.1.6" - -parse-semver@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/parse-semver/-/parse-semver-1.1.1.tgz#9a4afd6df063dc4826f93fba4a99cf223f666cb8" - integrity sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ== - dependencies: - semver "^5.1.0" - -parse5-htmlparser2-tree-adapter@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" - integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== - dependencies: - parse5 "^6.0.1" - -parse5-htmlparser2-tree-adapter@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1" - integrity sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g== - dependencies: - domhandler "^5.0.2" - parse5 "^7.0.0" - -parse5@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" - integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== - -parse5@^7.0.0: - version "7.1.2" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" - integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== - dependencies: - entities "^4.4.0" - -parseurl@^1.3.2: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - -pascal-case@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb" - integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - -path-browserify@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" - integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== - -path-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/path-case/-/path-case-3.0.4.tgz#9168645334eb942658375c56f80b4c0cb5f82c6f" - integrity sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg== - dependencies: - dot-case "^3.0.4" - tslib "^2.0.3" - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-is-absolute@1.0.1, path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - -path-is-inside@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" - integrity sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w== - -path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -path-to-regexp@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5" - integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw== - -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - -pend@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" - integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== - -picocolors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" - integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== - -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -pify@^2.0.0, pify@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" - integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== - -pify@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" - integrity sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg== - -pify@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" - integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== - -pinkie-promise@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" - integrity sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw== - dependencies: - pinkie "^2.0.0" - -pinkie@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" - integrity sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg== - -pkg-dir@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" - integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== - dependencies: - find-up "^4.0.0" - -playwright-core@1.30.0: - version "1.30.0" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.30.0.tgz#de987cea2e86669e3b85732d230c277771873285" - integrity sha512-7AnRmTCf+GVYhHbLJsGUtskWTE33SwMZkybJ0v6rqR1boxq2x36U7p1vDRV7HO2IwTZgmycracLxPEJI49wu4g== - -playwright@^1.29.2: - version "1.30.0" - resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.30.0.tgz#b1d7be2d45d97fbb59f829f36f521f12010fe072" - integrity sha512-ENbW5o75HYB3YhnMTKJLTErIBExrSlX2ZZ1C/FzmHjUYIfxj/UnI+DWpQr992m+OQVSg0rCExAOlRwB+x+yyIg== - dependencies: - playwright-core "1.30.0" - -plimit-lit@^1.2.6: - version "1.5.0" - resolved "https://registry.yarnpkg.com/plimit-lit/-/plimit-lit-1.5.0.tgz#f66df8a7041de1e965c4f1c0697ab486968a92a5" - integrity sha512-Eb/MqCb1Iv/ok4m1FqIXqvUKPISufcjZ605hl3KM/n8GaX8zfhtgdLwZU3vKjuHGh2O9Rjog/bHTq8ofIShdng== - dependencies: - queue-lit "^1.5.0" - -postcss-calc@^8.2.3: - version "8.2.4" - resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-8.2.4.tgz#77b9c29bfcbe8a07ff6693dc87050828889739a5" - integrity sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q== - dependencies: - postcss-selector-parser "^6.0.9" - postcss-value-parser "^4.2.0" - -postcss-colormin@^5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-5.3.0.tgz#3cee9e5ca62b2c27e84fce63affc0cfb5901956a" - integrity sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg== - dependencies: - browserslist "^4.16.6" - caniuse-api "^3.0.0" - colord "^2.9.1" - postcss-value-parser "^4.2.0" - -postcss-colormin@^5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-5.3.1.tgz#86c27c26ed6ba00d96c79e08f3ffb418d1d1988f" - integrity sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ== - dependencies: - browserslist "^4.21.4" - caniuse-api "^3.0.0" - colord "^2.9.1" - postcss-value-parser "^4.2.0" - -postcss-convert-values@^5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz#04998bb9ba6b65aa31035d669a6af342c5f9d393" - integrity sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA== - dependencies: - browserslist "^4.21.4" - postcss-value-parser "^4.2.0" - -postcss-discard-comments@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz#8df5e81d2925af2780075840c1526f0660e53696" - integrity sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ== - -postcss-discard-duplicates@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz#9eb4fe8456706a4eebd6d3b7b777d07bad03e848" - integrity sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw== - -postcss-discard-empty@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz#e57762343ff7f503fe53fca553d18d7f0c369c6c" - integrity sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A== - -postcss-discard-overridden@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz#7e8c5b53325747e9d90131bb88635282fb4a276e" - integrity sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw== - -postcss-discard-unused@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-unused/-/postcss-discard-unused-5.1.0.tgz#8974e9b143d887677304e558c1166d3762501142" - integrity sha512-KwLWymI9hbwXmJa0dkrzpRbSJEh0vVUd7r8t0yOGPcfKzyJJxFM8kLyC5Ev9avji6nY95pOp1W6HqIrfT+0VGw== - dependencies: - postcss-selector-parser "^6.0.5" - -postcss-merge-idents@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-merge-idents/-/postcss-merge-idents-5.1.1.tgz#7753817c2e0b75d0853b56f78a89771e15ca04a1" - integrity sha512-pCijL1TREiCoog5nQp7wUe+TUonA2tC2sQ54UGeMmryK3UFGIYKqDyjnqd6RcuI4znFn9hWSLNN8xKE/vWcUQw== - dependencies: - cssnano-utils "^3.1.0" - postcss-value-parser "^4.2.0" - -postcss-merge-longhand@^5.1.7: - version "5.1.7" - resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz#24a1bdf402d9ef0e70f568f39bdc0344d568fb16" - integrity sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ== - dependencies: - postcss-value-parser "^4.2.0" - stylehacks "^5.1.1" - -postcss-merge-rules@^5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-5.1.3.tgz#8f97679e67cc8d08677a6519afca41edf2220894" - integrity sha512-LbLd7uFC00vpOuMvyZop8+vvhnfRGpp2S+IMQKeuOZZapPRY4SMq5ErjQeHbHsjCUgJkRNrlU+LmxsKIqPKQlA== - dependencies: - browserslist "^4.21.4" - caniuse-api "^3.0.0" - cssnano-utils "^3.1.0" - postcss-selector-parser "^6.0.5" - -postcss-merge-rules@^5.1.4: - version "5.1.4" - resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz#2f26fa5cacb75b1402e213789f6766ae5e40313c" - integrity sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g== - dependencies: - browserslist "^4.21.4" - caniuse-api "^3.0.0" - cssnano-utils "^3.1.0" - postcss-selector-parser "^6.0.5" - -postcss-minify-font-values@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz#f1df0014a726083d260d3bd85d7385fb89d1f01b" - integrity sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-minify-gradients@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz#f1fe1b4f498134a5068240c2f25d46fcd236ba2c" - integrity sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw== - dependencies: - colord "^2.9.1" - cssnano-utils "^3.1.0" - postcss-value-parser "^4.2.0" - -postcss-minify-params@^5.1.4: - version "5.1.4" - resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz#c06a6c787128b3208b38c9364cfc40c8aa5d7352" - integrity sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw== - dependencies: - browserslist "^4.21.4" - cssnano-utils "^3.1.0" - postcss-value-parser "^4.2.0" - -postcss-minify-selectors@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz#d4e7e6b46147b8117ea9325a915a801d5fe656c6" - integrity sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg== - dependencies: - postcss-selector-parser "^6.0.5" - -postcss-modules-extract-imports@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d" - integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw== - -postcss-modules-local-by-default@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c" - integrity sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ== - dependencies: - icss-utils "^5.0.0" - postcss-selector-parser "^6.0.2" - postcss-value-parser "^4.1.0" - -postcss-modules-scope@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06" - integrity sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg== - dependencies: - postcss-selector-parser "^6.0.4" - -postcss-modules-values@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c" - integrity sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ== - dependencies: - icss-utils "^5.0.0" - -postcss-normalize-charset@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz#9302de0b29094b52c259e9b2cf8dc0879879f0ed" - integrity sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg== - -postcss-normalize-display-values@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz#72abbae58081960e9edd7200fcf21ab8325c3da8" - integrity sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-positions@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz#ef97279d894087b59325b45c47f1e863daefbb92" - integrity sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-repeat-style@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz#e9eb96805204f4766df66fd09ed2e13545420fb2" - integrity sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-string@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz#411961169e07308c82c1f8c55f3e8a337757e228" - integrity sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-timing-functions@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz#d5614410f8f0b2388e9f240aa6011ba6f52dafbb" - integrity sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-unicode@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz#f67297fca3fea7f17e0d2caa40769afc487aa030" - integrity sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA== - dependencies: - browserslist "^4.21.4" - postcss-value-parser "^4.2.0" - -postcss-normalize-url@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz#ed9d88ca82e21abef99f743457d3729a042adcdc" - integrity sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew== - dependencies: - normalize-url "^6.0.1" - postcss-value-parser "^4.2.0" - -postcss-normalize-whitespace@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz#08a1a0d1ffa17a7cc6efe1e6c9da969cc4493cfa" - integrity sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-ordered-values@^5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz#b6fd2bd10f937b23d86bc829c69e7732ce76ea38" - integrity sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ== - dependencies: - cssnano-utils "^3.1.0" - postcss-value-parser "^4.2.0" - -postcss-reduce-idents@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/postcss-reduce-idents/-/postcss-reduce-idents-5.2.0.tgz#c89c11336c432ac4b28792f24778859a67dfba95" - integrity sha512-BTrLjICoSB6gxbc58D5mdBK8OhXRDqud/zodYfdSi52qvDHdMwk+9kB9xsM8yJThH/sZU5A6QVSmMmaN001gIg== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-reduce-initial@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-5.1.1.tgz#c18b7dfb88aee24b1f8e4936541c29adbd35224e" - integrity sha512-//jeDqWcHPuXGZLoolFrUXBDyuEGbr9S2rMo19bkTIjBQ4PqkaO+oI8wua5BOUxpfi97i3PCoInsiFIEBfkm9w== - dependencies: - browserslist "^4.21.4" - caniuse-api "^3.0.0" - -postcss-reduce-initial@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz#798cd77b3e033eae7105c18c9d371d989e1382d6" - integrity sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg== - dependencies: - browserslist "^4.21.4" - caniuse-api "^3.0.0" - -postcss-reduce-transforms@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz#333b70e7758b802f3dd0ddfe98bb1ccfef96b6e9" - integrity sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9: - version "6.0.11" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz#2e41dc39b7ad74046e1615185185cd0b17d0c8dc" - integrity sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - -postcss-svgo@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-5.1.0.tgz#0a317400ced789f233a28826e77523f15857d80d" - integrity sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA== - dependencies: - postcss-value-parser "^4.2.0" - svgo "^2.7.0" - -postcss-unique-selectors@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz#a9f273d1eacd09e9aa6088f4b0507b18b1b541b6" - integrity sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA== - dependencies: - postcss-selector-parser "^6.0.5" - -postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" - integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== - -postcss-zindex@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-5.1.0.tgz#4a5c7e5ff1050bd4c01d95b1847dfdcc58a496ff" - integrity sha512-fgFMf0OtVSBR1va1JNHYgMxYk73yhn/qb4uQDq1DLGYolz8gHCyr/sesEuGUaYs58E3ZJRcpoGuPVoB7Meiq9A== - -postcss@^8.4.17, postcss@^8.4.19: - version "8.4.21" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4" - integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== - dependencies: - nanoid "^3.3.4" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -prebuild-install@^7.0.1, prebuild-install@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.1.tgz#de97d5b34a70a0c81334fd24641f2a1702352e45" - integrity sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw== - dependencies: - detect-libc "^2.0.0" - expand-template "^2.0.3" - github-from-package "0.0.0" - minimist "^1.2.3" - mkdirp-classic "^0.5.3" - napi-build-utils "^1.0.1" - node-abi "^3.3.0" - pump "^3.0.0" - rc "^1.2.7" - simple-get "^4.0.0" - tar-fs "^2.0.0" - tunnel-agent "^0.6.0" - -prelude-ls@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" - integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== - -prettier@2.8.4: - version "2.8.4" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.4.tgz#34dd2595629bfbb79d344ac4a91ff948694463c3" - integrity sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw== - -pretty-error@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-4.0.0.tgz#90a703f46dd7234adb46d0f84823e9d1cb8f10d6" - integrity sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw== - dependencies: - lodash "^4.17.20" - renderkid "^3.0.0" - -process-nextick-args@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" - integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== - -promise-inflight@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" - integrity sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g== - -promise-retry@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22" - integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g== - dependencies: - err-code "^2.0.2" - retry "^0.12.0" - -prop-types-extra@^1.0.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/prop-types-extra/-/prop-types-extra-1.1.1.tgz#58c3b74cbfbb95d304625975aa2f0848329a010b" - integrity sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew== - dependencies: - react-is "^16.3.2" - warning "^4.0.0" - -prop-types@^15.5.10, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: - version "15.8.1" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" - integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== - dependencies: - loose-envify "^1.4.0" - object-assign "^4.1.1" - react-is "^16.13.1" - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -punycode@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" - integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== - -qs@^6.9.1: - version "6.11.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" - integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== - dependencies: - side-channel "^1.0.4" - -queue-lit@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/queue-lit/-/queue-lit-1.5.0.tgz#8197fdafda1edd615c8a0fc14c48353626e5160a" - integrity sha512-IslToJ4eiCEE9xwMzq3viOO5nH8sUWUCwoElrhNMozzr9IIt2qqvB4I+uHu/zJTQVqc9R5DFwok4ijNK1pU3fA== - -queue-microtask@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" - integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== - -quick-lru@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" - integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== - -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - -rc@^1.2.7: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - -re-resizable@6.9.1: - version "6.9.1" - resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.9.1.tgz#6be082b55d02364ca4bfee139e04feebdf52441c" - integrity sha512-KRYAgr9/j1PJ3K+t+MBhlQ+qkkoLDJ1rs0z1heIWvYbCW/9Vq4djDU+QumJ3hQbwwtzXF6OInla6rOx6hhgRhQ== - dependencies: - fast-memoize "^2.5.1" - -react-bootstrap@0.32.4: - version "0.32.4" - resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-0.32.4.tgz#8efc4cbfc4807215d75b7639bee0d324c8d740d1" - integrity sha512-xj+JfaPOvnvr3ow0aHC7Y3HaBKZNR1mm361hVxVzVX3fcdJNIrfiodbQ0m9nLBpNxiKG6FTU2lq/SbTDYT2vew== - dependencies: - "@babel/runtime-corejs2" "^7.0.0" - classnames "^2.2.5" - dom-helpers "^3.2.0" - invariant "^2.2.4" - keycode "^2.2.0" - prop-types "^15.6.1" - prop-types-extra "^1.0.1" - react-overlays "^0.8.0" - react-prop-types "^0.4.0" - react-transition-group "^2.0.0" - uncontrollable "^5.0.0" - warning "^3.0.0" - -react-dom@16.8.4: - version "16.8.4" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.4.tgz#1061a8e01a2b3b0c8160037441c3bf00a0e3bc48" - integrity sha512-Ob2wK7XG2tUDt7ps7LtLzGYYB6DXMCLj0G5fO6WeEICtT4/HdpOi7W/xLzZnR6RCG1tYza60nMdqtxzA8FaPJQ== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - prop-types "^15.6.2" - scheduler "^0.13.4" - -react-dragula@1.1.17: - version "1.1.17" - resolved "https://registry.yarnpkg.com/react-dragula/-/react-dragula-1.1.17.tgz#b3cb352a470a719367ba99d6a5401c60fad4f6ff" - integrity sha512-gJdY190sPWAyV8jz79vyK9SGk97bVOHjUguVNIYIEVosvt27HLxnbJo4qiuEkb/nAuGY13Im2CHup92fUyO3fw== - dependencies: - atoa "1.0.0" - dragula "3.7.2" - -react-fast-compare@^3.1.1: - version "3.2.0" - resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" - integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== - -react-helmet@6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726" - integrity sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw== - dependencies: - object-assign "^4.1.1" - prop-types "^15.7.2" - react-fast-compare "^3.1.1" - react-side-effect "^2.1.0" - -react-is@^16.13.1, react-is@^16.3.2: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" - integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== - -react-lifecycles-compat@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" - integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== - -react-overlays@^0.8.0: - version "0.8.3" - resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.8.3.tgz#fad65eea5b24301cca192a169f5dddb0b20d3ac5" - integrity sha512-h6GT3jgy90PgctleP39Yu3eK1v9vaJAW73GOA/UbN9dJ7aAN4BTZD6793eI1D5U+ukMk17qiqN/wl3diK1Z5LA== - dependencies: - classnames "^2.2.5" - dom-helpers "^3.2.1" - prop-types "^15.5.10" - prop-types-extra "^1.0.1" - react-transition-group "^2.2.0" - warning "^3.0.0" - -react-prop-types@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/react-prop-types/-/react-prop-types-0.4.0.tgz#f99b0bfb4006929c9af2051e7c1414a5c75b93d0" - integrity sha512-IyjsJhDX9JkoOV9wlmLaS7z+oxYoIWhfzDcFy7inwoAKTu+VcVNrVpPmLeioJ94y6GeDRsnwarG1py5qofFQMg== - dependencies: - warning "^3.0.0" - -react-side-effect@^2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.2.tgz#dc6345b9e8f9906dc2eeb68700b615e0b4fe752a" - integrity sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw== - -react-transition-group@^2.0.0, react-transition-group@^2.2.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" - integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== - dependencies: - dom-helpers "^3.4.0" - loose-envify "^1.4.0" - prop-types "^15.6.2" - react-lifecycles-compat "^3.0.4" - -react@16.8.4: - version "16.8.4" - resolved "https://registry.yarnpkg.com/react/-/react-16.8.4.tgz#fdf7bd9ae53f03a9c4cd1a371432c206be1c4768" - integrity sha512-0GQ6gFXfUH7aZcjGVymlPOASTuSjlQL4ZtVC5YKH+3JL6bBLCVO21DknzmaPlI90LN253ojj02nsapy+j7wIjg== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - prop-types "^15.6.2" - scheduler "^0.13.4" - -read-installed-packages@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/read-installed-packages/-/read-installed-packages-1.0.0.tgz#ca94531c4392f579a26128906bad161fc19e2727" - integrity sha512-CZdFN0oYn7Iko+X8ynOztXNfbpO7UvAw1qEboRXCASP/psVrdt8LTvmwUYhAUF9q1dnD5R7lW4pmbpO6CQsfOg== - dependencies: - debug "^4.3.1" - read-package-json "^4.0.0" - readdir-scoped-modules "^1.0.0" - semver "2 || 3 || 4 || 5 || 6 || 7" - slide "~1.1.3" - optionalDependencies: - graceful-fs "^4.1.2" - -read-package-json@^4.0.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-4.1.2.tgz#b444d047de7c75d4a160cb056d00c0693c1df703" - integrity sha512-Dqer4pqzamDE2O4M55xp1qZMuLPqi4ldk2ya648FOMHRjwMzFhuxVrG04wd0c38IsvkVdr3vgHI6z+QTPdAjrQ== - dependencies: - glob "^7.1.1" - json-parse-even-better-errors "^2.3.0" - normalize-package-data "^3.0.0" - npm-normalize-package-bin "^1.0.0" - -read-pkg-up@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-8.0.0.tgz#72f595b65e66110f43b052dd9af4de6b10534670" - integrity sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ== - dependencies: - find-up "^5.0.0" - read-pkg "^6.0.0" - type-fest "^1.0.1" - -read-pkg@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-6.0.0.tgz#a67a7d6a1c2b0c3cd6aa2ea521f40c458a4a504c" - integrity sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q== - dependencies: - "@types/normalize-package-data" "^2.4.0" - normalize-package-data "^3.0.2" - parse-json "^5.2.0" - type-fest "^1.0.1" - -read@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4" - integrity sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ== - dependencies: - mute-stream "~0.0.4" - -readable-stream@^1.0.33: - version "1.1.14" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" - integrity sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "0.0.1" - string_decoder "~0.10.x" - -readable-stream@^2.0.2, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@~2.3.6: - version "2.3.7" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" - integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" - integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readdir-scoped-modules@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309" - integrity sha512-asaikDeqAQg7JifRsZn1NJZXo9E+VwlyCfbkZhwyISinqk5zNS6266HS5kah6P0SaQKGF6SkNnZVHUzHFYxYDw== - dependencies: - debuglog "^1.0.1" - dezalgo "^1.0.0" - graceful-fs "^4.1.2" - once "^1.3.0" - -readdirp@~3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" - integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - dependencies: - picomatch "^2.2.1" - -rechoir@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22" - integrity sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ== - dependencies: - resolve "^1.20.0" - -redent@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/redent/-/redent-4.0.0.tgz#0c0ba7caabb24257ab3bb7a4fd95dd1d5c5681f9" - integrity sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag== - dependencies: - indent-string "^5.0.0" - strip-indent "^4.0.0" - -regenerator-runtime@^0.13.11: - version "0.13.11" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" - integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== - -regexp.prototype.flags@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" - integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.3" - functions-have-names "^1.2.2" - -regexpp@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" - integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== - -relateurl@^0.2.7: - version "0.2.7" - resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" - integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog== - -renderkid@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a" - integrity sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg== - dependencies: - css-select "^4.1.3" - dom-converter "^0.2.0" - htmlparser2 "^6.1.0" - lodash "^4.17.21" - strip-ansi "^6.0.1" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== - -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - -requireindex@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.2.0.tgz#3463cdb22ee151902635aa6c9535d4de9c2ef1ef" - integrity sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww== - -resolve-cwd@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" - integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== - dependencies: - resolve-from "^5.0.0" - -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - -resolve-from@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" - integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== - -resolve-path@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7" - integrity sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w== - dependencies: - http-errors "~1.6.2" - path-is-absolute "1.0.1" - -resolve@^1.20.0, resolve@^1.22.1, resolve@^1.3.3: - version "1.22.1" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" - integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== - dependencies: - is-core-module "^2.9.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - -retry@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" - integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== - -reusify@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" - integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== - -rimraf@2, rimraf@^2.6.3: - version "2.7.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== - dependencies: - glob "^7.1.3" - -rimraf@^3.0.0, rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - -run-parallel@^1.1.9: - version "1.2.0" - resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" - integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== - dependencies: - queue-microtask "^1.2.2" - -rw@1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" - integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== - -rxjs@^7.0.0: - version "7.8.0" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4" - integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg== - dependencies: - tslib "^2.1.0" - -safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safe-regex-test@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295" - integrity sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.1.3" - is-regex "^1.1.4" - -"safer-buffer@>= 2.1.2 < 3.0.0": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -sass-loader@13.2.0: - version "13.2.0" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-13.2.0.tgz#80195050f58c9aac63b792fa52acb6f5e0f6bdc3" - integrity sha512-JWEp48djQA4nbZxmgC02/Wh0eroSUutulROUusYJO9P9zltRbNN80JCBHqRGzjd4cmZCa/r88xgfkjGD0TXsHg== - dependencies: - klona "^2.0.4" - neo-async "^2.6.2" - -sass@1.58.3: - version "1.58.3" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.58.3.tgz#2348cc052061ba4f00243a208b09c40e031f270d" - integrity sha512-Q7RaEtYf6BflYrQ+buPudKR26/lH+10EmO9bBqbmPh/KeLqv8bjpTNqxe71ocONqXq+jYiCbpPUmQMS+JJPk4A== - dependencies: - chokidar ">=3.0.0 <4.0.0" - immutable "^4.0.0" - source-map-js ">=0.6.2 <2.0.0" - -sax@>=0.6.0, sax@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" - integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== - -scheduler@^0.13.4: - version "0.13.6" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.6.tgz#466a4ec332467b31a91b9bf74e5347072e4cd889" - integrity sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - -schema-utils@2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" - integrity sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A== - dependencies: - "@types/json-schema" "^7.0.4" - ajv "^6.12.2" - ajv-keywords "^3.4.1" - -schema-utils@4.0.0, schema-utils@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.0.0.tgz#60331e9e3ae78ec5d16353c467c34b3a0a1d3df7" - integrity sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg== - dependencies: - "@types/json-schema" "^7.0.9" - ajv "^8.8.0" - ajv-formats "^2.1.1" - ajv-keywords "^5.0.0" - -schema-utils@^3.1.0, schema-utils@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.1.tgz#bc74c4b6b6995c1d88f76a8b77bea7219e0c8281" - integrity sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw== - dependencies: - "@types/json-schema" "^7.0.8" - ajv "^6.12.5" - ajv-keywords "^3.5.2" - -seek-bzip@^1.0.5: - version "1.0.6" - resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.6.tgz#35c4171f55a680916b52a07859ecf3b5857f21c4" - integrity sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ== - dependencies: - commander "^2.8.1" - -semver-regex@4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-4.0.5.tgz#fbfa36c7ba70461311f5debcb3928821eb4f9180" - integrity sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw== - -"semver@2 || 3 || 4 || 5 || 6 || 7", semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8: - version "7.3.8" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" - integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== - dependencies: - lru-cache "^6.0.0" - -semver@^5.1.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== - -semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -sentence-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/sentence-case/-/sentence-case-3.0.4.tgz#3645a7b8c117c787fde8702056225bb62a45131f" - integrity sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - upper-case-first "^2.0.2" - -serialize-javascript@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" - integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== - dependencies: - randombytes "^2.1.0" - -serialize-javascript@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.1.tgz#b206efb27c3da0b0ab6b52f48d170b7996458e5c" - integrity sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w== - dependencies: - randombytes "^2.1.0" - -set-blocking@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== - -setimmediate@~1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== - -setprototypeof@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" - integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== - -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - -shallow-clone@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" - integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== - dependencies: - kind-of "^6.0.2" - -sharp@0.31.3: - version "0.31.3" - resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.31.3.tgz#60227edc5c2be90e7378a210466c99aefcf32688" - integrity sha512-XcR4+FCLBFKw1bdB+GEhnUNXNXvnt0tDo4WsBsraKymuo/IAuPuCBVAL2wIkUw2r/dwFW5Q5+g66Kwl2dgDFVg== - dependencies: - color "^4.2.3" - detect-libc "^2.0.1" - node-addon-api "^5.0.0" - prebuild-install "^7.1.1" - semver "^7.3.8" - simple-get "^4.0.1" - tar-fs "^2.1.1" - tunnel-agent "^0.6.0" - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -shell-quote@^1.7.3: - version "1.8.0" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.0.tgz#20d078d0eaf71d54f43bd2ba14a1b5b9bfa5c8ba" - integrity sha512-QHsz8GgQIGKlRi24yFc6a6lN69Idnx634w49ay6+jA5yFh7a1UY+4Rp6HPx/L/1zcEDPEij8cIsiqR6bQsE5VQ== - -side-channel@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" - integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== - dependencies: - call-bind "^1.0.0" - get-intrinsic "^1.0.2" - object-inspect "^1.9.0" - -signal-exit@^3.0.7: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - -simple-concat@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" - integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== - -simple-get@^4.0.0, simple-get@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" - integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== - dependencies: - decompress-response "^6.0.0" - once "^1.3.1" - simple-concat "^1.0.0" - -simple-swizzle@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" - integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== - dependencies: - is-arrayish "^0.3.1" - -sirv@^1.0.7: - version "1.0.19" - resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.19.tgz#1d73979b38c7fe91fcba49c85280daa9c2363b49" - integrity sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ== - dependencies: - "@polka/url" "^1.0.0-next.20" - mrmime "^1.0.0" - totalist "^1.0.0" - -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== - -slash@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" - integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== - -slide@~1.1.3: - version "1.1.6" - resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" - integrity sha512-NwrtjCg+lZoqhFU8fOwl4ay2ei8PaqCBOUV3/ektPY9trO1yQ1oXEfmHAhKArUVUr/hOHvy5f6AdP17dCM0zMw== - -slugify@^1.6.0: - version "1.6.5" - resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.5.tgz#c8f5c072bf2135b80703589b39a3d41451fbe8c8" - integrity sha512-8mo9bslnBO3tr5PEVFzMPIWwWnipGS0xVbYf65zxDqfNwmzYn1LpiKNrR6DlClusuvo+hDHd1zKpmfAe83NQSQ== - -smart-buffer@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" - integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== - -snake-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" - integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== - dependencies: - dot-case "^3.0.4" - tslib "^2.0.3" - -socks-proxy-agent@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz#dc069ecf34436621acb41e3efa66ca1b5fed15b6" - integrity sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww== - dependencies: - agent-base "^6.0.2" - debug "^4.3.3" - socks "^2.6.2" - -socks@^2.6.2: - version "2.7.1" - resolved "https://registry.yarnpkg.com/socks/-/socks-2.7.1.tgz#d8e651247178fde79c0663043e07240196857d55" - integrity sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ== - dependencies: - ip "^2.0.0" - smart-buffer "^4.2.0" - -sortablejs@1.15.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.15.0.tgz#53230b8aa3502bb77a29e2005808ffdb4a5f7e2a" - integrity sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w== - -source-list-map@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" - integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== - -"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" - integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== - -source-map-support@~0.5.20: - version "0.5.21" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" - integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -spawn-command@^0.0.2-1: - version "0.0.2-1" - resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0" - integrity sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg== - -spdx-compare@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/spdx-compare/-/spdx-compare-1.0.0.tgz#2c55f117362078d7409e6d7b08ce70a857cd3ed7" - integrity sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A== - dependencies: - array-find-index "^1.0.2" - spdx-expression-parse "^3.0.0" - spdx-ranges "^2.0.0" - -spdx-correct@^3.0.0, spdx-correct@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" - integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== - dependencies: - spdx-expression-parse "^3.0.0" - spdx-license-ids "^3.0.0" - -spdx-exceptions@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" - integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== - -spdx-expression-parse@^3.0.0, spdx-expression-parse@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" - integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== - dependencies: - spdx-exceptions "^2.1.0" - spdx-license-ids "^3.0.0" - -spdx-license-ids@^3.0.0: - version "3.0.12" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz#69077835abe2710b65f03969898b6637b505a779" - integrity sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA== - -spdx-ranges@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/spdx-ranges/-/spdx-ranges-2.1.1.tgz#87573927ba51e92b3f4550ab60bfc83dd07bac20" - integrity sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA== - -spdx-satisfies@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/spdx-satisfies/-/spdx-satisfies-5.0.1.tgz#9feeb2524686c08e5f7933c16248d4fdf07ed6a6" - integrity sha512-Nwor6W6gzFp8XX4neaKQ7ChV4wmpSh2sSDemMFSzHxpTw460jxFYeOn+jq4ybnSSw/5sc3pjka9MQPouksQNpw== - dependencies: - spdx-compare "^1.0.0" - spdx-expression-parse "^3.0.0" - spdx-ranges "^2.0.0" - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== - -ssri@^9.0.0: - version "9.0.1" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-9.0.1.tgz#544d4c357a8d7b71a19700074b6883fcb4eae057" - integrity sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q== - dependencies: - minipass "^3.1.1" - -stable@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" - integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== - -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== - -"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" - integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string.prototype.trimend@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz#c4a27fa026d979d79c04f17397f250a462944533" - integrity sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - -string.prototype.trimstart@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz#e90ab66aa8e4007d92ef591bbf3cd422c56bdcf4" - integrity sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~0.10.x: - version "0.10.31" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-bom@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" - integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== - -strip-dirs@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/strip-dirs/-/strip-dirs-2.1.0.tgz#4987736264fc344cf20f6c34aca9d13d1d4ed6c5" - integrity sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g== - dependencies: - is-natural-number "^4.0.1" - -strip-indent@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-4.0.0.tgz#b41379433dd06f5eae805e21d631e07ee670d853" - integrity sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA== - dependencies: - min-indent "^1.0.1" - -strip-json-comments@3.1.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== - -stylehacks@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-5.1.1.tgz#7934a34eb59d7152149fa69d6e9e56f2fc34bcc9" - integrity sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw== - dependencies: - browserslist "^4.21.4" - postcss-selector-parser "^6.0.4" - -supports-color@8.1.1, supports-color@^8.0.0, supports-color@^8.1.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -supports-color@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" - integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - dependencies: - has-flag "^4.0.0" - -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== - -svg-pathdata@^6.0.0: - version "6.0.3" - resolved "https://registry.yarnpkg.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz#80b0e0283b652ccbafb69ad4f8f73e8d3fbf2cac" - integrity sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw== - -svg2ttf@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/svg2ttf/-/svg2ttf-6.0.3.tgz#7b92978ff124b2a583d21e1208b9675e245e56d1" - integrity sha512-CgqMyZrbOPpc+WqH7aga4JWkDPso23EgypLsbQ6gN3uoPWwwiLjXvzgrwGADBExvCRJrWFzAeK1bSoSpE7ixSQ== - dependencies: - "@xmldom/xmldom" "^0.7.2" - argparse "^2.0.1" - cubic2quad "^1.2.1" - lodash "^4.17.10" - microbuffer "^1.0.0" - svgpath "^2.1.5" - -svgicons2svgfont@^10.0.3: - version "10.0.6" - resolved "https://registry.yarnpkg.com/svgicons2svgfont/-/svgicons2svgfont-10.0.6.tgz#2901f9016244049674d3b3178c36471994a30c0a" - integrity sha512-fUgQEVg3XwTbOHvlXahHGqCet5Wvfo1bV4DCvbSRvjsOCPCRunYbG4dUJCPegps37BMph3eOrfoobhH5AWuC6A== - dependencies: - commander "^7.2.0" - geometry-interfaces "^1.1.4" - glob "^7.1.6" - neatequal "^1.0.0" - readable-stream "^3.4.0" - sax "^1.2.4" - svg-pathdata "^6.0.0" - -svgo@3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.0.2.tgz#5e99eeea42c68ee0dc46aa16da093838c262fe0a" - integrity sha512-Z706C1U2pb1+JGP48fbazf3KxHrWOsLme6Rv7imFBn5EnuanDW1GPaA/P1/dvObE670JDePC3mnj0k0B7P0jjQ== - dependencies: - "@trysound/sax" "0.2.0" - commander "^7.2.0" - css-select "^5.1.0" - css-tree "^2.2.1" - csso "^5.0.5" - picocolors "^1.0.0" - -svgo@^2.7.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24" - integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg== - dependencies: - "@trysound/sax" "0.2.0" - commander "^7.2.0" - css-select "^4.1.3" - css-tree "^1.1.3" - csso "^4.2.0" - picocolors "^1.0.0" - stable "^0.1.8" - -svgpath@^2.1.5: - version "2.6.0" - resolved "https://registry.yarnpkg.com/svgpath/-/svgpath-2.6.0.tgz#5b160ef3d742b7dfd2d721bf90588d3450d7a90d" - integrity sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg== - -synckit@^0.8.4: - version "0.8.5" - resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.5.tgz#b7f4358f9bb559437f9f167eb6bc46b3c9818fa3" - integrity sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q== - dependencies: - "@pkgr/utils" "^2.3.1" - tslib "^2.5.0" - -tabbable@^5.2.0: - version "5.3.3" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" - integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA== - -tapable@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" - integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== - -tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" - integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== - -tar-fs@^2.0.0, tar-fs@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" - integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== - dependencies: - chownr "^1.1.1" - mkdirp-classic "^0.5.2" - pump "^3.0.0" - tar-stream "^2.1.4" - -tar-stream@^1.5.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" - integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== - dependencies: - bl "^1.0.0" - buffer-alloc "^1.2.0" - end-of-stream "^1.0.0" - fs-constants "^1.0.0" - readable-stream "^2.3.0" - to-buffer "^1.1.1" - xtend "^4.0.0" - -tar-stream@^2.1.4: - version "2.2.0" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" - integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== - dependencies: - bl "^4.0.3" - end-of-stream "^1.4.1" - fs-constants "^1.0.0" - inherits "^2.0.3" - readable-stream "^3.1.1" - -tar@^6.1.11, tar@^6.1.2: - version "6.1.13" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.13.tgz#46e22529000f612180601a6fe0680e7da508847b" - integrity sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw== - dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^4.0.0" - minizlib "^2.1.1" - mkdirp "^1.0.3" - yallist "^4.0.0" - -terser-webpack-plugin@5.3.6, terser-webpack-plugin@^5.1.3: - version "5.3.6" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz#5590aec31aa3c6f771ce1b1acca60639eab3195c" - integrity sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ== - dependencies: - "@jridgewell/trace-mapping" "^0.3.14" - jest-worker "^27.4.5" - schema-utils "^3.1.1" - serialize-javascript "^6.0.0" - terser "^5.14.1" - -terser@^5.10.0, terser@^5.14.1, terser@^5.15.1: - version "5.16.3" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.3.tgz#3266017a9b682edfe019b8ecddd2abaae7b39c6b" - integrity sha512-v8wWLaS/xt3nE9dgKEWhNUFP6q4kngO5B8eYFUuebsu7Dw/UNAnpUod6UHo04jSSkv8TzKHjZDSd7EXdDQAl8Q== - dependencies: - "@jridgewell/source-map" "^0.3.2" - acorn "^8.5.0" - commander "^2.20.0" - source-map-support "~0.5.20" - -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - -through@^2.3.8: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== - -ticky@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/ticky/-/ticky-1.0.1.tgz#b7cfa71e768f1c9000c497b9151b30947c50e46d" - integrity sha512-RX35iq/D+lrsqhcPWIazM9ELkjOe30MSeoBHQHSsRwd1YuhJO5ui1K1/R0r7N3mFvbLBs33idw+eR6j+w6i/DA== - -timers-ext@^0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.7.tgz#6f57ad8578e07a3fb9f91d9387d65647555e25c6" - integrity sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ== - dependencies: - es5-ext "~0.10.46" - next-tick "1" - -tiny-glob@^0.2.9: - version "0.2.9" - resolved "https://registry.yarnpkg.com/tiny-glob/-/tiny-glob-0.2.9.tgz#2212d441ac17928033b110f8b3640683129d31e2" - integrity sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg== - dependencies: - globalyzer "0.1.0" - globrex "^0.1.2" - -tmp@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" - integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== - dependencies: - rimraf "^3.0.0" - -to-buffer@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" - integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg== - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -toidentifier@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" - integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== - -totalist@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" - integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== - -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== - -"traverse@>=0.3.0 <0.4": - version "0.3.9" - resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" - integrity sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ== - -tree-kill@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" - integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== - -treeify@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/treeify/-/treeify-1.1.0.tgz#4e31c6a463accd0943879f30667c4fdaff411bb8" - integrity sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A== - -trim-newlines@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-4.0.2.tgz#d6aaaf6a0df1b4b536d183879a6b939489808c7c" - integrity sha512-GJtWyq9InR/2HRiLZgpIKv+ufIKrVrvjQWEj7PxAXNc5dwbNJkqhAUoAGgzRmULAnoOM5EIpveYd3J2VeSAIew== - -ts-loader@9.4.2: - version "9.4.2" - resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.4.2.tgz#80a45eee92dd5170b900b3d00abcfa14949aeb78" - integrity sha512-OmlC4WVmFv5I0PpaxYb+qGeGOdm5giHU7HwDDUjw59emP2UYMHy9fFSDcYgSNoH8sXcj4hGCSEhlDZ9ULeDraA== - dependencies: - chalk "^4.1.0" - enhanced-resolve "^5.0.0" - micromatch "^4.0.0" - semver "^7.3.4" - -tsc-alias@1.8.2: - version "1.8.2" - resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.8.2.tgz#3cd24bba7333a5e05cb7db3ac206d7bcec079630" - integrity sha512-ukBkcNekOgwtnSWYLD5QsMX3yQWg7JviAs8zg3qJGgu4LGtY3tsV4G6vnqvOXIDkbC+XL9vbhObWSpRA5/6wbg== - dependencies: - chokidar "^3.5.3" - commander "^9.0.0" - globby "^11.0.4" - mylas "^2.1.9" - normalize-path "^3.0.0" - plimit-lit "^1.2.6" - -tsconfig-paths@^3.14.1: - version "3.14.1" - resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" - integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ== - dependencies: - "@types/json5" "^0.0.29" - json5 "^1.0.1" - minimist "^1.2.6" - strip-bom "^3.0.0" - -tslib@^1.13.0, tslib@^1.8.1: - version "1.14.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" - integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== - -tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" - integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== - -tsscmp@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" - integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== - -tsutils@^3.21.0: - version "3.21.0" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" - integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== - dependencies: - tslib "^1.8.1" - -ttf2eot@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ttf2eot/-/ttf2eot-2.0.0.tgz#8e6337a585abd1608a0c84958ab483ce69f6654b" - integrity sha512-U56aG2Ylw7psLOmakjemAzmpqVgeadwENg9oaDjaZG5NYX4WB6+7h74bNPcc+0BXsoU5A/XWiHabDXyzFOmsxQ== - dependencies: - argparse "^1.0.6" - microbuffer "^1.0.0" - -ttf2woff2@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/ttf2woff2/-/ttf2woff2-4.0.5.tgz#c7c87242938e9e2ed37fe5f477dd21acdb88fbfd" - integrity sha512-zpoU0NopfjoyVqkFeQ722SyKk/n607mm5OHxuDS/wCCSy82B8H3hHXrezftA2KMbKqfJIjie2lsJHdvPnBGbsw== - dependencies: - bindings "^1.5.0" - bufferstreams "^3.0.0" - nan "^2.14.2" - node-gyp "^9.0.0" - -ttf2woff@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ttf2woff/-/ttf2woff-3.0.0.tgz#bd0fc0157e428b7a9a30340f78adf72fb741962a" - integrity sha512-OvmFcj70PhmAsVQKfC15XoKH55cRWuaRzvr2fpTNhTNer6JBpG8n6vOhRrIgxMjcikyYt88xqYXMMVapJ4Rjvg== - dependencies: - argparse "^2.0.1" - pako "^1.0.0" - -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" - integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== - dependencies: - safe-buffer "^5.0.1" - -tunnel@0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c" - integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== - -type-check@^0.4.0, type-check@~0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" - integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== - dependencies: - prelude-ls "^1.2.1" - -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - -type-fest@^1.0.1, type-fest@^1.2.1, type-fest@^1.2.2: - version "1.4.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" - integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== - -type-is@^1.6.16: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - -type@^1.0.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/type/-/type-1.2.0.tgz#848dd7698dafa3e54a6c479e759c4bc3f18847a0" - integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== - -type@^2.7.2: - version "2.7.2" - resolved "https://registry.yarnpkg.com/type/-/type-2.7.2.tgz#2376a15a3a28b1efa0f5350dcf72d24df6ef98d0" - integrity sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw== - -typed-array-length@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.4.tgz#89d83785e5c4098bec72e08b319651f0eac9c1bb" - integrity sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng== - dependencies: - call-bind "^1.0.2" - for-each "^0.3.3" - is-typed-array "^1.1.9" - -typed-rest-client@^1.8.4: - version "1.8.9" - resolved "https://registry.yarnpkg.com/typed-rest-client/-/typed-rest-client-1.8.9.tgz#e560226bcadfe71b0fb5c416b587f8da3b8f92d8" - integrity sha512-uSmjE38B80wjL85UFX3sTYEUlvZ1JgCRhsWj/fJ4rZ0FqDUFoIuodtiVeE+cUqiVTOKPdKrp/sdftD15MDek6g== - dependencies: - qs "^6.9.1" - tunnel "0.0.6" - underscore "^1.12.1" - -typescript@4.9.5: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== - -uc.micro@^1.0.1, uc.micro@^1.0.5: - version "1.0.6" - resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" - integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== - -uglify-js@^3.1.4: - version "3.17.4" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c" - integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g== - -unbox-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" - integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== - dependencies: - call-bind "^1.0.2" - has-bigints "^1.0.2" - has-symbols "^1.0.3" - which-boxed-primitive "^1.0.2" - -unbzip2-stream@^1.0.9: - version "1.4.3" - resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" - integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== - dependencies: - buffer "^5.2.1" - through "^2.3.8" - -uncontrollable@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-5.1.0.tgz#7e9a1c50ea24e3c78b625e52d21ff3f758c7bd59" - integrity sha512-5FXYaFANKaafg4IVZXUNtGyzsnYEvqlr9wQ3WpZxFpEUxl29A3H6Q4G1Dnnorvq9TGOGATBApWR4YpLAh+F5hw== - dependencies: - invariant "^2.2.4" - -underscore@^1.12.1: - version "1.13.6" - resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" - integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== - -unique-filename@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-2.0.1.tgz#e785f8675a9a7589e0ac77e0b5c34d2eaeac6da2" - integrity sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A== - dependencies: - unique-slug "^3.0.0" - -unique-slug@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-3.0.0.tgz#6d347cf57c8a7a7a6044aabd0e2d74e4d76dc7c9" - integrity sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w== - dependencies: - imurmurhash "^0.1.4" - -universal-user-agent@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" - integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== - -universalify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" - integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== - -unzipper@^0.10.11: - version "0.10.11" - resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e" - integrity sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw== - dependencies: - big-integer "^1.6.17" - binary "~0.3.0" - bluebird "~3.4.1" - buffer-indexof-polyfill "~1.0.0" - duplexer2 "~0.1.4" - fstream "^1.0.12" - graceful-fs "^4.2.2" - listenercount "~1.0.1" - readable-stream "~2.3.6" - setimmediate "~1.0.4" - -update-browserslist-db@^1.0.10: - version "1.0.10" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3" - integrity sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ== - dependencies: - escalade "^3.1.1" - picocolors "^1.0.0" - -upper-case-first@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-2.0.2.tgz#992c3273f882abd19d1e02894cc147117f844324" - integrity sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg== - dependencies: - tslib "^2.0.3" - -upper-case@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-2.0.2.tgz#d89810823faab1df1549b7d97a76f8662bae6f7a" - integrity sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg== - dependencies: - tslib "^2.0.3" - -uri-js@^4.2.2: - version "4.4.1" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" - integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== - dependencies: - punycode "^2.1.0" - -url-join@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.1.tgz#b642e21a2646808ffa178c4c5fda39844e12cde7" - integrity sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA== - -util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - -utila@~0.4: - version "0.4.0" - resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" - integrity sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA== - -validate-npm-package-license@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" - integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== - dependencies: - spdx-correct "^3.0.0" - spdx-expression-parse "^3.0.0" - -varstream@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/varstream/-/varstream-0.3.2.tgz#18ac6494765f3ff1a35ad9a4be053bec188a5de1" - integrity sha512-OpR3Usr9dGZZbDttlTxdviGdxiURI0prX68+DuaN/JfIDbK9ZOmREKM6PgmelsejMnhgjXmEEEgf+E4NbsSqMg== - dependencies: - readable-stream "^1.0.33" - -vary@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== - -vscode-uri@^3.0.7: - version "3.0.7" - resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.7.tgz#6d19fef387ee6b46c479e5fb00870e15e58c1eb8" - integrity sha512-eOpPHogvorZRobNqJGhapa0JdwaxpjVvyBp0QIUMRMSf8ZAlqOdEquKuRmw9Qwu0qXtJIWqFtMkmvJjUZmMjVA== - -warning@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c" - integrity sha512-jMBt6pUrKn5I+OGgtQ4YZLdhIeJmObddh6CsibPxyQ5yPZm1XExSyzC1LCNX7BzhxWgiHmizBWJTHJIjMjTQYQ== - dependencies: - loose-envify "^1.0.0" - -warning@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" - integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== - dependencies: - loose-envify "^1.0.0" - -watchpack@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" - integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== - dependencies: - glob-to-regexp "^0.4.1" - graceful-fs "^4.1.2" - -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - -webpack-bundle-analyzer@4.8.0: - version "4.8.0" - resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.8.0.tgz#951b8aaf491f665d2ae325d8b84da229157b1d04" - integrity sha512-ZzoSBePshOKhr+hd8u6oCkZVwpVaXgpw23ScGLFpR6SjYI7+7iIWYarjN6OEYOfRt8o7ZyZZQk0DuMizJ+LEIg== - dependencies: - "@discoveryjs/json-ext" "0.5.7" - acorn "^8.0.4" - acorn-walk "^8.0.0" - chalk "^4.1.0" - commander "^7.2.0" - gzip-size "^6.0.0" - lodash "^4.17.20" - opener "^1.5.2" - sirv "^1.0.7" - ws "^7.3.1" - -webpack-cli@5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.0.1.tgz#95fc0495ac4065e9423a722dec9175560b6f2d9a" - integrity sha512-S3KVAyfwUqr0Mo/ur3NzIp6jnerNpo7GUO6so51mxLi1spqsA17YcMXy0WOIJtBSnj748lthxC6XLbNKh/ZC+A== - dependencies: - "@discoveryjs/json-ext" "^0.5.0" - "@webpack-cli/configtest" "^2.0.1" - "@webpack-cli/info" "^2.0.1" - "@webpack-cli/serve" "^2.0.1" - colorette "^2.0.14" - commander "^9.4.1" - cross-spawn "^7.0.3" - envinfo "^7.7.3" - fastest-levenshtein "^1.0.12" - import-local "^3.0.2" - interpret "^3.1.1" - rechoir "^0.8.0" - webpack-merge "^5.7.3" - -webpack-merge@^5.7.3: - version "5.8.0" - resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.8.0.tgz#2b39dbf22af87776ad744c390223731d30a68f61" - integrity sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q== - dependencies: - clone-deep "^4.0.1" - wildcard "^2.0.0" - -webpack-node-externals@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz#1a3407c158d547a9feb4229a9e3385b7b60c9917" - integrity sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ== - -webpack-require-from@1.8.6: - version "1.8.6" - resolved "https://registry.yarnpkg.com/webpack-require-from/-/webpack-require-from-1.8.6.tgz#47dc065257c522abdf39715556c4f027ea749bed" - integrity sha512-QmRsOkOYPKeNXp4uVc7qxnPrFQPrP4bhOc/gl4QenTFNgXdEbF1U8VC+jM/Sljb0VzJLNgyNiHlVkuHjcmDtBQ== - -webpack-sources@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" - integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== - dependencies: - source-list-map "^2.0.0" - source-map "~0.6.1" - -webpack-sources@^3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" - integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== - -webpack@5.75.0: - version "5.75.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.75.0.tgz#1e440468647b2505860e94c9ff3e44d5b582c152" - integrity sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ== - dependencies: - "@types/eslint-scope" "^3.7.3" - "@types/estree" "^0.0.51" - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/wasm-edit" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" - acorn "^8.7.1" - acorn-import-assertions "^1.7.6" - browserslist "^4.14.5" - chrome-trace-event "^1.0.2" - enhanced-resolve "^5.10.0" - es-module-lexer "^0.9.0" - eslint-scope "5.1.1" - events "^3.2.0" - glob-to-regexp "^0.4.1" - graceful-fs "^4.2.9" - json-parse-even-better-errors "^2.3.1" - loader-runner "^4.2.0" - mime-types "^2.1.27" - neo-async "^2.6.2" - schema-utils "^3.1.0" - tapable "^2.1.1" - terser-webpack-plugin "^5.1.3" - watchpack "^2.4.0" - webpack-sources "^3.2.3" - -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - -which-boxed-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" - integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== - dependencies: - is-bigint "^1.0.1" - is-boolean-object "^1.1.0" - is-number-object "^1.0.4" - is-string "^1.0.5" - is-symbol "^1.0.3" - -which-typed-array@^1.1.9: - version "1.1.9" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.9.tgz#307cf898025848cf995e795e8423c7f337efbde6" - integrity sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA== - dependencies: - available-typed-arrays "^1.0.5" - call-bind "^1.0.2" - for-each "^0.3.3" - gopd "^1.0.1" - has-tostringtag "^1.0.0" - is-typed-array "^1.1.10" - -which@^2.0.1, which@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -wide-align@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" - integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== - dependencies: - string-width "^1.0.2 || 2 || 3 || 4" - -wildcard@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec" - integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw== - -word-wrap@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== - -wordwrap@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" - integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== - -workerpool@6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" - integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - -ws@^7.3.1: - version "7.5.9" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" - integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== - -xml2js@^0.4.23: - version "0.4.23" - resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" - integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== - dependencies: - sax ">=0.6.0" - xmlbuilder "~11.0.0" - -xmlbuilder@~11.0.0: - version "11.0.1" - resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" - integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== - -xtend@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - -yaml@^1.10.2, yaml@^1.7.2: - version "1.10.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== - -yargs-parser@20.2.4: - version "20.2.4" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" - integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== - -yargs-parser@^20.2.2, yargs-parser@^20.2.9: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - -yargs-parser@^21.1.1: - version "21.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - -yargs-unparser@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" - integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== - dependencies: - camelcase "^6.0.0" - decamelize "^4.0.0" - flat "^5.0.2" - is-plain-obj "^2.1.0" - -yargs@16.2.0: - version "16.2.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" - integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.0" - y18n "^5.0.5" - yargs-parser "^20.2.2" - -yargs@^17.3.1: - version "17.6.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.6.2.tgz#2e23f2944e976339a1ee00f18c77fedee8332541" - integrity sha512-1/9UrdHjDZc0eOU0HxOHoS78C69UD3JRMvzlJ7S79S2nTaWRA/whGCTV8o9e/N/1Va9YIV7Q4sOxD8VV4pCWOw== - dependencies: - cliui "^8.0.1" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" - y18n "^5.0.5" - yargs-parser "^21.1.1" - -yauzl@^2.3.1, yauzl@^2.4.2: - version "2.10.0" - resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" - integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== - dependencies: - buffer-crc32 "~0.2.3" - fd-slicer "~1.1.0" - -yazl@^2.2.2: - version "2.5.1" - resolved "https://registry.yarnpkg.com/yazl/-/yazl-2.5.1.tgz#a3d65d3dd659a5b0937850e8609f22fffa2b5c35" - integrity sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw== - dependencies: - buffer-crc32 "~0.2.3" - -ylru@^1.2.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785" - integrity sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA== - -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==